diff --git a/docs/collections.md b/docs/collections.md new file mode 100644 index 0000000..6b15831 --- /dev/null +++ b/docs/collections.md @@ -0,0 +1,101 @@ +# Collections + +`Modules\Core\Catalog\Services\CollectionService` provides category browsing/nav AND +single-collection lookup for a storefront — `list()`, `getById()`, `getBySlug()` — +all reading directly from the Meilisearch index, mirroring +`Modules\Core\Catalog\Services\ProductService` (see `product-listing.md`) exactly. + +--- + +## Why it reads from the index, not the database + +Lunar's own `Lunar\Search\CollectionIndexer` only carries `id`/`name`/`created_at` — +nowhere near enough for a storefront category page or a nav tree. +`Modules\Core\Catalog\Services\CollectionIndexer` extends it to add everything +`CollectionService` needs: + +| Field | Source | Notes | +|---|---|---| +| `parent_id` | `$model->parent_id` | Filterable. The nested-set tree's parent pointer — `null` for a top-level collection. | +| `_lft` | `$model->_lft` | Filterable and sortable. The nested-set tree position — lets `CollectionService` resolve tree order without a database read. | +| `collection_group_id` | `$model->collection_group_id` | Filterable. Mirrors `Collection::scopeInGroup()`. | +| `slugs` | `$model->urls->pluck('slug')` | Filterable. Every locale's `Url::slug`, so `getBySlug()` resolves purely from the index. | +| `thumbnail` | `$model->getThumbnailImage()` | Display only. `null` if the collection has no thumbnail image. | + +`name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale +by Lunar's base indexer and resolved by `CollectionService` exactly like +`ProductService` does — see `product-listing.md`'s "Locale resolution" section, same +logic, same `LanguageCache::defaultLocale()` fallback. + +--- + +## Usage + +```php +use Modules\Core\Catalog\DTOs\CollectionFilters; +use Modules\Core\Catalog\Enums\CollectionSort; +use Modules\Core\Catalog\Services\CollectionService; + +$service = app(CollectionService::class); + +// Top-level collections only (parent_id IS NULL) — for building a nav tree +$roots = $service->list( + filters: new CollectionFilters(rootOnly: true), + sort: CollectionSort::Position, +); + +// Children of a specific collection +$children = $service->list( + filters: new CollectionFilters(parentId: 222), + sort: CollectionSort::Position, +); + +// Filter by collection group +$collections = $service->list(filters: new CollectionFilters(groupId: 4)); + +// Single collection, by primary key or slug +$collection = $service->getById(223); +$collection = $service->getBySlug('keychains'); +``` + +`CollectionFilters(parentId: ..., rootOnly: ...)` are mutually exclusive — if both are +set, `parentId` wins. There's no `parentId: null` shorthand for "root only", since +that would be ambiguous with "don't filter by parent at all" (the DTO's actual +default); `rootOnly` names the root-collections case explicitly instead. + +`CollectionSort::Position` (`_lft:asc`) is the recommended default for any nav/tree +UI — it matches the order an admin arranges collections in Lunar's own Filament UI. +`Name` and `Newest` are also available, mirroring `ProductSort`'s shape. + +--- + +## Registration + +Like `ProductIndexer`, `CollectionIndexer` must be registered in the consuming app's +own `config/lunar/search.php`: + +```php +'indexers' => [ + Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class, + // ... +], +``` + +New/changed fields aren't filterable/sortable in Meilisearch until `php artisan +lunar:meilisearch:setup` re-syncs index settings, and existing documents need +`lunar:search:index --refresh` to pick up the new shape. If `SCOUT_QUEUE` is enabled, +the queue worker also needs restarting after deploying changes to the indexer class — +see `docs/lunar.md` "Gotchas". + +--- + +## When to still use Eloquent directly + +A single collection's full detail page (breadcrumb via `$collection->breadcrumb`, +tree ancestors/descendants, route-model-bound `Collection $collection` in a +controller signature) should keep reading Eloquent directly rather than going through +`CollectionService` — the indexed document doesn't carry ancestor chains or the full +nested-set relations, and route-model binding already gives a controller the full +model for free. `CollectionService` is for browsing/listing and lightweight +by-id/by-slug lookups where a full Eloquent hydration would be wasteful, the same +tradeoff `ProductService` makes for products. diff --git a/docs/localization.md b/docs/localization.md index 1a1fd5a..abf98c7 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -199,9 +199,20 @@ registered. ### Seeding A starter set of common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`, -English + Greek) is seeded by `Modules\Core\Command\InstallLunarCommand` (overrides Lunar's own -`lunar:install`), guarded by `LanguageLine::where('group', 'storefront')->exists()` — same -idempotent pattern as the rest of that command, safe to run unattended on every boot. +`review.*`, `shop.*`, `pagination.*`, English + Greek) lives in +`Modules\Core\Localization\Services\StorefrontLabels::all()` — kept as its own class, separate +from the seeding logic, so the label list can be scanned/diffed without wading through the +seeding mechanics. + +`Modules\Core\Command\InstallLunarCommand` (overrides Lunar's own `lunar:install`) seeds them via +a **per-key upsert**, not an all-or-nothing "only seed if the group is empty" guard: a key already +present in the database — including one an admin has since edited via the Filament **Language +Lines** resource — is left untouched; only keys missing entirely are created. This is what makes +it safe to add new keys to `StorefrontLabels::all()` later and re-run `lunar:install` on an +already-installed store, without either silently skipping the new keys (the old guard's behavior) +or reverting an admin's edits back to the hardcoded default (what a naive `updateOrCreate` would +do). New writes go through `TranslationService::create()`, so the usual cache-invalidation and +activity-log events fire for them too. ### Admin UI diff --git a/docs/product-listing.md b/docs/product-listing.md index 5161635..2332acc 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -72,7 +72,8 @@ needs, listing and detail alike: | Field | Source | Notes | |---|---|---| | `id` | — | Newly marked **filterable** — needed for `getById()`'s `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. | -| `collections` | `$product->collections` | Array of `{id, name}` — `name` is the translated collection name. Filterable on the nested field `collections.id`, not `collections` itself. | +| `collections` | `$product->collections` | Array of `{id, name}` — directly assigned collections only, `name` is the translated collection name. Not filterable — see `collection_ids`. | +| `collection_ids` | `$product->collections` + `->ancestors` | Filterable. Flat array of every directly-assigned collection's id, unioned with all of its ancestors' ids. `ProductFilters(collectionId: ...)` filters against this field, not `collections`, since products are typically attached only to leaf collections — a plain direct-match filter would never return anything for a parent/root category page. | | `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. | | `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. | | `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. | diff --git a/src/Catalog/DTOs/CollectionFilters.php b/src/Catalog/DTOs/CollectionFilters.php new file mode 100644 index 0000000..3d1044e --- /dev/null +++ b/src/Catalog/DTOs/CollectionFilters.php @@ -0,0 +1,24 @@ + '_lft:asc', + self::Name => 'name:asc', + self::Newest => 'created_at:desc', + }; + } +} diff --git a/src/Catalog/Services/CollectionIndexer.php b/src/Catalog/Services/CollectionIndexer.php new file mode 100644 index 0000000..117a63e --- /dev/null +++ b/src/Catalog/Services/CollectionIndexer.php @@ -0,0 +1,69 @@ +with(['urls', 'media']); + } + + public function toSearchableArray(Model $model): array + { + /** @var Collection $model */ + $data = parent::toSearchableArray($model); + + $data['parent_id'] = $model->parent_id; + $data['_lft'] = $model->_lft; + $data['_rgt'] = $model->_rgt; + $data['collection_group_id'] = $model->collection_group_id; + $data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); + $data['thumbnail'] = $model->getThumbnailImage() ?: null; + + return $data; + } +} diff --git a/src/Catalog/Services/CollectionService.php b/src/Catalog/Services/CollectionService.php new file mode 100644 index 0000000..001c239 --- /dev/null +++ b/src/Catalog/Services/CollectionService.php @@ -0,0 +1,147 @@ + $this->buildFilter($filters)]; + + if ($sort !== null) { + $options['sort'] = [$sort->toMeilisearchSort()]; + } + + $paginator = CollectionModel::search('') + ->options($options) + ->paginateRaw(perPage: $perPage, page: $page); + + $data = collect($this->hitsFrom($paginator)) + ->map(fn (array $collection) => $this->withLocalizedFields($collection)) + ->all(); + + return new LengthAwarePaginator( + items: $data, + total: $paginator->total(), + perPage: $paginator->perPage(), + currentPage: $paginator->currentPage(), + options: ['path' => LengthAwarePaginator::resolveCurrentPath()], + ); + } + + /** + * Look up a single collection by its URL slug (any locale). Returns the full + * indexed collection document, or null if no collection has that slug. + */ + public function getBySlug(string $slug): ?array + { + return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"'); + } + + /** + * Look up a single collection by its primary key. Returns the full indexed + * collection document, or null if no collection has that id. + */ + public function getById(int $id): ?array + { + return $this->findOneWhere("id = \"{$id}\""); + } + + private function findOneWhere(string $filter): ?array + { + $paginator = CollectionModel::search('') + ->options(['filter' => $filter]) + ->paginateRaw(perPage: 1, page: 1); + + $collection = $this->hitsFrom($paginator)[0] ?? null; + + return $collection !== null ? $this->withLocalizedFields($collection) : null; + } + + /** + * Resolves every translated Collection attribute's current-locale value — same + * logic as ProductService::withLocalizedFields(), see there for the full + * reasoning (AttributeManifest-driven, store-default-locale fallback, raw + * per-locale keys stripped after resolving). + */ + private function withLocalizedFields(array $collection): array + { + $locale = App::getLocale(); + $fallbackLocale = $this->languages->defaultLocale(); + $availableLocales = $this->languages->availableLocales(); + + foreach ($this->translatedAttributeHandles() as $handle) { + $collection[$handle] = $collection[$handle.'_'.$locale] ?? $collection[$handle.'_'.$fallbackLocale] ?? null; + + foreach ($availableLocales as $availableLocale) { + unset($collection[$handle.'_'.$availableLocale]); + } + } + + return $collection; + } + + /** + * @return array + */ + private function translatedAttributeHandles(): array + { + return $this->attributes->getSearchableAttributes((new CollectionModel)->getMorphClass()) + ->filter(fn ($attribute) => $attribute->type === TranslatedText::class) + ->pluck('handle') + ->all(); + } + + /** + * For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response + * in items(), not a plain list of hits — see ProductService's identical note. + */ + private function hitsFrom(LengthAwarePaginatorContract $paginator): array + { + $rawResponse = $paginator->items(); + + return collect($rawResponse['hits'] ?? [])->values()->all(); + } + + private function buildFilter(?CollectionFilters $filters): ?string + { + if ($filters === null) { + return null; + } + + $clauses = Collection::make([ + $filters->parentId !== null ? "parent_id = \"{$filters->parentId}\"" + : ($filters->rootOnly ? 'parent_id IS NULL' : null), + $filters->groupId !== null ? "collection_group_id = \"{$filters->groupId}\"" : null, + ])->filter(); + + return $clauses->isEmpty() ? null : $clauses->join(' AND '); + } +} diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php index 3640f27..79f6ed3 100644 --- a/src/Catalog/Services/ProductIndexer.php +++ b/src/Catalog/Services/ProductIndexer.php @@ -16,7 +16,14 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media; * Extends Lunar's own indexer so Modules\Core\Catalog\Services\ProductService can * serve both listing/filtering AND single-product lookups from Meilisearch alone — * one data source, no separate database read path for a product detail page. Adds: - * - collections: [{id, name}, ...] — filterable via `collections.id` + * - collections: [{id, name}, ...] — directly assigned collections only, for + * display (breadcrumbs, "also in"). Not filterable — see collection_ids below. + * - collection_ids (filterable): flat array of every directly-assigned collection's + * id UNIONED with all of its ancestors' ids. Products are typically attached only + * to leaf collections in a Shopify-imported tree, so a plain `collections.id` + * filter would never match a parent/root category page — ProductService::list() + * filters `collectionId` against this field instead, so "products in category X" + * also picks up every product attached only to one of X's subcategories. * - slugs (every locale's Url::slug for the product, filterable) — lets * ProductService::getBySlug() resolve a product from the index directly, with * no database read at all @@ -49,7 +56,7 @@ class ProductIndexer extends BaseProductIndexer ...parent::getFilterableFields(), 'id', 'brand', - 'collections.id', + 'collection_ids', 'price', 'slugs', 'channel_ids', @@ -68,6 +75,7 @@ class ProductIndexer extends BaseProductIndexer { return parent::makeAllSearchableUsing($query)->with([ 'collections', + 'collections.ancestors', 'media', 'tags', 'urls', @@ -89,6 +97,11 @@ class ProductIndexer extends BaseProductIndexer 'id' => $collection->id, 'name' => $collection->translateAttribute('name'), ])->all(); + $data['collection_ids'] = $model->collections + ->flatMap(fn ($collection) => [$collection->id, ...$collection->ancestors->pluck('id')]) + ->unique() + ->values() + ->all(); $data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); $data['tags'] = $model->tags->pluck('value')->all(); $data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all(); diff --git a/src/Catalog/Services/ProductService.php b/src/Catalog/Services/ProductService.php index f881d6c..b820b07 100644 --- a/src/Catalog/Services/ProductService.php +++ b/src/Catalog/Services/ProductService.php @@ -157,7 +157,7 @@ class ProductService } $clauses = Collection::make([ - $filters->collectionId !== null ? "collections.id = \"{$filters->collectionId}\"" : null, + $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null, $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, $filters->minPrice !== null ? "price >= {$filters->minPrice}" : null, $filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null, diff --git a/src/Command/InstallLunarCommand.php b/src/Command/InstallLunarCommand.php index e07d3a3..e55b9c9 100644 --- a/src/Command/InstallLunarCommand.php +++ b/src/Command/InstallLunarCommand.php @@ -18,7 +18,9 @@ use Lunar\Models\Product; use Lunar\Models\ProductType; use Lunar\Models\TaxClass; use Lunar\Models\TaxZone; -use Spatie\TranslationLoader\LanguageLine; +use Modules\Core\Localization\Models\LanguageLine; +use Modules\Core\Localization\Services\StorefrontLabels; +use Modules\Core\Localization\Services\TranslationService; /** * Overrides Lunar's own lunar:install to skip the interactive prompts (migrate @@ -32,7 +34,7 @@ class InstallLunarCommand extends Command protected $description = 'Seed the default Lunar store data (countries, channel, currency, tax zone, attributes, product type)'; - public function handle(): void + public function handle(TranslationService $translations): void { $this->components->info('Seeding default Lunar store data...'); @@ -242,10 +244,8 @@ class InstallLunarCommand extends Command } }); - if (! LanguageLine::where('group', 'storefront')->exists()) { - $this->components->info('Seeding storefront label translations'); - $this->seedStorefrontLabels(); - } + $this->components->info('Seeding storefront label translations'); + $this->seedStorefrontLabels($translations); $this->components->info('Publishing Filament assets'); $this->call('filament:assets'); @@ -253,32 +253,29 @@ class InstallLunarCommand extends Command $this->components->info('Lunar default data seeded.'); } - private function seedStorefrontLabels(): void + /** + * Per-key upsert, not an all-or-nothing "only seed if the group is empty" guard — + * a key already present in the database (including one an admin has since edited + * via the Filament Languages resource) is left untouched; only keys missing + * entirely are created. This is what makes it safe to add new keys to + * StorefrontLabels later and re-run this on an already-installed store without + * either skipping the new keys (the old all-or-nothing guard) or reverting an + * admin's edits back to the hardcoded default (a naive updateOrCreate would). + */ + private function seedStorefrontLabels(TranslationService $translations): void { - $labels = [ - 'nav.home' => ['en' => 'Home', 'el' => 'Αρχική'], - 'nav.products' => ['en' => 'Products', 'el' => 'Προϊόντα'], - 'nav.cart' => ['en' => 'Cart', 'el' => 'Καλάθι'], - 'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'], - 'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'], - 'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'], - 'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'], - 'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'], - 'cart.remove' => ['en' => 'Remove', 'el' => 'Αφαίρεση'], - 'product.add_to_cart' => ['en' => 'Add to Cart', 'el' => 'Προσθήκη στο Καλάθι'], - 'product.out_of_stock' => ['en' => 'Out of Stock', 'el' => 'Εξαντλήθηκε'], - 'product.price' => ['en' => 'Price', 'el' => 'Τιμή'], - 'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'], - 'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'], - 'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'], - ]; + $labels = StorefrontLabels::all(); + + $existingKeys = LanguageLine::where('group', 'storefront') + ->whereIn('key', array_keys($labels)) + ->pluck('key'); foreach ($labels as $key => $text) { - LanguageLine::create([ - 'group' => 'storefront', - 'key' => $key, - 'text' => $text, - ]); + if ($existingKeys->contains($key)) { + continue; + } + + $translations->create('storefront', $key, $text); } } } diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 355748a..12b8468 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -20,7 +20,7 @@ use Modules\Core\Auth\Mail\InviteMail; use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension; use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Localization\Filament\Resources\LanguageLineResource; -use Modules\Core\Review\Extensions\ProductResourceExtension; +use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; class CorePlugin implements Plugin diff --git a/src/Localization/Services/StorefrontLabels.php b/src/Localization/Services/StorefrontLabels.php new file mode 100644 index 0000000..3267c2b --- /dev/null +++ b/src/Localization/Services/StorefrontLabels.php @@ -0,0 +1,84 @@ +> keyed by `group.key` dot-notation, + * each value a locale => text map (`en`/`el`). + */ + public static function all(): array + { + return [ + 'nav.home' => ['en' => 'Home', 'el' => 'Αρχική'], + 'nav.products' => ['en' => 'Products', 'el' => 'Προϊόντα'], + 'nav.cart' => ['en' => 'Cart', 'el' => 'Καλάθι'], + 'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'], + 'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'], + 'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'], + 'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'], + 'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'], + 'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'], + 'cart.remove' => ['en' => 'Remove', 'el' => 'Αφαίρεση'], + 'product.add_to_cart' => ['en' => 'Add to Cart', 'el' => 'Προσθήκη στο Καλάθι'], + 'product.out_of_stock' => ['en' => 'Out of Stock', 'el' => 'Εξαντλήθηκε'], + 'product.price' => ['en' => 'Price', 'el' => 'Τιμή'], + 'product.description' => ['en' => 'Description', 'el' => 'Περιγραφή'], + 'product.no_image' => ['en' => 'No image', 'el' => 'Χωρίς εικόνα'], + 'product.read_more' => ['en' => 'Read more', 'el' => 'Περισσότερα'], + 'product.reviews' => ['en' => 'Reviews', 'el' => 'Αξιολογήσεις'], + 'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'], + 'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'], + 'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'], + 'customer_reviews' => [ + 'en' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews', + 'el' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών', + ], + 'pagination.nav_label' => ['en' => 'Pagination', 'el' => 'Σελιδοποίηση'], + 'pagination.next' => ['en' => 'Next page', 'el' => 'Επόμενη σελίδα'], + 'pagination.previous' => ['en' => 'Previous page', 'el' => 'Προηγούμενη σελίδα'], + 'pagination.page' => ['en' => 'Page :page', 'el' => 'Σελίδα :page'], + 'review.rating' => ['en' => 'Rating', 'el' => 'Βαθμολογία'], + 'review.write_label' => ['en' => 'Write a review', 'el' => 'Γράψε μια αξιολόγηση'], + 'review.name' => ['en' => 'Name', 'el' => 'Όνομα'], + 'review.name_optional' => ['en' => 'Optional', 'el' => 'Προαιρετικό'], + 'review.email' => ['en' => 'Email', 'el' => 'Email'], + 'review.email_not_published' => ['en' => 'Will not be published', 'el' => 'Δεν θα δημοσιευτεί'], + 'review.save_info' => [ + 'en' => 'Save my name and email for the next time I comment.', + 'el' => 'Αποθήκευσε το όνομα και το email μου για την επόμενη φορά που θα σχολιάσω.', + ], + 'review.submit' => ['en' => 'Submit', 'el' => 'Υποβολή'], + 'review.stars_count' => ['en' => '{1} :count star|[2,*] :count stars', 'el' => '{1} :count αστέρι|[2,*] :count αστέρια'], + 'review.no_reviews_yet' => ['en' => 'No reviews yet.', 'el' => 'Δεν υπάρχουν αξιολογήσεις ακόμα.'], + 'review.write_first' => ['en' => 'Write the first review', 'el' => 'Γράψε την πρώτη'], + 'review.write_new' => ['en' => 'Add a review', 'el' => 'Πρόσθεσε μια'], + 'review.for_product' => ['en' => 'review for ":name"', 'el' => 'αξιολόγηση για το «:name»'], + 'shop.showing_results' => [ + 'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results', + 'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα', + ], + 'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'], + 'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'], + 'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'], + 'shop.sort_price_asc' => ['en' => 'Price: Low to High', 'el' => 'Τιμή: Αύξουσα'], + 'shop.sort_price_desc' => ['en' => 'Price: High to Low', 'el' => 'Τιμή: Φθίνουσα'], + 'shop.sort_newest' => ['en' => 'Newest', 'el' => 'Νεότερα'], + 'shop.no_products' => ['en' => 'No products found in this category.', 'el' => 'Δεν βρέθηκαν προϊόντα σε αυτή την κατηγορία.'], + 'shop.search_label' => ['en' => 'Search products', 'el' => 'Αναζήτηση προϊόντων'], + 'shop.search_placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτησε προϊόντα…'], + 'shop.filter_price' => ['en' => 'Filter by price', 'el' => 'Φίλτρο τιμής'], + 'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'], + 'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'], + 'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'], + ]; + } +} diff --git a/src/Review/Extensions/ProductResourceExtension.php b/src/Review/Filament/Extensions/ProductResourceExtension.php similarity index 78% rename from src/Review/Extensions/ProductResourceExtension.php rename to src/Review/Filament/Extensions/ProductResourceExtension.php index 120154e..9d36031 100644 --- a/src/Review/Extensions/ProductResourceExtension.php +++ b/src/Review/Filament/Extensions/ProductResourceExtension.php @@ -1,9 +1,9 @@