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_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
CONTACT_EMAIL=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
||||
@@ -2,10 +2,71 @@
|
||||
|
||||
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
|
||||
{
|
||||
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');
|
||||
}
|
||||
|
||||
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' => [
|
||||
'sso_secret' => env('STOIC_SSO_SECRET'),
|
||||
'host' => env('STOIC_HOST'),
|
||||
|
||||
@@ -70,8 +70,15 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ $line->description }}
|
||||
@if ($lineProduct?->status === 'published')
|
||||
<a href="{{ route('product.show', ['id' => $lineProduct->id]) }}" class="underline hover:no-underline">{{ $line->description }}</a>
|
||||
@else
|
||||
{{ $line->description }}
|
||||
@endif
|
||||
<span class="font-normal">× {{ $line->quantity }}</span>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
|
||||
@if (!empty($review['image']))
|
||||
<div class="mt-1">
|
||||
<img
|
||||
src="{{ $review['image'] }}"
|
||||
alt="Φωτογραφία από τον αγοραστή"
|
||||
class="w-24 h-24 object-cover border border-black"
|
||||
>
|
||||
<a href="{{ $review['image'] }}" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src="{{ $review['image'] }}"
|
||||
alt="Φωτογραφία από τον αγοραστή"
|
||||
class="w-24 h-24 object-cover border border-black"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -24,23 +24,35 @@
|
||||
Έχεις απορία, ιδέα ή θες να μας πεις κάτι; Γράψε μας το μήνυμά σου και θα σου απαντήσουμε το συντομότερο δυνατό.
|
||||
</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
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
<x-ui.field label="Το όνομά σου" for="contact-name">
|
||||
<x-ui.input id="contact-name" name="name" autocomplete="name" :required="true" />
|
||||
<x-ui.field label="Το όνομά σου" for="contact-name" :error="$errors->first('name')">
|
||||
<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 label="Το email σου" for="contact-email">
|
||||
<x-ui.input id="contact-email" name="email" type="email" autocomplete="email" :required="true" />
|
||||
<x-ui.field label="Το email σου" for="contact-email" :error="$errors->first('email')">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<x-ui.field label="Το μήνυμά σου" for="contact-message">
|
||||
<x-ui.textarea id="contact-message" name="message" rows="5" :required="true" />
|
||||
<x-ui.field label="Το μήνυμά σου" for="contact-message" :error="$errors->first('message')">
|
||||
<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>
|
||||
|
||||
@if (session('contact_error'))
|
||||
<p role="alert" class="text-sm text-red-600">{{ session('contact_error') }}</p>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<x-ui.button type="submit" size="md">Υποβολή</x-ui.button>
|
||||
</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('/contact', [ContactController::class, 'index'])->name('contact');
|
||||
Route::post('/contact', [ContactController::class, 'send'])->name('contact.send');
|
||||
|
||||
Route::get('/terms-and-conditions', [LegalPageController::class, 'terms'])->name(
|
||||
'legal.terms',
|
||||
|
||||
Reference in New Issue
Block a user