generated from boboko/starter
reviews photos clickable and contact form functionality
This commit is contained in:
@@ -59,6 +59,7 @@ MAIL_USERNAME=null
|
|||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
CONTACT_EMAIL=
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
|||||||
@@ -2,10 +2,71 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\ContactRequest;
|
||||||
|
use App\Mail\ContactConfirmationMail;
|
||||||
|
use App\Mail\ContactMessageMail;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact form. Nothing is stored: the message is emailed to the store
|
||||||
|
* (CONTACT_EMAIL) and the sender gets a generic confirmation. Both are sent
|
||||||
|
* synchronously so a failed store email can be reported back on the form.
|
||||||
|
*
|
||||||
|
* Limited per IP in here rather than with throttle middleware, so the limit
|
||||||
|
* shows as a message on the form instead of a bare 429 page.
|
||||||
|
*/
|
||||||
class ContactController extends Controller
|
class ContactController extends Controller
|
||||||
{
|
{
|
||||||
public function index(string $locale)
|
private const SEND_LIMIT = 3;
|
||||||
|
private const SEND_DECAY_SECONDS = 600;
|
||||||
|
|
||||||
|
public function index(string $locale): View
|
||||||
{
|
{
|
||||||
return view('contact');
|
return view('contact');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function send(string $locale, ContactRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$key = 'contact:'.$request->ip();
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($key, self::SEND_LIMIT)) {
|
||||||
|
return back()->withInput()->with('contact_error', __('storefront.contact.too_many'));
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit($key, self::SEND_DECAY_SECONDS);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
$to = config('services.contact.email');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (blank($to)) {
|
||||||
|
throw new \RuntimeException('CONTACT_EMAIL is not set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Mail::to($to)->send(new ContactMessageMail(
|
||||||
|
$data['name'],
|
||||||
|
$data['email'],
|
||||||
|
$data['message'],
|
||||||
|
$locale,
|
||||||
|
));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('Contact form: store email failed', ['exception' => $e]);
|
||||||
|
|
||||||
|
return back()->withInput()->with('contact_error', __('storefront.contact.send_failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The store already has the message, so a failed confirmation is only logged.
|
||||||
|
try {
|
||||||
|
Mail::to($data['email'])->locale($locale)->send(new ContactConfirmationMail);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('Contact form: confirmation email failed', ['exception' => $e]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('contact')->with('status', __('storefront.contact.sent'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ContactRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
'message' => ['required', 'string', 'max:5000'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent to whoever submitted the contact form. Deliberately generic: it never
|
||||||
|
* repeats what they wrote, so the form can't be used to deliver arbitrary text
|
||||||
|
* to someone else's inbox.
|
||||||
|
*/
|
||||||
|
class ContactConfirmationMail extends Mailable
|
||||||
|
{
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(subject: __('storefront.contact.confirmation_subject'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(view: 'emails.contact-confirmation');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Address;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A contact-form message, sent to the store's CONTACT_EMAIL. Reply-To is the
|
||||||
|
* sender, so replying from the inbox goes straight to them.
|
||||||
|
*/
|
||||||
|
class ContactMessageMail extends Mailable
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $senderName,
|
||||||
|
public readonly string $senderEmail,
|
||||||
|
public readonly string $body,
|
||||||
|
public readonly string $senderLocale,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
replyTo: [new Address($this->senderEmail, $this->senderName)],
|
||||||
|
subject: "Νέο μήνυμα επικοινωνίας από {$this->senderName}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(view: 'emails.contact-message');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,11 @@
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Where contact-form messages are sent (ContactController).
|
||||||
|
'contact' => [
|
||||||
|
'email' => env('CONTACT_EMAIL'),
|
||||||
|
],
|
||||||
|
|
||||||
'stoic' => [
|
'stoic' => [
|
||||||
'sso_secret' => env('STOIC_SSO_SECRET'),
|
'sso_secret' => env('STOIC_SSO_SECRET'),
|
||||||
'host' => env('STOIC_HOST'),
|
'host' => env('STOIC_HOST'),
|
||||||
|
|||||||
@@ -70,8 +70,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
{{-- Linked only while the product still exists and is published;
|
||||||
|
otherwise the name stays plain text (the order keeps it). --}}
|
||||||
|
@php($lineProduct = $line->purchasable?->product)
|
||||||
<p class="font-bold">
|
<p class="font-bold">
|
||||||
|
@if ($lineProduct?->status === 'published')
|
||||||
|
<a href="{{ route('product.show', ['id' => $lineProduct->id]) }}" class="underline hover:no-underline">{{ $line->description }}</a>
|
||||||
|
@else
|
||||||
{{ $line->description }}
|
{{ $line->description }}
|
||||||
|
@endif
|
||||||
<span class="font-normal">× {{ $line->quantity }}</span>
|
<span class="font-normal">× {{ $line->quantity }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,13 @@
|
|||||||
|
|
||||||
@if (!empty($review['image']))
|
@if (!empty($review['image']))
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
|
<a href="{{ $review['image'] }}" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
src="{{ $review['image'] }}"
|
src="{{ $review['image'] }}"
|
||||||
alt="Φωτογραφία από τον αγοραστή"
|
alt="Φωτογραφία από τον αγοραστή"
|
||||||
class="w-24 h-24 object-cover border border-black"
|
class="w-24 h-24 object-cover border border-black"
|
||||||
>
|
>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@@ -24,23 +24,35 @@
|
|||||||
Έχεις απορία, ιδέα ή θες να μας πεις κάτι; Γράψε μας το μήνυμά σου και θα σου απαντήσουμε το συντομότερο δυνατό.
|
Έχεις απορία, ιδέα ή θες να μας πεις κάτι; Γράψε μας το μήνυμά σου και θα σου απαντήσουμε το συντομότερο δυνατό.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="POST" action="#" class="flex flex-col gap-8 max-w-xl">
|
<x-ui.status class="max-w-xl" />
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('contact.send') }}" class="flex flex-col gap-8 max-w-xl">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||||
<x-ui.field label="Το όνομά σου" for="contact-name">
|
<x-ui.field label="Το όνομά σου" for="contact-name" :error="$errors->first('name')">
|
||||||
<x-ui.input id="contact-name" name="name" autocomplete="name" :required="true" />
|
<x-ui.input id="contact-name" name="name" autocomplete="name" maxlength="100" :value="old('name')" :required="true"
|
||||||
|
:aria-invalid="$errors->has('name') ? 'true' : null"
|
||||||
|
:aria-describedby="$errors->has('name') ? 'contact-name-error' : null" />
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
|
|
||||||
<x-ui.field label="Το email σου" for="contact-email">
|
<x-ui.field label="Το email σου" for="contact-email" :error="$errors->first('email')">
|
||||||
<x-ui.input id="contact-email" name="email" type="email" autocomplete="email" :required="true" />
|
<x-ui.input id="contact-email" name="email" type="email" autocomplete="email" maxlength="255" :value="old('email')" :required="true"
|
||||||
|
:aria-invalid="$errors->has('email') ? 'true' : null"
|
||||||
|
:aria-describedby="$errors->has('email') ? 'contact-email-error' : null" />
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-ui.field label="Το μήνυμά σου" for="contact-message">
|
<x-ui.field label="Το μήνυμά σου" for="contact-message" :error="$errors->first('message')">
|
||||||
<x-ui.textarea id="contact-message" name="message" rows="5" :required="true" />
|
<x-ui.textarea id="contact-message" name="message" rows="5" maxlength="5000" :required="true"
|
||||||
|
:aria-invalid="$errors->has('message') ? 'true' : null"
|
||||||
|
:aria-describedby="$errors->has('message') ? 'contact-message-error' : null">{{ old('message') }}</x-ui.textarea>
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
|
|
||||||
|
@if (session('contact_error'))
|
||||||
|
<p role="alert" class="text-sm text-red-600">{{ session('contact_error') }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-ui.button type="submit" size="md">Υποβολή</x-ui.button>
|
<x-ui.button type="submit" size="md">Υποβολή</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@extends('emails.layout')
|
||||||
|
|
||||||
|
@section('title', __('storefront.contact.confirmation_subject'))
|
||||||
|
|
||||||
|
@section('preheader', __('storefront.contact.confirmation_preheader'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||||
|
{{ __('storefront.contact.confirmation_heading') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin:0;">{{ __('storefront.contact.confirmation_body') }}</p>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('footer')
|
||||||
|
<p style="margin:0;">{{ __('storefront.contact.confirmation_footer') }}</p>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@extends('emails.layout')
|
||||||
|
|
||||||
|
@section('title', 'Νέο μήνυμα επικοινωνίας')
|
||||||
|
|
||||||
|
@section('preheader', "Από {$senderName} ({$senderEmail})")
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||||
|
Νέο μήνυμα επικοινωνίας
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin:0 0 4px;"><strong>Όνομα:</strong> {{ $senderName }}</p>
|
||||||
|
<p style="margin:0 0 4px;"><strong>Email:</strong> <a href="mailto:{{ $senderEmail }}" style="color:#000000;">{{ $senderEmail }}</a></p>
|
||||||
|
<p style="margin:0 0 24px;"><strong>Γλώσσα:</strong> {{ strtoupper($senderLocale) }}</p>
|
||||||
|
|
||||||
|
<p style="margin:0;white-space:pre-line;">{{ $body }}</p>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('footer')
|
||||||
|
<p style="margin:0;">Στάλθηκε από τη φόρμα επικοινωνίας του 3dealer. Απάντησε σε αυτό το email για να απαντήσεις στον αποστολέα.</p>
|
||||||
|
@endsection
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
Route::get('/search', [SearchController::class, 'show'])->name('search');
|
Route::get('/search', [SearchController::class, 'show'])->name('search');
|
||||||
|
|
||||||
Route::get('/contact', [ContactController::class, 'index'])->name('contact');
|
Route::get('/contact', [ContactController::class, 'index'])->name('contact');
|
||||||
|
Route::post('/contact', [ContactController::class, 'send'])->name('contact.send');
|
||||||
|
|
||||||
Route::get('/terms-and-conditions', [LegalPageController::class, 'terms'])->name(
|
Route::get('/terms-and-conditions', [LegalPageController::class, 'terms'])->name(
|
||||||
'legal.terms',
|
'legal.terms',
|
||||||
|
|||||||
Reference in New Issue
Block a user