Compare commits

..
4 Commits
11 changed files with 543 additions and 72 deletions
+22
View File
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.20.2] - 2026-09-25
### Changed
- Product custom fields (`Product::$custom_fields`) moved off the main product edit form onto
their own "Custom Fields" sub-page (`Modules\Core\Catalog\Filament\Pages\
ManageProductCustomFields`), alongside "Reviews" — the same admin pattern, registered from the
same `Review\Filament\Extensions\ProductResourceExtension` (CorePlugin only allows one
extension class per Lunar resource, and Review's already owns this one).
- Each custom field's `label` and new `help_text` are now translatable per storefront language
(`{locale: string}`, e.g. `{en: "...", el: "..."}`) instead of a single plain string — entered
as a plain `TextInput` per configured language rather than Lunar's `TranslatedText` form
component, which turned out to only resolve its state path correctly as a top-level form
field, not nested inside a `Repeater` item (every value silently failed to save under that
combination). `help_text` is optional and, unlike `label`, shown only on the product page, not
the cart or checkout.
- `Modules\Core\Catalog\Support\ProductDocumentLocalizer::withLocalizedFields()` now also
resolves each `custom_fields` item's `label`/`help_text` to a single string for the current
locale (falling back to the store's default language), the same `filled()`-over-`??` way as
every other translated field — the storefront and cart still only ever see one resolved
string per field, unaware the admin-side value became translatable. A product's custom fields
saved before this change (plain string `label`, no `help_text`) still resolve correctly.
## [0.20.1] - 2026-09-24 ## [0.20.1] - 2026-09-24
### Fixed ### Fixed
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.20.1", "version": "0.20.2",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -43,6 +43,7 @@
"Modules\\Core\\Providers\\CatalogServiceProvider", "Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\CartServiceProvider", "Modules\\Core\\Providers\\CartServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider", "Modules\\Core\\Providers\\ReviewServiceProvider",
"Modules\\Core\\Providers\\FileServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider", "Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider", "Modules\\Core\\Providers\\OrderServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider" "Modules\\Core\\Providers\\PrivacyServiceProvider"
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A generic, storage-backend-agnostic file registry — Modules\Core\Files\
* Services\FileService's own backing table. `disk`/`path` are whatever
* Laravel's Storage facade already understands (local, s3, ...); this
* table adds what Flysystem itself has no concept of: who a file
* belongs to, why it was uploaded, and whether anything still needs it.
*
* `owner_type`/`owner_id` are nullable — a file can (and, for a product
* custom-field photo, always does) exist before anything owns it yet: a
* shopper picks a photo on the product page and it's uploaded immediately
* (see 3dealer's CustomFieldUploadController), well before add-to-cart
* gives it a CartLine to belong to. FileService::attachOwner() re-points
* these columns once an owner exists, rather than creating a second row
* for the same physical file.
*
* `purpose` (e.g. 'custom-field-upload') lets one table serve unrelated
* future features without collision — FileService itself has no
* knowledge of what a purpose means, callers scope their own queries by
* it.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('file_uploads', function (Blueprint $table) {
$table->id();
$table->string('disk');
$table->string('path');
$table->string('original_name')->nullable();
$table->string('mime')->nullable();
$table->unsignedBigInteger('size')->nullable();
$table->string('purpose');
$table->nullableMorphs('owner');
$table->timestamps();
$table->index(['purpose', 'owner_type', 'owner_id']);
});
}
public function down(): void
{
Schema::dropIfExists('file_uploads');
}
};
@@ -0,0 +1,162 @@
<?php
namespace Modules\Core\Catalog\Filament\Pages;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Support\Pages\BaseEditRecord;
use Lunar\Models\Language;
/**
* Own sub-page for Product::$custom_fields (see that column's own docblock
* on Modules\Core\Catalog\Models\Product) — used to be a collapsible
* Section inline on the main product edit form (Review\Filament\
* Extensions\ProductResourceExtension::extendForm()), moved out to match
* how Reviews already gets its own sub-page (ManageProductReviews) rather
* than crowding the main form with a second unrelated concern.
*
* Deliberately no ->statePath('') override, no custom mount()/
* handleRecordUpdate() — EditRecord::mount() already fills the form from
* $record->attributesToArray() (which includes custom_fields, a real cast
* + fillable column) onto the default 'data' statePath, and save() reads
* it straight back off via $this->form->getState(). An earlier version of
* this page used ->statePath('') to bind the repeater directly to the
* record's attributes (copying ManageProductPricing) — that repointed the
* Repeater at $this->data['custom_fields'] AS THE ROOT state path itself,
* so every "add item" click re-filled the whole form from the record's
* still-unsaved value and immediately discarded the new row before it
* ever reached the page. Reverting to the plain default form/statePath is
* both simpler and is what actually works — same as the original inline
* repeater on the main product form did before this became its own page.
*
* Registered from Review\Filament\Extensions\ProductResourceExtension, not
* here — CorePlugin only allows one extension class per Lunar resource,
* and Review's already owns ProductResource's extension slot (see that
* class's own docblock).
*
* `label`/`help_text` are each stored as {locale: string} (e.g. {en: "...",
* el: "..."}) — see translatedField()'s own docblock for why that's a
* hand-rolled TextInput per language rather than Lunar's TranslatedText
* component. A product saved before this change still has a plain string
* `label` and no `help_text` at all; itemLabel() below tolerates both
* shapes, and the storefront/cart resolve either shape the same way (see
* product-custom-fields.blade.php and CartController::
* customFieldsMeta()). `key`/`type`/`required` stay plain, single values —
* only shopper-facing copy needs a translation, not the field's own
* machine-facing configuration.
*/
class ManageProductCustomFields extends BaseEditRecord
{
protected static string $resource = ProductResource::class;
public static function getNavigationIcon(): ?string
{
return 'heroicon-o-adjustments-horizontal';
}
public function getTitle(): string
{
return 'Custom Fields';
}
public static function getNavigationLabel(): string
{
return 'Custom Fields';
}
/**
* Without this, Filament's EditRecord defaults to every relation
* manager the WHOLE ProductResource defines (see HasRelationManagers::
* getAllRelationManagers(), which reads ProductResource::getRelations()
* regardless of which sub-page is rendering) — Channels, Customer
* Groups, Media, Pricing tabs all bleeding onto this page alongside the
* repeater below. This page has no relations of its own.
*/
public function getRelationManagers(): array
{
return [];
}
/**
* A plain TextInput per configured language, named "{$field}.{locale}"
* so it resolves to a normal nested array under the repeater item
* (custom_fields.{item}.label.en, .label.el, ...) — NOT Lunar's
* TranslatedText component. That component's per-locale sub-fields
* set their own statePath to just the locale code itself
* (TranslatedText::prepareTranslateLocaleComponent()), which only
* resolves correctly when TranslatedText is used as a single
* top-level named field directly on a form's root state (exactly how
* every existing usage in this codebase uses it — Lunar's own
* product name/description). Nested inside a Repeater item here, that
* same statePath resolution silently failed to nest under the item's
* own label/help_text key at all, and every typed value was lost on
* save. Hand-rolling the per-locale inputs sidesteps that assumption
* entirely.
*/
private function translatedField(string $field, string $label, string $helperText, bool $required): Group
{
$languages = Language::orderBy('default', 'desc')->get(['code', 'name', 'default']);
return Group::make(
$languages->map(fn (Language $language, int $index) => TextInput::make("{$field}.{$language->code}")
->label($index === 0 ? $label : null)
->hiddenLabel($index !== 0)
->helperText($index === 0 ? $helperText : null)
->prefix(Str::upper($language->code))
->required($required && $language->default))->values()->all(),
)
->columnSpanFull();
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Custom Fields')
->description('Extra input the shopper fills in on this product\'s page before adding it to their cart — a reference photo, personalization text, etc.')
->schema([
Repeater::make('custom_fields')
->hiddenLabel()
->schema([
$this->translatedField('label', 'Label', 'Shown to the shopper above the field. Only the current storefront locale is shown on the cart and checkout.', required: true),
$this->translatedField('help_text', 'Help text', 'Optional — shown under the label on the product page only, not on the cart or checkout.', required: false),
Select::make('type')
->label('Field type')
->options([
'text' => 'Short text',
'textarea' => 'Long text',
'file' => 'File upload',
])
->default('text')
->native(false)
->live()
->required(),
TextInput::make('key')
->label('Key')
->helperText('Machine-facing identifier — stored on the order/cart line, used to look up this answer elsewhere. Cannot be changed once orders reference it.')
->required()
->alphaDash()
->maxLength(64),
Toggle::make('required')
->label('Required')
->helperText('Shopper cannot add this product to their cart without answering.')
->default(false),
])
->columns(2)
->addActionLabel('Add a custom field')
->reorderable()
->collapsible()
->itemLabel(fn (array $state): ?string => is_array($state['label'] ?? null)
? collect($state['label'])->first(fn ($value) => filled($value))
: ($state['label'] ?? null)),
]),
]);
}
}
@@ -66,9 +66,47 @@ class ProductDocumentLocalizer
} }
} }
if (! empty($product['custom_fields'])) {
$product['custom_fields'] = $this->localizeCustomFields($product['custom_fields'], $locale, $fallbackLocale);
}
return $product; return $product;
} }
/**
* Product::$custom_fields isn't an AttributeManifest attribute (it's a
* plain JSON column, see Catalog\Models\Product's own docblock), so it
* never goes through the {handle}_{locale} explosion above — the
* indexer copies it straight through (see ProductIndexer), meaning
* each item's `label`/`help_text` still arrives here as a raw
* {locale: string} object (or, for a product saved before those
* became translatable, a plain string). Resolved the same filled()-
* over-?? way as every other translated field above, to the same
* single current-locale string the storefront/cart already expect
* (see product-custom-fields.blade.php and CartController::
* customFieldsMeta()) — a repeater item has no other reason to reach
* the storefront untouched.
*
* @param array<int, array<string, mixed>> $fields
* @return array<int, array<string, mixed>>
*/
private function localizeCustomFields(array $fields, string $locale, ?string $fallbackLocale): array
{
return array_map(function (array $field) use ($locale, $fallbackLocale) {
foreach (['label', 'help_text'] as $key) {
if (! is_array($field[$key] ?? null)) {
continue;
}
$field[$key] = filled($field[$key][$locale] ?? null)
? $field[$key][$locale]
: ($field[$key][$fallbackLocale] ?? null);
}
return $field;
}, $fields);
}
/** /**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response * For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the * (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Modules\Core\File\Adapters;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Modules\Core\File\Contracts\FileAdapterInterface;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Wraps Laravel's own 'local' Storage disk — see FileAdapterInterface's
* own docblock for why this exists as a named adapter rather than every
* caller reaching for Storage::disk('local') directly: swapping to a
* different backend later (S3FileAdapter, say) means adding one class and
* one contextual-binding entry, touching nothing that already uses
* FileService.
*/
class LocalFileAdapter implements FileAdapterInterface
{
public function __construct(
private readonly Filesystem $disk,
) {}
public function store(UploadedFile $file, string $directory): string
{
return $this->disk->putFile($directory, $file);
}
public function exists(string $path): bool
{
return $this->disk->exists($path);
}
public function delete(string $path): void
{
$this->disk->delete($path);
}
public function retrieve(string $path, ?string $name = null): StreamedResponse
{
return $this->disk->response($path, $name);
}
}
@@ -0,0 +1,38 @@
<?php
namespace Modules\Core\File\Contracts;
use Illuminate\Http\UploadedFile;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* One storage backend's actual byte-level operations — a disk name (see
* Modules\Core\File\Models\File::$disk) resolves to exactly one
* implementation of this via Modules\Core\File\Services\FileService's own
* contextual binding (see Providers\FileServiceProvider), the same
* pattern Shipping\Contracts\CarrierFulfillmentInterface uses to pick an
* AcsFulfillmentService/BoxNowFulfillmentService per carrier. FileService
* itself never touches a disk directly — every backend-specific detail
* (a local path, an S3 bucket/region, ...) lives entirely inside one
* adapter, so adding a new backend never touches FileService or any of
* its callers.
*/
interface FileAdapterInterface
{
/**
* Stores the file under $directory, returning the path to record on
* the File row (Models\File::$path) — backend-specific (a relative
* local path, an S3 object key, ...), meaningful only to this same
* adapter.
*/
public function store(UploadedFile $file, string $directory): string;
public function exists(string $path): bool;
public function delete(string $path): void;
/**
* Streams the file at $path straight to the browser.
*/
public function retrieve(string $path, ?string $name = null): StreamedResponse;
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\File\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* A row per physical file Modules\Core\File\Services\FileService has
* stored — see that migration's own docblock for why `owner_type`/
* `owner_id` are nullable and what `purpose` is for.
*/
class File extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'size' => 'integer',
];
}
public function owner(): MorphTo
{
return $this->morphTo();
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace Modules\Core\File\Services;
use Illuminate\Contracts\Container\Container;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Modules\Core\File\Contracts\FileAdapterInterface;
use Modules\Core\File\Models\File;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Orchestrates the `files` table (ownership, purpose, cleanup — see that
* migration's own docblock) on top of whichever FileAdapterInterface a
* disk resolves to (see Providers\FileServiceProvider's contextual
* binding) — never touches a disk or a raw path itself. Knows nothing
* about custom fields, carts, or orders specifically; every caller
* (3dealer's custom-field upload flow today, some other future
* file-upload need tomorrow) supplies its own `purpose` string and owner
* model, and scopes its own queries by them.
*/
class FileService
{
public function __construct(
private readonly Container $container,
) {}
public function store(UploadedFile $file, string $purpose, string $directory, string $disk = 'local'): File
{
$path = $this->adapter($disk)->store($file, $directory);
return File::create([
'disk' => $disk,
'path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime' => $file->getMimeType(),
'size' => $file->getSize(),
'purpose' => $purpose,
]);
}
/**
* Re-points an existing File at its owner — called once an owner
* actually exists (e.g. a shopper's picked-but-not-yet-added photo
* gets a CartLine the moment it's added to the cart). Never creates a
* second row for the same physical file.
*/
public function attachOwner(File $file, Model $owner): File
{
$file->update([
'owner_type' => $owner->getMorphClass(),
'owner_id' => $owner->getKey(),
]);
return $file;
}
public function retrieve(File $file): StreamedResponse
{
return $this->adapter($file->disk)->retrieve($file->path, $file->original_name);
}
public function exists(File $file): bool
{
return $this->adapter($file->disk)->exists($file->path);
}
public function delete(File $file): void
{
$this->adapter($file->disk)->delete($file->path);
$file->delete();
}
/**
* @return Collection<int, File>
*/
public function list(string $purpose, ?Model $owner = null): Collection
{
return File::query()
->where('purpose', $purpose)
->when($owner, fn ($query) => $query
->where('owner_type', $owner->getMorphClass())
->where('owner_id', $owner->getKey()))
->get();
}
/**
* Deletes every File of the given purpose that has no owner yet and
* is older than $olderThan — the grace period covers a shopper still
* on the page with a picked-but-not-yet-added file. An owned File
* (whatever the owner type) is never touched here; callers that want
* owned files gone too should delete() them explicitly wherever that
* ownership itself ends (e.g. a CartLine being removed).
*
* @return int number of files deleted
*/
public function pruneUnowned(string $purpose, Carbon $olderThan): int
{
$files = File::query()
->where('purpose', $purpose)
->whereNull('owner_type')
->where('created_at', '<', $olderThan)
->get();
foreach ($files as $file) {
$this->delete($file);
}
return $files->count();
}
private function adapter(string $disk): FileAdapterInterface
{
return $this->container->make(FileAdapterInterface::class, ['disk' => $disk]);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\ServiceProvider;
use InvalidArgumentException;
use Modules\Core\File\Adapters\LocalFileAdapter;
use Modules\Core\File\Contracts\FileAdapterInterface;
class FileServiceProvider extends ServiceProvider
{
public function register(): void
{
// Same pattern as ShippingServiceProvider's CarrierFulfillmentInterface
// binding — a plain param ('disk' here, 'carrier' there) picks which
// concrete adapter Modules\Core\File\Services\FileService actually
// talks to. Adding a real S3FileAdapter later is one class plus one
// more match arm here; nothing that already calls FileService changes.
$this->app->bind(FileAdapterInterface::class, function ($app, array $params) {
$disk = $params['disk'] ?? 'local';
return match ($disk) {
'local' => new LocalFileAdapter(Storage::disk($disk)),
default => throw new InvalidArgumentException("No FileAdapterInterface available for disk \"{$disk}\"."),
};
});
}
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../../database/migrations');
}
}
@@ -2,94 +2,30 @@
namespace Modules\Core\Review\Filament\Extensions; namespace Modules\Core\Review\Filament\Extensions;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Catalog\Filament\Pages\ManageProductCustomFields;
use Modules\Core\Review\Filament\Pages\ManageProductReviews; use Modules\Core\Review\Filament\Pages\ManageProductReviews;
/** /**
* CorePlugin allows exactly one extension class per Lunar resource — this * CorePlugin allows exactly one extension class per Lunar resource — this
* one already owned ProductResource (adding the Reviews sub-page) before * one already owned ProductResource (adding the Reviews sub-page) before
* Catalog needed its own product-form addition, so extendForm() lives * Catalog needed its own product-page addition, so registering
* here too rather than competing for the same resource slot. * ManageProductCustomFields lives here too rather than competing for the
* * same resource slot. See that page's own docblock for what it does and
* extendForm() adds a "Custom Fields" repeater authoring Product:: * why it's a separate sub-page rather than a section on the main form.
* $custom_fields (see the migration adding that column, and Modules\Core\
* Catalog\Models\Product's own docblock on why this needed a first-party
* Product subclass rather than being addable to the base Lunar model) —
* per-product, customer-authored input (a reference photo upload, an
* optional engraving textarea) rendered on the storefront product page,
* NOT a Lunar ProductOption: an option's values are a fixed, admin-
* authored list that define variants, which doesn't fit "the customer
* uploads their own unique photo."
*/ */
class ProductResourceExtension extends ResourceExtension class ProductResourceExtension extends ResourceExtension
{ {
public function extendPages(array $pages): array public function extendPages(array $pages): array
{ {
$pages['reviews'] = ManageProductReviews::route('/{record}/reviews'); $pages['reviews'] = ManageProductReviews::route('/{record}/reviews');
$pages['custom-fields'] = ManageProductCustomFields::route('/{record}/custom-fields');
return $pages; return $pages;
} }
public function extendSubNavigation(array $pages): array public function extendSubNavigation(array $pages): array
{ {
return [...$pages, ManageProductReviews::class]; return [...$pages, ManageProductReviews::class, ManageProductCustomFields::class];
}
public function extendForm(Schema $schema): Schema
{
return $schema->components([
...$schema->getComponents(),
$this->customFieldsSection(),
]);
}
private function customFieldsSection(): Section
{
return Section::make('Custom Fields')
->description('Extra input the shopper fills in on this product\'s page before adding it to their cart — a reference photo, personalization text, etc.')
->collapsible()
->collapsed(fn ($record) => blank($record?->custom_fields))
->schema([
Repeater::make('custom_fields')
->hiddenLabel()
->schema([
TextInput::make('label')
->label('Label')
->helperText('Shown to the shopper above the field.')
->required(),
Select::make('type')
->label('Field type')
->options([
'text' => 'Short text',
'textarea' => 'Long text',
'file' => 'File upload',
])
->default('text')
->native(false)
->live()
->required(),
TextInput::make('key')
->label('Key')
->helperText('Machine-facing identifier — stored on the order/cart line, used to look up this answer elsewhere. Cannot be changed once orders reference it.')
->required()
->alphaDash()
->maxLength(64),
Toggle::make('required')
->label('Required')
->helperText('Shopper cannot add this product to their cart without answering.')
->default(false),
])
->columns(2)
->addActionLabel('Add a custom field')
->reorderable()
->collapsible()
->itemLabel(fn (array $state): ?string => $state['label'] ?? null),
]);
} }
} }