Feature: Updating Products Service and Locale MIddleware
This commit is contained in:
+52
-17
@@ -1,24 +1,21 @@
|
||||
# Product Listing
|
||||
|
||||
`Modules\Core\Catalog\ProductService` provides browsing/filtering of the product catalog
|
||||
(list all, filter by collection/brand/price range) for a storefront, reading directly from the
|
||||
Meilisearch index rather than the database.
|
||||
`Modules\Core\Catalog\ProductService` provides catalog browsing/filtering AND single-product
|
||||
lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the
|
||||
Meilisearch index rather than the database. One data source for everything this service does.
|
||||
|
||||
This is separate from `Modules\Core\Search\ProductSearchService` (see `product-search.md`), which
|
||||
handles free-text query search. `ProductService` is for browsing without a search term.
|
||||
handles free-text query search. `ProductService` is for browsing/lookup without a search term.
|
||||
|
||||
---
|
||||
|
||||
## Why it reads from the index, not the database
|
||||
|
||||
`ProductService::list()` calls `Product::search('')->paginateRaw(...)` and returns the raw
|
||||
Meilisearch hits directly — it never calls Scout's `->get()`, which would re-hydrate Eloquent
|
||||
models from the database per result. This avoids an extra database round-trip on every listing
|
||||
request, but it means **callers only get whatever fields are in the indexed document**, not the
|
||||
full `Product` model or its relations.
|
||||
|
||||
A single-product detail view needs the full record (all attributes, media, variants, etc.) and
|
||||
should look the product up directly via `Lunar\Models\Product`, not through `ProductService`.
|
||||
Every method here reads Meilisearch documents directly and returns plain arrays — never Scout's
|
||||
`->get()`, which would re-hydrate Eloquent models from the database. This means the index has to
|
||||
carry everything a detail page needs (variants, prices, options, media, reviews — see below), not
|
||||
just the trimmed fields a listing page needs. `Modules\Core\Search\ProductIndexer` is built to
|
||||
carry that full shape.
|
||||
|
||||
---
|
||||
|
||||
@@ -45,6 +42,12 @@ $result['meta']['total'];
|
||||
$result['meta']['per_page'];
|
||||
$result['meta']['current_page'];
|
||||
$result['meta']['last_page'];
|
||||
|
||||
// Single product, by primary key
|
||||
$product = $service->getById(367); // array, or null if not found
|
||||
|
||||
// Single product, by URL slug (any locale — slugs are indexed across all languages)
|
||||
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
|
||||
```
|
||||
|
||||
All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query.
|
||||
@@ -53,20 +56,52 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei
|
||||
|
||||
## Fields this depends on: `Modules\Core\Search\ProductIndexer`
|
||||
|
||||
Lunar's own `Lunar\Search\ProductIndexer` doesn't index collection membership or a comparable
|
||||
price, and only marks `__soft_deleted`, `skus`, `status` as filterable — none of what
|
||||
`ProductFilters` needs. `Modules\Core\Search\ProductIndexer` extends it to add:
|
||||
Lunar's own `Lunar\Search\ProductIndexer` only carries listing-grade fields (name, description,
|
||||
status, brand, a single thumbnail, skus) and marks just `__soft_deleted`, `skus`, `status` as
|
||||
filterable. `Modules\Core\Search\ProductIndexer` extends it to add everything `ProductService`
|
||||
needs, listing and detail alike:
|
||||
|
||||
| Field | Source | Notes |
|
||||
|---|---|---|
|
||||
| `collections` | `$product->collections->pluck('id')` | Array of collection IDs (as strings). Filtering matches by ID, not slug — the caller resolves whichever collection it means before calling `ProductService`. |
|
||||
| `price` | Cheapest variant's base price | 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. |
|
||||
| `id` | — | Newly marked **filterable** — needed for `getById()`'s `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. |
|
||||
| `collections` | `$product->collections->pluck('id')` | Filterable. Array of collection IDs (as strings) — filtering matches by ID, not slug. |
|
||||
| `collection_names` | `$product->collections` | Display only, not filterable — translated collection names. |
|
||||
| `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. |
|
||||
| `tags` | `$product->tags->pluck('value')` | Display only. |
|
||||
| `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. |
|
||||
| `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). |
|
||||
| `reviews`, `review_count`, `average_rating` | `Modules\Core\Review\Models\ProductReview` | See "Reviews" below. |
|
||||
|
||||
`description` and other translated attributes are indexed as-is, including any HTML markup
|
||||
(e.g. from a Shopify `Body (HTML)` import) — **not stripped**. Any view rendering a description
|
||||
sourced from `ProductService`'s results must treat it as trusted HTML.
|
||||
|
||||
**`ProductOption`/`ProductOptionValue` names need a different translation accessor.** Unlike
|
||||
`Product`/`Collection`/`Brand`, their `name` is a plain locale-keyed array cast, not
|
||||
`attribute_data` — Lunar's `translateAttribute('name')` silently returns `null` for them. The
|
||||
indexer's `translatedName()` reads the array directly instead. See `docs/lunar.md` "Gotchas".
|
||||
|
||||
---
|
||||
|
||||
## Reviews
|
||||
|
||||
`Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product as
|
||||
`reviews` (array), plus `review_count` and `average_rating` (rounded to 1 decimal, `null` if the
|
||||
product has no reviews). Only public-safe fields are included — **`reviewer_email` is deliberately
|
||||
excluded**, it's PII with no storefront use. `reply`/`replied_at` (the staff response) are
|
||||
included, since they're meant to be shown alongside the review.
|
||||
|
||||
A review is created/edited independently of its product (a customer submission, a staff reply)
|
||||
— its own save doesn't touch the `Product` row, so the product's own model events never fire.
|
||||
`Modules\Core\Providers\ReviewServiceProvider` listens on `ProductReview`'s `created`/`updated`/
|
||||
`deleted` events and calls `$review->product->searchable()`, so the parent product's document
|
||||
stays current without waiting for the next full reindex. This provider must be registered in
|
||||
`composer.json`'s `extra.laravel.providers` (already done in this repo) — see `docs/modules.md`
|
||||
"Provider Registration Pitfalls" for what happens if a provider like this is ever added but not
|
||||
registered.
|
||||
|
||||
---
|
||||
|
||||
## Multi-variant products and price
|
||||
|
||||
Reference in New Issue
Block a user