diff --git a/README.md b/README.md new file mode 100644 index 0000000..c39a7d0 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# 3dealer + +Storefront for 3dealer.gr: Laravel 12 + Lunar PHP (headless), on top of `boboko/core`. Front-end conventions are in [CLAUDE.md](CLAUDE.md). + +## Scheduled jobs + +These run in the `scheduler` container (`php artisan schedule:work`). + +| Command | When | What it does | +|---|---|---| +| `custom-fields:prune-uploads` | Daily, 04:00 | Deletes custom-field photo uploads older than 24 h that no cart line or order line references. | +| `lunar:search:index` | Daily, 03:00 | Full product reindex. Registered by `boboko/core`. | + +## Product custom fields + +Admins can add custom fields to a product in the Lunar admin (the product's **Custom Fields** section, from `boboko/core`). The customer fills them in on the product page before adding the product to the cart. The answers are stored on the cart line (`meta.custom_fields`) and carried over to the order line. + +| Field type | Storefront input | Limit | +|---|---|---| +| Short text | text input | 255 characters | +| Long text | textarea | 2,000 characters | +| File upload | photo upload | JPG, PNG, WEBP, HEIC/HEIF, up to 10 MB | + +- **Photos upload as soon as they're picked.** They go to `POST /{locale}/custom-field-uploads`, which is limited to 20 per minute per client. They're stored on the private `local` disk under `storage/app/private/custom-field-uploads/`. Only an encrypted reference is sent with add-to-cart. The allowed types and size are set in `App\Http\Controllers\CustomFieldUploadController`. +- **Photos are never public.** The cart drawer, checkout summary and order confirmation link to a photo through a signed URL that expires after 2 hours. +- **Emails show text answers only**, never photos. +- **Photos are cleaned up automatically.** Photos never added to a cart are deleted by `custom-fields:prune-uploads` (see above). A photo on a cart line is kept as long as that cart line exists, and a photo on an order is kept indefinitely. +- **Products with custom fields can't be quick-added.** On product cards, the "add to cart" button becomes a link to the product page. +- Field labels are entered once in the admin and aren't translated, so they appear as entered in both `/el` and `/en`. diff --git a/app/Catalog/ProductCard.php b/app/Catalog/ProductCard.php index 74232d1..d5a5189 100644 --- a/app/Catalog/ProductCard.php +++ b/app/Catalog/ProductCard.php @@ -19,7 +19,7 @@ final class ProductCard { /** * @param array $product one item from ProductService's localized array shape - * @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int} + * @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int, hasCustomFields: bool} */ public static function fromIndexed(array $product): array { @@ -34,6 +34,10 @@ public static function fromIndexed(array $product): array // picker at listing-grid scope, unlike the product page's own // color swatches. 'variantId' => $product['variants'][0]['id'] ?? null, + // A product with custom fields (photo upload, engraving text…) + // can't be quick-added from a card — the card links to the + // product page instead, even when every field is optional. + 'hasCustomFields' => ! empty($product['custom_fields']), ]; } } diff --git a/app/Console/Commands/PruneCustomFieldUploads.php b/app/Console/Commands/PruneCustomFieldUploads.php new file mode 100644 index 0000000..da9872a --- /dev/null +++ b/app/Console/Commands/PruneCustomFieldUploads.php @@ -0,0 +1,73 @@ +referencedPaths(); + $cutoff = now()->subHours((int) $this->option('hours'))->getTimestamp(); + $deleted = 0; + + foreach ($disk->files(CustomFieldUploadController::DIRECTORY) as $path) { + if (isset($referenced[$path]) || $disk->lastModified($path) > $cutoff) { + continue; + } + + $disk->delete($path); + $deleted++; + } + + $this->info("Deleted {$deleted} unreferenced custom-field upload(s)."); + + return self::SUCCESS; + } + + /** + * @return array + */ + private function referencedPaths(): array + { + $paths = []; + + foreach ([CartLine::class, OrderLine::class] as $model) { + $model::query() + ->where('meta', 'like', '%custom_fields%') + ->select('meta') + ->cursor() + ->each(function ($line) use (&$paths) { + foreach ($line->meta['custom_fields'] ?? [] as $field) { + if (! empty($field['path'])) { + $paths[$field['path']] = true; + } + } + }); + } + + return $paths; + } +} diff --git a/app/Http/Controllers/Checkout/CartController.php b/app/Http/Controllers/Checkout/CartController.php index 8afcd5a..5b7e248 100644 --- a/app/Http/Controllers/Checkout/CartController.php +++ b/app/Http/Controllers/Checkout/CartController.php @@ -3,8 +3,14 @@ namespace App\Http\Controllers\Checkout; use App\Http\Controllers\Controller; +use Closure; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; use Illuminate\View\View; use Lunar\Exceptions\Carts\CartException; use Lunar\Models\CartLine; @@ -41,12 +47,19 @@ public function add(string $locale, Request $request): View|JsonResponse $data = $request->validate([ 'purchasable_id' => ['required', 'integer'], 'quantity' => ['nullable', 'integer', 'min:1'], + 'custom_fields' => ['nullable', 'array'], ]); $variant = ProductVariant::findOrFail($data['purchasable_id']); try { - $this->cart->addLine($variant, $data['quantity'] ?? 1); + $meta = $this->customFieldsMeta($variant, $data['custom_fields'] ?? []); + } catch (ValidationException $e) { + return response()->json(['error' => collect($e->errors())->flatten()->first()], 422); + } + + try { + $this->cart->addLine($variant, $data['quantity'] ?? 1, $meta); } catch (CartException) { return $this->stockError($variant); } @@ -54,6 +67,98 @@ public function add(string $locale, Request $request): View|JsonResponse return view('checkout::partials.cart-body'); } + /** + * The shopper's answers to the product's custom fields (boboko-core's + * Product::$custom_fields — {key, type: text|textarea|file, label, + * required}), as cart line meta. Lunar copies CartLine.meta onto the + * OrderLine at order creation, so this is also what the order keeps. + * + * Only keys the product actually defines are kept, nested under + * `custom_fields` — line meta also carries behavior flags (core's + * `saved_for_later` zeroes the line's price), so shopper input must never + * be merged into it directly. Label and type are snapshotted alongside + * each value so the cart/order still reads correctly if the product's + * fields are edited later. + * + * A `file` answer is the opaque reference returned by the host's upload + * endpoint: an encrypted JSON {disk, path, name, mime}. The module doesn't + * decide which files are acceptable (that's the host's upload endpoint) — + * it only checks the reference is genuine and the file still exists. + * + * Two adds with identical answers merge into one line (Lunar matches + * existing lines on meta); different answers stay separate lines. + */ + private function customFieldsMeta(ProductVariant $variant, array $input): array + { + $fields = collect($variant->product?->custom_fields ?? [])->keyBy('key'); + + if ($fields->isEmpty()) { + return []; + } + + $validated = Validator::make( + $input, + $fields->map(fn (array $field) => [ + ($field['required'] ?? false) ? 'required' : 'nullable', + 'string', + match ($field['type']) { + 'textarea' => 'max:2000', + 'file' => function (string $attribute, mixed $value, Closure $fail) { + if ($this->decodeUpload($value) === null) { + $fail('validation.uploaded')->translate(); + } + }, + default => 'max:255', + }, + ])->all(), + [], + $fields->map(fn (array $field) => $field['label'])->all(), + )->validate(); + + $answers = $fields + ->filter(fn (array $field) => filled($validated[$field['key']] ?? null)) + ->map(fn (array $field) => [ + 'key' => $field['key'], + 'label' => $field['label'], + 'type' => $field['type'], + ...($field['type'] === 'file' + ? $this->fileAnswer($this->decodeUpload($validated[$field['key']])) + : ['value' => $validated[$field['key']]]), + ]) + ->values() + ->all(); + + return $answers === [] ? [] : ['custom_fields' => $answers]; + } + + /** + * @return array{disk: string, path: string, name: string, mime: ?string}|null + */ + private function decodeUpload(string $reference): ?array + { + try { + $upload = json_decode(Crypt::decryptString($reference), true); + } catch (DecryptException) { + return null; + } + + if (! is_array($upload) || ! isset($upload['disk'], $upload['path'], $upload['name'])) { + return null; + } + + return Storage::disk($upload['disk'])->exists($upload['path']) ? $upload : null; + } + + private function fileAnswer(array $upload): array + { + return [ + 'value' => $upload['name'], + 'disk' => $upload['disk'], + 'path' => $upload['path'], + 'mime' => $upload['mime'] ?? null, + ]; + } + public function updateLine(string $locale, Request $request, int $line): View|JsonResponse { $quantity = (int) $request->validate([ diff --git a/app/Http/Controllers/Checkout/CustomFieldFileController.php b/app/Http/Controllers/Checkout/CustomFieldFileController.php new file mode 100644 index 0000000..65ab178 --- /dev/null +++ b/app/Http/Controllers/Checkout/CustomFieldFileController.php @@ -0,0 +1,28 @@ +query('disk')); + $path = (string) $request->query('path'); + + abort_unless($disk->exists($path), 404); + + return $disk->response($path, null, ['Cache-Control' => 'private, max-age=3600']); + } +} diff --git a/app/Http/Controllers/CustomFieldUploadController.php b/app/Http/Controllers/CustomFieldUploadController.php new file mode 100644 index 0000000..3a7dd35 --- /dev/null +++ b/app/Http/Controllers/CustomFieldUploadController.php @@ -0,0 +1,74 @@ +all(), + ['file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES]], + [], + ['file' => (string) $request->input('label', 'file')], + ); + + if ($validator->fails()) { + return response()->json(['error' => $validator->errors()->first('file')], 422); + } + + $file = $request->file('file'); + $path = $file->store(self::DIRECTORY, self::DISK); + + abort_if($path === false, 500); + + return response()->json([ + 'reference' => Crypt::encryptString(json_encode([ + 'disk' => self::DISK, + 'path' => $path, + 'name' => $file->getClientOriginalName(), + 'mime' => $file->getMimeType(), + ])), + ]); + } +} diff --git a/composer.lock b/composer.lock index 691d46c..700f0f5 100644 --- a/composer.lock +++ b/composer.lock @@ -515,11 +515,11 @@ }, { "name": "boboko/core", - "version": "0.19.0", + "version": "0.20.0", "source": { "type": "git", "url": "https://code.radical-elements.com/boboko/core.git", - "reference": "0437057e5d604e3c05479089071fcbe79ad69bb7" + "reference": "c7035d678275a6c7aae6eb2a2ee1b12569da1a3d" }, "require": { "laravel/framework": "^12.0", @@ -569,7 +569,7 @@ } }, "description": "Core module — authentication and shared panel behaviour", - "time": "2026-09-17T22:30:37+00:00" + "time": "2026-09-23T06:47:28+00:00" }, { "name": "brick/math", diff --git a/database/seeders/ValidationTranslationsSeeder.php b/database/seeders/ValidationTranslationsSeeder.php index e7a2367..acfd193 100644 --- a/database/seeders/ValidationTranslationsSeeder.php +++ b/database/seeders/ValidationTranslationsSeeder.php @@ -68,6 +68,11 @@ private function lines(): array 'max.string' => ['The :attribute field must not be greater than :max characters.', 'Το πεδίο :attribute δεν πρέπει να ξεπερνά τους :max χαρακτήρες.'], 'between.numeric' => ['The :attribute field must be between :min and :max.', 'Το πεδίο :attribute πρέπει να είναι μεταξύ :min και :max.'], 'exists' => ['The selected :attribute is invalid.', 'Η επιλεγμένη τιμή για το πεδίο :attribute δεν είναι έγκυρη.'], + // Product custom-field photo uploads (CustomFieldUploadController, CartController). + 'file' => ['The :attribute field must be a file.', 'Το πεδίο :attribute πρέπει να είναι αρχείο.'], + 'mimes' => ['The :attribute field must be a file of type: :values.', 'Το πεδίο :attribute πρέπει να είναι αρχείο τύπου: :values.'], + 'max.file' => ['The :attribute field must not be greater than :max kilobytes.', 'Το αρχείο στο πεδίο :attribute δεν πρέπει να ξεπερνά τα :max kilobytes.'], + 'uploaded' => ['The :attribute failed to upload.', 'Η μεταφόρτωση στο πεδίο :attribute απέτυχε.'], // ── Field names (checkout: billing/shipping address) ────────── 'attributes.contact_email' => ['email', 'email'], diff --git a/resources/css/checkout.css b/resources/css/checkout.css index 2fa900f..444ba98 100644 --- a/resources/css/checkout.css +++ b/resources/css/checkout.css @@ -215,6 +215,38 @@ .bbk-cart-item-variant { color: var(--bbk-color-muted); } +/* A line's custom-field answers (checkout::partials.line-custom-fields). */ +.bbk-line-fields { + display: grid; + gap: 0.25rem; + margin: 0 0 0.5rem; + font-size: 0.8125rem; +} + +.bbk-line-field dt { + color: var(--bbk-color-muted); +} + +.bbk-line-field dd { + margin: 0; + white-space: pre-line; + overflow-wrap: anywhere; +} + +.bbk-line-field-file { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: inherit; +} + +.bbk-line-field-file img { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 0; +} + .bbk-cart-item-unit { margin: 0 0 0.625rem; color: var(--bbk-color-muted); diff --git a/resources/js/stimulus/custom-field-upload-controller.js b/resources/js/stimulus/custom-field-upload-controller.js new file mode 100644 index 0000000..4770932 --- /dev/null +++ b/resources/js/stimulus/custom-field-upload-controller.js @@ -0,0 +1,108 @@ +import { Controller } from '@hotwired/stimulus' + +// One product custom field of type `file` (see x-product-custom-fields). +// Uploads the photo to the storefront's own endpoint (CustomFieldUploadController) +// as soon as it's picked, then writes the returned opaque reference into the +// hidden input the add-to-cart form actually submits — the checkout module +// never receives the file itself. +// +// While uploading, the file input is marked invalid via setCustomValidity(), +// so the browser's own form validation blocks add-to-cart until the reference +// is in place. A failed upload clears the input, so `required` blocks it too. +export default class extends Controller { + static targets = ['file', 'reference', 'preview', 'error'] + static values = { + url: String, + label: String, + uploadingMessage: String, + failedMessage: String, + } + + disconnect() { + this.abortController?.abort() + this.revokePreview() + } + + async upload() { + this.reset() + + const file = this.fileTarget.files[0] + if (!file) return + + const abortController = new AbortController() + this.abortController = abortController + + this.fileTarget.setCustomValidity(this.uploadingMessageValue) + this.fileTarget.setAttribute('aria-busy', 'true') + + const body = new FormData() + body.append('file', file) + body.append('label', this.labelValue) + + try { + const response = await fetch(this.urlValue, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', + 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', + }, + body, + signal: abortController.signal, + }) + const data = await response.json().catch(() => null) + + if (!response.ok || !data?.reference) { + this.fail(data?.error) + return + } + + this.referenceTarget.value = data.reference + this.showPreview(file) + } catch (error) { + // A newer pick superseded this upload — reset() already handled it. + if (error.name === 'AbortError') return + this.fail() + } finally { + if (!abortController.signal.aborted) this.markIdle() + } + } + + reset() { + this.abortController?.abort() + this.referenceTarget.value = '' + this.errorTarget.hidden = true + this.markIdle() + this.revokePreview() + } + + fail(message) { + this.fileTarget.value = '' + this.errorTarget.textContent = message || this.failedMessageValue + this.errorTarget.hidden = false + } + + markIdle() { + this.fileTarget.setCustomValidity('') + this.fileTarget.removeAttribute('aria-busy') + } + + showPreview(file) { + this.previewUrl = URL.createObjectURL(file) + this.previewTarget.src = this.previewUrl + this.previewTarget.hidden = false + } + + // Formats the browser can't render (HEIC outside Safari) — the file + // input's own filename is enough there. + hidePreview() { + this.previewTarget.hidden = true + } + + revokePreview() { + if (this.previewUrl) URL.revokeObjectURL(this.previewUrl) + this.previewUrl = null + this.previewTarget.removeAttribute('src') + this.previewTarget.hidden = true + } +} diff --git a/resources/js/stimulus/index.js b/resources/js/stimulus/index.js index f9ed941..de8ce1c 100644 --- a/resources/js/stimulus/index.js +++ b/resources/js/stimulus/index.js @@ -8,6 +8,7 @@ import AutoSubmitController from './auto-submit-controller' import BackToTopController from './back-to-top-controller' import CartCountController from './cart-count-controller' import CarouselController from './carousel-controller' +import CustomFieldUploadController from './custom-field-upload-controller' import DropdownController from './dropdown-controller' import FrameScrollController from './frame-scroll-controller' import NavSearchController from './nav-search-controller' @@ -25,6 +26,7 @@ export function registerControllers(application) { application.register('back-to-top', BackToTopController) application.register('cart-count', CartCountController) application.register('carousel', CarouselController) + application.register('custom-field-upload', CustomFieldUploadController) application.register('dropdown', DropdownController) application.register('frame-scroll', FrameScrollController) application.register('nav-search', NavSearchController) diff --git a/resources/views/checkout/confirmation.blade.php b/resources/views/checkout/confirmation.blade.php index c76d4c1..e5769a6 100644 --- a/resources/views/checkout/confirmation.blade.php +++ b/resources/views/checkout/confirmation.blade.php @@ -60,6 +60,8 @@ @if ($line->option)

{{ $line->option }}

@endif + + @include('checkout::partials.line-custom-fields', ['line' => $line]) {{ $line->sub_total?->formatted() }} diff --git a/resources/views/checkout/partials/cart-line.blade.php b/resources/views/checkout/partials/cart-line.blade.php index 93a373a..df76966 100644 --- a/resources/views/checkout/partials/cart-line.blade.php +++ b/resources/views/checkout/partials/cart-line.blade.php @@ -43,6 +43,7 @@ @if ($variantLabel)

{{ $variantLabel }}

@endif + @include('checkout::partials.line-custom-fields', ['line' => $line])

{{ $line->unitPrice?->formatted() }}

$line]) + + A cart or order line's custom-field answers (meta.custom_fields, written by + CartController::customFieldsMeta()) — label/value pairs. A file answer links + to the file through a temporary signed URL (CustomFieldFileController), + minted fresh on every render, with a thumbnail when the browser can display + the format (HEIC can't be shown outside Safari, so it gets the name only). +--}} +@php + $fields = $line->meta['custom_fields'] ?? []; + $previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; +@endphp + +@if (! empty($fields)) +
+ @foreach ($fields as $field) +
+
{{ $field['label'] }}
+
+ @if ($field['type'] === 'file') + @php + $fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute( + 'checkout.custom-field-file', + now()->addHours(2), + ['locale' => app()->getLocale(), 'disk' => $field['disk'], 'path' => $field['path']], + ); + @endphp + + @if (in_array($field['mime'] ?? null, $previewable, true)) + + @endif + {{ $field['value'] }} + + @else + {{ $field['value'] }} + @endif +
+
+ @endforeach +
+@endif diff --git a/resources/views/components/product-custom-fields.blade.php b/resources/views/components/product-custom-fields.blade.php new file mode 100644 index 0000000..9f42047 --- /dev/null +++ b/resources/views/components/product-custom-fields.blade.php @@ -0,0 +1,76 @@ +{{-- + + + The product's custom fields (boboko-core's Product::$custom_fields), rendered + inside the add-to-cart so their values travel with it as + custom_fields[key]. text → input, textarea → textarea, file → photo upload. + + A photo is uploaded as soon as it's picked (custom-field-upload-controller.js) + and only the returned reference is submitted with the form — the file input + itself has no name. It still carries `required`, so the browser's own + validation blocks add-to-cart until a photo is picked, and the controller + marks it invalid (setCustomValidity) while the upload is in flight. +--}} +@props(['fields' => []]) + +@use('App\Http\Controllers\CustomFieldUploadController') + +
+ @foreach ($fields as $field) + @php + $id = 'custom-field-'.$field['key']; + $name = 'custom_fields['.$field['key'].']'; + $required = (bool) ($field['required'] ?? false); + @endphp + + @switch($field['type']) + @case('textarea') + + + + @break + + @case('file') + + + + + + + + + + @break + + @default + + + + @endswitch + @endforeach +
diff --git a/resources/views/components/product-grid.blade.php b/resources/views/components/product-grid.blade.php index 4eea0b1..0055cea 100644 --- a/resources/views/components/product-grid.blade.php +++ b/resources/views/components/product-grid.blade.php @@ -25,6 +25,7 @@ :image="$product['image'] ?? null" :href="$product['href'] ?? '#'" :variant-id="$product['variantId'] ?? null" + :has-custom-fields="$product['hasCustomFields'] ?? false" /> @endforeach diff --git a/resources/views/components/ui/file-input.blade.php b/resources/views/components/ui/file-input.blade.php new file mode 100644 index 0000000..a310119 --- /dev/null +++ b/resources/views/components/ui/file-input.blade.php @@ -0,0 +1,20 @@ +@props([ + 'accept' => null, + 'required' => false, + 'disabled' => false, +]) + +merge([ + 'class' => 'w-full py-2 text-sm + file:mr-4 file:py-2 file:px-4 file:rounded-none file:border file:border-black file:bg-transparent + file:font-semibold file:cursor-pointer file:transition-colors + hover:file:bg-black hover:file:text-neutral-200 + focus:outline-none focus-visible:ring-2 focus-visible:ring-black + disabled:cursor-not-allowed disabled:opacity-50', + ]) }} +/> diff --git a/resources/views/components/ui/product-card.blade.php b/resources/views/components/ui/product-card.blade.php index bf492cb..9be5dd4 100644 --- a/resources/views/components/ui/product-card.blade.php +++ b/resources/views/components/ui/product-card.blade.php @@ -4,6 +4,7 @@ 'image' => null, 'href' => '#', 'variantId' => null, + 'hasCustomFields' => false, ]) {{-- data-turbo-frame="_top" on the links: this card renders inside the @@ -28,7 +29,18 @@ class="w-full h-auto block" @endif - @if ($variantId) + @if ($hasCustomFields) + {{-- Custom fields have to be filled in on the product page. --}} + + {{ __('storefront.product.personalize') }} + + @elseif ($variantId) {{-- has-[...] forces the button visible while an add-to-cart error is showing, so it isn't only readable on hover — a shopper who already moved off the card (mouse or the click itself) must diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php index 307ab0e..d0e8dc3 100644 --- a/resources/views/home.blade.php +++ b/resources/views/home.blade.php @@ -60,7 +60,16 @@ class="w-full h-full object-cover" @endif - @if ($product['variantId'] ?? null) + @if ($product['hasCustomFields'] ?? false) + + {{ __('storefront.product.personalize') }} + + @elseif ($product['variantId'] ?? null) @endforeach diff --git a/resources/views/product/show.blade.php b/resources/views/product/show.blade.php index 2df807f..59350d0 100644 --- a/resources/views/product/show.blade.php +++ b/resources/views/product/show.blade.php @@ -166,7 +166,7 @@ class="absolute bottom-6 right-8 text-white text-sm" @endif @php - $desc = strip_tags($product['description'] ?? ''); + $desc = html_entity_decode(strip_tags($product['description'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $descTruncated = Str::limit($desc, 137); $descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.')); @endphp @@ -195,10 +195,16 @@ class="underline-slide font-semibold whitespace-nowrap" - - {{ __('storefront.product.add_to_cart') }} + @if(!empty($product['custom_fields'])) + + @endif + +
+ + {{ __('storefront.product.add_to_cart') }} +
{{-- Storefront-owned, not part of the checkout module — the diff --git a/resources/views/vendor/core/order/notifications/placed.blade.php b/resources/views/vendor/core/order/notifications/placed.blade.php index 0e1b7aa..de0fad6 100644 --- a/resources/views/vendor/core/order/notifications/placed.blade.php +++ b/resources/views/vendor/core/order/notifications/placed.blade.php @@ -4,7 +4,15 @@
    @foreach ($lines as $line) -
  • {{ $line->quantity }} × {{ $line->description }} — {{ $line->total?->formatted }}
  • +
  • + {{ $line->quantity }} × {{ $line->description }} — {{ $line->total?->formatted }} + {{-- Text answers only — photo answers are deliberately left out of emails. --}} + @foreach ($line->meta['custom_fields'] ?? [] as $field) + @if ($field['type'] !== 'file') +
    {{ $field['label'] }}: {!! nl2br(e($field['value'])) !!} + @endif + @endforeach +
  • @endforeach
diff --git a/routes/checkout.php b/routes/checkout.php index 47f8f36..24bc048 100644 --- a/routes/checkout.php +++ b/routes/checkout.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Checkout\CartController; use App\Http\Controllers\Checkout\CheckoutController; +use App\Http\Controllers\Checkout\CustomFieldFileController; use Illuminate\Support\Facades\Route; /* @@ -52,6 +53,12 @@ ->whereNumber('line') ->name('checkout.cart.remove'); + // A line's custom-field file (e.g. the shopper's photo) — signed, + // temporary URLs only; see CustomFieldFileController. + Route::get('cart/custom-field-file', CustomFieldFileController::class) + ->middleware('signed') + ->name('checkout.custom-field-file'); + Route::post('cart/coupon', [CartController::class, 'applyCoupon']) ->name('checkout.cart.coupon.apply'); diff --git a/routes/console.php b/routes/console.php index 3c9adf1..2d129d9 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,12 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Deletes custom-field photo uploads (CustomFieldUploadController) older than +// 24h that no cart or order line references — see PruneCustomFieldUploads. +Schedule::command('custom-fields:prune-uploads')->dailyAt('04:00'); diff --git a/routes/web.php b/routes/web.php index 908da3d..4e0e5a4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\CategoryController; use App\Http\Controllers\ContactController; +use App\Http\Controllers\CustomFieldUploadController; use App\Http\Controllers\HomeController; use App\Http\Controllers\LegalPageController; use App\Http\Controllers\ProductController; @@ -33,6 +34,13 @@ 'product.stock-check', ); + // Photo for a product custom field, uploaded as soon as it's picked — + // see CustomFieldUploadController. Throttled: it writes to disk and + // needs no cart/session to call. + Route::post('/custom-field-uploads', [CustomFieldUploadController::class, 'store']) + ->middleware('throttle:20,1') + ->name('custom-field-upload.store'); + Route::post('/products/{product}/reviews', [ProductController::class, 'storeReview'])->name( 'product.reviews.store', );