Files
core/CHANGELOG.md
T
2026-08-29 13:41:17 +03:00

41 KiB
Raw Blame History

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog.

[0.9.0] - 2026-08-29

Added

  • Modules\Core\Cart\Services\CartService — the boboko-owned API for all cart mutation, wrapping Lunar's CartSession/Cart primitives: addLine(), updateLine(), removeLine(), clear(), applyCoupon()/removeCoupon() (throws InvalidCouponException on an invalid code), and save-for-later (saveForLater()/moveToCart()/activeLines()/savedLines(), backed by a meta.saved_for_later flag and a new Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice cart-line pipeline step that zeroes a saved line's price so it's excluded from cart totals without being removed). Dispatches 8 real domain events (CartLineAdded/Updated/Removed/Saved/MovedToCart, CartCleared, CartCouponApplied/Removed) — none have a listener yet, built so a future concern (analytics, recovery) has something to attach to. Documented in docs/cart.md.
  • Modules\Core\Checkout\Services\CheckoutService — the boboko-owned API for the checkout stage (address → shipping selection → order placement), sitting between CartService and Order: setShippingAddress()/setBillingAddress(), getShippingOptions()/selectShippingOption() (throws the new InvalidShippingOptionException on an identifier that doesn't resolve — previously a silent no-op), and placeOrder(string $fingerprint) (the fingerprint is mandatory, not optional — forces re-confirmation via Lunar's own FingerprintMismatchException if the cart changed since the shopper last saw its total). Dispatches ShippingAddressSet/BillingAddressSet/ShippingOptionSelected/OrderPlaced, each carrying richer, already-resolved payload (e.g. the resolved ShippingOption, not just its identifier) than CartService's events. No exception wrapping otherwise — Lunar's own CartException/FingerprintMismatchException are already the right shape for a storefront to render as form errors. Documented in docs/checkout.md.
  • Modules\Core\Cart\Filament\Resources\CartResource's list view now classifies every cart into one of four states — Ongoing, Abandoned Cart, Abandoned Checkout, Completed — instead of the previous two-tab Abandoned/Completed split, distinguishing a cart that never reached checkout from one that has a started-but-unplaced order (mirrors the real distinction in Lunar's own Cart::scopeActive()). Abandonment threshold is a fixed, configurable cutoff (config('core.cart.abandoned_after'), default 1 hour). Added a customer hyperlink (list column + a "View Customer" header action on the view page, both pointing straight at customers/{id} via the plain customer_id column, no extra query via the customer relation).
  • Modules\Core\Cart\Commands\DetectAbandonedCarts (boboko:cart:detect-abandoned, scheduled hourly) dispatches Modules\Core\Recovery\Events\CartAbandoned/CheckoutAbandoned for carts/checkouts past the abandonment cutoff — detection only, no persistence; a real tracking table is left for when Recovery is built as its own concern. Fixed a self-defeating bug from an earlier draft: marking a cart as notified by writing to it bumped updated_at, which immediately un-staled it for the next run's own cutoff check.
  • Merged the Shipping-Carriers branch: live carrier rate quoting and fulfillment for ACS Courier and Box Now (Modules\Core\Shipping\Carriers\{Acs,BoxNow}) on top of lunarphp/table-rate-shipping — AcsRateDriver/BoxNowRateDriver (live + static price-break resolution), AcsFulfillmentService/BoxNowFulfillmentService (shipment creation, label printing, cancellation via the new Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface, resolved per-carrier via contextual container binding), Modules\Core\Shipping\Models\Shipment/ShipmentInfo, PollShipmentTrackingJob (scheduled every 30 minutes), ManagePickupManifests (Filament page for carrier manifest batching), and an OrderViewExtension adding a "Create Shipment" header action to Lunar's order view. Carrier credentials are published config (config/shippingCarriers/{acs,boxnow}.php), never committed.
  • Modules\Core\Shipping\Concerns\CachesLivePricing caches a live-priced carrier quote per (rate, cart) for 30 minutes — a real, billed API call that's otherwise re-run on every getShippingOptions()/selectShippingOption() call within the same checkout attempt. Modules\Core\Shipping\Listeners\FlushLivePricingCache invalidates it on the only two things that can change a quote: a cart line changing or the shipping address changing (deliberately not on order placement — the price the shopper was quoted must still be readable afterwards). Scoped generically to any SupportsLivePricing driver, not hardcoded to ACS.
  • AcsRateDriver::resolveLivePrice() now falls back to the rate's own configured static price if the live ACS API call fails (previously: the shipping option silently disappeared from the list on any API error, including a brief outage). ManageShippingRates (our Filament subclass of the vendor rates page) now allows a static price to be configured and saved on a "live" rate specifically for this fallback — previously those fields were hidden and discarded on save for any live-priced rate.

Fixed

  • Fixed a crash (Attempt to read property "price" on null) opening/editing a live-priced shipping rate with no fallback price configured yet — the vendor ManageShippingRates page's afterStateHydrated callback for the price field had no null-guard for a rate with zero basePrices, which is now the routine case for an unconfigured live rate.
  • Fixed the Filament admin panel's home URL (/boboko/home) incorrectly resolving to the Shipping module's ManagePickupManifests page instead of the Dashboard — Filament falls back to the first item of the first registered navigation group when no explicit homeUrl() is set, and ManagePickupManifests had no navigationGroup/navigationSort of its own. Fixed via explicit navigationGroup = 'Sales' / navigationSort = 100, placing it after Sales in the nav instead of first overall.

[0.8.0] - 2026-08-27

Added

  • Modules\Core\Cart\Filament\Resources\CartResource gives staff read-only visibility into carts in the Filament admin panel — Lunar ships no cart admin view at all. Scoped to carts with a known user_id/customer_id (an anonymous guest cart carries no identity staff could act on); list table shows customer/user, line/item counts (via Filament's built-in ->counts()/->sum(), no per-row queries), currency, and last activity. List page has only two tabs, Abandoned (default active) and Completed — no "All" tab, so the list never runs an unfiltered fetch over the whole table. They key off whether the cart has a placed order (orders.placed_at IS NOT NULL), not Cart::completed_at — that column is declared/cast on the model but never actually written anywhere in Lunar core, so it's not a real signal; "Abandoned" mirrors Lunar's own Cart::scopeActive(). getNavigationBadge() shows the abandoned-cart count in the sidebar via a single COUNT(*) query, no rows loaded. View page runs $cart->calculate() once so line/cart totals (plain public properties Lunar never persists) are populated, without paying that cost per row in the list. Documented in docs/cart.md.

[0.7.0] - 2026-08-27

Added

  • Modules\Core\Catalog\Services\CollectionService provides category browsing/nav AND single-collection lookup from Meilisearch, mirroring ProductService exactly (list(), getById(), getBySlug(), same locale-resolution logic). Modules\Core\Catalog\Services\CollectionIndexer extends Lunar's own Lunar\Search\CollectionIndexer (which only carried id/name/created_at) to add parent_id, _lft/_rgt (nested-set tree position, filterable/sortable), collection_group_id, slugs, and thumbnail. Modules\Core\Catalog\DTOs\CollectionFilters supports parentId (children of a specific collection), groupId, and rootOnly (top-level collections, parent_id IS NULL — mutually exclusive with parentId). Modules\Core\Catalog\Enums\CollectionSort adds Position (_lft:asc, the recommended default for nav/tree UIs — matches admin arrangement order), Name, Newest. Must be registered in a consuming app's config/lunar/search.php (Lunar\Models\Collection::class => CollectionIndexer::class), same as ProductIndexer. Documented in docs/collections.md.
  • Modules\Core\Localization\Services\StorefrontLabels::all() extracts the default storefront UI label list out of InstallLunarCommand into its own class, and adds every previously-missing key (nav.contact, product.description/no_image/read_more/reviews, customer_reviews, pagination.*, review.*, shop.*) that had already been seeded manually in some stores but was absent from the command's own list — bringing the code-side default back in sync with what a real store actually has. InstallLunarCommand::seedStorefrontLabels() now does a per-key upsert instead of 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, and only missing keys are created via TranslationService::create(). This 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. Documented in docs/localization.md ("Seeding").
  • Modules\Core\Catalog\Services\CollectionIndexer adds ancestors — [{id, name}, ...] ordered root-first (via the newly eager-loaded ancestors relation) — so a breadcrumb can render directly from CollectionService::getById()/getBySlug() with zero extra queries, and product_count — how many products are in a collection or any of its descendants, queried from the product Meilisearch index at collection-index time via the same collection_ids field ProductFilters(collectionId:) filters against. Documented in docs/collections.md, including the reindex-ordering gotcha (product_count needs the product index reindexed first).
  • Modules\Core\Catalog\Services\ProductIndexer adds a filterable in_stock boolean — true if any variant currently passes ProductVariant::canBeFulfilledAtQuantity(1) (Lunar's own purchasability rule, not a naive stock > 0 check). Modules\Core\Catalog\DTOs\ProductFilters gets a matching inStockOnly flag. Reflects stock as of the last reindex only — nothing currently reindexes a product when an order decrements its stock, since that's a cart/checkout concern this doesn't attempt to solve; see docs/product-listing.md ("Stock goes stale between orders").
  • Modules\Core\Catalog\Services\ProductService::facets(string $field, ?ProductFilters $filters = null): array returns Meilisearch facet value counts (e.g. ['Brand A' => 48, 'Brand B' => 135]) for a discrete-value filterable field, scoped to the given filters. Uses Scout's plain ->options(['facets' => [...]]), merged directly into the raw Meilisearch query the same way filter/sort already are — no adoption of Lunar's separate SearchManager/Search facade needed. ProductService::priceRange(?ProductFilters $filters = null): array{min, max} covers the numeric-field case facets() explicitly doesn't (price would otherwise return one "facet" per exact price) — backed by Meilisearch's facetStats, not facetDistribution. priceRange() always excludes minPrice/maxPrice from the filter it builds (via a new $exclude parameter on the private buildFilter()), so a price slider's own bounds don't shrink to whatever range is already selected on it; other filters (collectionId, brand, inStockOnly) still apply normally. Documented in docs/product-listing.md.

Changed

  • Breaking: Renamed the Product module to Catalog, flattened. Every class under Modules\Core\Product\* (Contracts, DTOs, Enums, Services, Observers, Filament\Extensions, OptionTypes) now lives under Modules\Core\Catalog\* at the same sub-path — e.g. Modules\Core\Product\Services\ProductService is now Modules\Core\Catalog\Services\ProductService, Modules\Core\Product\DTOs\ProductFilters is now Modules\Core\Catalog\DTOs\ProductFilters. Class names themselves are unchanged (still ProductService, ProductIndexer, ProductFilters, etc.) — only the namespace/folder moved, to make room for Collection as a sibling concern under the same Catalog umbrella rather than a disconnected top-level module. Consuming apps must update every use Modules\Core\Product\... import and any FQCN reference (config/lunar/search.php's indexer registration, service provider bindings).
  • Breaking: Modules\Core\Providers\ProductServiceProvider renamed to Modules\Core\Providers\CatalogServiceProvider (composer.json's provider list updated accordingly) — it now only wires Catalog-namespace classes (ProductOptionTypeManager, ProductOptionReindexObserver), so the name follows the same by-concern convention as LocalizationServiceProvider/ReviewServiceProvider.
  • Breaking: Modules\Core\Review's flat Extensions//Pages/ folders now nest under Filament/, matching the strict per-concern subfolder convention already applied to Product(now Catalog)/Localization. Modules\Core\Review\Extensions\ProductResourceExtension is now Modules\Core\Review\Filament\Extensions\ProductResourceExtension; Modules\Core\Review\Pages\ManageProductReviews is now Modules\Core\Review\Filament\Pages\ManageProductReviews. Modules\Core\Review\Models\ProductReview is unchanged.
  • Breaking: ProductFilters(collectionId: ...) now matches a product in that collection or any of its descendant collections, not just direct assignment. Products in a Shopify-imported tree are typically attached only to leaf collections, so filtering strictly on direct assignment meant a parent/root category page (CollectionFilters(rootOnly: true)'s results, or any non-leaf collection) always returned zero products even though real products existed several levels down. Modules\Core\Catalog\Services\ProductIndexer adds a new filterable collection_ids field — every directly-assigned collection's id unioned with all of its ancestors' ids (via the newly eager-loaded collections.ancestors) — and ProductService::buildFilter() now filters collectionId against collection_ids instead of the old collections.id. The display-only collections field ({id, name}, direct assignments) is unchanged and no longer filterable.

[0.6.1] - 2026-08-27

Added

  • Modules\Core\Product\Contracts\ProductOptionTypeInterface describes how a category of Lunar\Models\ProductOption (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form meta jsonb column, and how an admin edits it via Filament — without introducing a new model. Registered via Modules\Core\Product\Services\ProductOptionTypeManager::get()->register([...]) (a singleton registry, same shape as Modules\Core\Notification\NotificationRegistry) from a service provider's boot(). An admin then picks one per ProductOption from an "Option Type" dropdown on the option's own edit form (added by Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension), stored in ProductOption::meta['option_type'] — deliberately not tied to the option's handle, since a shop's own handle naming shouldn't have to match a type's key. Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension hooks Lunar's own ValuesRelationManager (both extensions via LunarPanel::extensions(), registered in CorePlugin) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, Modules\Core\Product\OptionTypes\ColorOptionType, registered automatically by the new Modules\Core\Providers\ProductServiceProvider. Documented in docs/product-options.md.
  • Modules\Core\Product\Services\ProductIndexer::mapVariant() now includes each option's handle (alongside its translated name) in a variant's indexed options[] — previously only the translated option/value names and meta were indexed, with no stable, locale-independent identifier for which option a value belongs to.
  • Modules\Core\Product\Observers\ProductOptionReindexObserver, wired in the new Modules\Core\Providers\ProductServiceProvider, keeps Meilisearch in sync when a ProductOption or ProductOptionValue is saved or deleted — e.g. picking an Option Type or editing a color's hex. ProductIndexer::mapVariant() embeds each option value's meta directly into a product's indexed document, but saving the option/value never fires the product's own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every Lunar\Models\Product whose variants use the changed option (or option value) via the product_option_value_product_variant pivot, and calls ->searchable() on each.

Changed

  • Breaking: Modules\Core\Product\Services\ProductIndexer's indexed collections field is now an array of {id, name} objects instead of two parallel arrays (collections as bare ID strings, collection_names as translated names joined only by array index). collection_names is removed. Filtering by collection now targets the nested field collections.id (Meilisearch supports filtering on nested object fields), not bare collections — Modules\Core\Product\Services\ProductService::buildFilter() updated accordingly; ProductFilters(collectionId: ...)'s public API is unchanged. Run php artisan lunar:meilisearch:setup then lunar:search:index --refresh after upgrading (see docs/product-listing.md "Gotchas").
  • Breaking: ProductIndexer's indexed review_count/average_rating top-level keys are folded into the existing reviews key: reviews is now {items, count, average_rating} instead of a bare array with review_count/average_rating as separate sibling keys. reviews (the array of review items) moved to reviews.items.

[0.6.0] - 2026-08-27

Added

  • Modules\Core\Localization\Models\LanguageLine extends spatie/laravel-translation-loader's LanguageLine to fall back to the store's actual default language (LanguageCache::defaultLocale(), backed by Lunar's languages.default flag) instead of the package's stock behavior of falling back to the static config('app.fallback_locale') — the two were previously disconnected, so changing the default language via the Filament Languages resource had no effect on which locale an untranslated storefront label silently fell back to. Swapped in automatically via config('translation-loader.model') in LocalizationServiceProvider::register(); no consuming app changes needed. Documented in docs/localization.md ("Fallback locale follows the store's default language").

Changed

  • Breaking: Modules\Core\Catalog\ProductService::list() now returns a real Illuminate\Pagination\LengthAwarePaginator (built from the localized Meilisearch hits) instead of a plain array{data, meta} — gives callers normal Laravel pagination behaviour ($products->links(), standard JSON serialization) without ever touching Scout's raw paginateRaw() response directly. getById()/getBySlug() are unaffected (still return ?array).
  • ProductService::withLocalizedFields() (used by list(), getById(), getBySlug()) no longer hardcodes name/description as the only translated fields — it now reads every TranslatedText attribute on Product from Lunar\Base\AttributeManifest (the same source Lunar's own indexer reads), so a store's own custom translated attributes (e.g. seo_title, seo_description) are resolved and locale-stripped automatically with no code change here. Raw {handle}_{locale} keys (e.g. name_el, seo_title_en) are now stripped from every returned product, not just name_*/description_*.
  • Extracted Modules\Core\Localization\Services\LanguageCache (cached read layer over Lunar's languages table: all(), defaultLocale(), availableLocales(), forget()) out of LocaleMiddleware, which previously owned this as private/static methods despite not being middleware-specific behavior. LocaleMiddleware now takes LanguageCache via constructor injection. LocaleMiddleware::defaultLocale()/forgetLanguagesCache() (static) are removed — use app(LanguageCache::class) or inject LanguageCache directly.

Fixed

  • Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver::resolve() picked whichever lunar_urls row matched a slug first, which can be a soft-deleted product left behind by an earlier import batch rather than the current live one — a store can easily end up with more than one Product row sharing the same slug across re-imports, since a soft-deleted product's URL row isn't cleaned up. This silently broke every downstream lookup for that handle (e.g. Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter logging "no product found for handle, skipping review" and dropping the row, even though a live product with that exact handle existed). Rewrote as a join against lunar_products — via Product::query(), so Eloquent's SoftDeletes global scope excludes trashed rows — so only a URL pointing at a live product resolves.
  • Modules\Core\Review\Models\ProductReview had no registerMediaConversions() at all, unlike Product/ProductVariant which get one automatically from Lunar's own Lunar\Base\StandardMediaDefinitions. Modules\Core\Search\ProductIndexer::mapMedia() is shared across product, variant, and review media and always requests the small conversion — the first time a review had an attached image, indexing it threw Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidConversion, silently failing the product's MakeSearchable queue job (and everything queued after it, since Scout batches). Added a matching small conversion (300×300, same fit/border/background as Lunar's standard one) directly on ProductReview.

Breaking

  • Merged Modules\Core\Catalog and Modules\Core\Search into a single Modules\Core\Product concern, since both existed purely to serve Product (browsing/filtering vs. indexing/full-text search — two services, one concern), following a stricter subfolder convention (Contracts/, Enums/, Services/, DTOs/, Models/, etc. per concern) going forward:

    • Modules\Core\Catalog\ProductService → Modules\Core\Product\Services\ProductService
    • Modules\Core\Catalog\ProductFilters → Modules\Core\Product\DTOs\ProductFilters
    • Modules\Core\Catalog\ProductSort → Modules\Core\Product\Enums\ProductSort
    • Modules\Core\Search\ProductIndexer → Modules\Core\Product\Services\ProductIndexer
    • Modules\Core\Search\ProductSearchService → Modules\Core\Product\Services\ProductSearchService

    Consuming apps must update any direct references — notably config/lunar/search.php's 'indexers' map, which points at ProductIndexer by FQCN. Modules\Core\Catalog\ProductOptionTypeInterface (in-progress, not yet wired to anything) was deliberately left in place rather than moved.

  • Reorganized Modules\Core\Localization under the same stricter per-concern subfolder convention — Events/, Filament/, Listeners/ were already correctly categorized; four loose root files moved into typed buckets by structural role:

    • Modules\Core\Localization\LocaleMiddleware → Modules\Core\Localization\Middleware\LocaleMiddleware
    • Modules\Core\Localization\LanguageCacheObserver → Modules\Core\Localization\Observers\LanguageCacheObserver
    • Modules\Core\Localization\TranslationReader → Modules\Core\Localization\Services\TranslationReader
    • Modules\Core\Localization\TranslationService → Modules\Core\Localization\Services\TranslationService

    Modules\Core\Localization\Services\LanguageCache (added earlier in this same unreleased version) already lived at its correct final path — unaffected. The 'locale' route-middleware alias (registered in LocalizationServiceProvider) is unaffected for consuming apps using it by string alias rather than FQCN.

[0.5.4] - 2026-08-26

Added

  • Modules\Core\Catalog\ProductService::list() accepts a sort parameter (new ProductSort enum: PriceAsc, PriceDesc, Newest), translated into a Meilisearch sort clause — list() previously had no way to order results, since it always searches with an empty query string and so has no relevance score to fall back on. Modules\Core\Search\ProductIndexer::getSortableFields() now also marks price sortable (Lunar's base indexer only marks created_at/updated_at/skus/status). Requires re-syncing index settings (php artisan lunar:meilisearch:setup) on existing stores. Documented in docs/product-listing.md ("Sorting").

[0.5.3] - 2026-08-26

Fixed

  • Modules\Core\Search\ProductIndexer::toSearchableArray() threw column reference "id" is ambiguous on Postgres when computing channel_ids — $model->channels()->wherePivot('enabled', true)->pluck('id') joins lunar_channels and lunar_channelables, both of which have an id column, and the unqualified pluck('id') left Postgres unable to resolve which table's column to select (SQLite/MySQL tolerated the ambiguity). Qualified as pluck('lunar_channels.id').

[0.5.2] - 2026-08-26

Fixed

  • Modules\Core\Localization\LocaleMiddleware's shared view data only ever surfaced a single alternate locale (altLocale/altLocaleUrl, found via firstWhere('code', '!=', $current)) — correct by coincidence for a 2-language store, but silently dropped every locale past the first "other" one found for a 3+ language store, with no error. Replaced with altLocales, a collection of every other configured language (code, name, url for the current route each), so a language switcher or hreflang tags scale to any number of locales. Documented in docs/localization.md ("Shared view data — language switcher and hreflang tags").

[0.5.1] - 2026-08-25

Added

  • Modules\Core\Search\ProductIndexer now indexes channel_ids (filterable) — Lunar's base indexer only marks status as filterable, not channel assignment, so storefront search couldn't otherwise scope results to products actually assigned and enabled on the current sales channel. Computed from $product->channels()->wherePivot('enabled', true). Ported from an older Products branch whose remote had been deleted; the branch's other, now-superseded ProductIndexer changes were dropped in favor of the richer indexer already on master (collections, price, variants, reviews — see 0.5.0).

[0.5.0] - 2026-08-24

Added

  • Modules\Core\Catalog\ProductService: storefront product listing/filtering (list()) and single-product lookup (getById(), getBySlug()), reading directly from the Meilisearch index rather than the database — one data source, no ->get() model hydration. Returns plain arrays (not Eloquent models), meant to be called directly from a consuming app's controllers.
    • ProductFilters DTO: optional collectionId, brand, minPrice, maxPrice, translated into a Meilisearch filter expression.
    • Listing results are locale-aware: withLocalizedFields() resolves name/description from the indexer's per-locale fields, falling back to the store's default language (via LocaleMiddleware::defaultLocale()) when the current locale has no translation yet, instead of rendering blank.
    • Modules\Core\Search\ProductIndexer expanded well beyond its original collection/price additions to carry everything a detail page needs: id/slugs (filterable — getById()/getBySlug() resolve purely from the index, no database read), collection_names, tags, the full media gallery, per-variant data (sku, stock, purchasable, translated option/value names + meta for swatches, per-currency prices, variant media), and reviews (reviews, review_count, average_rating — public-safe fields only, reviewer_email deliberately excluded).
    • Modules\Core\Providers\ReviewServiceProvider (newly registered): re-indexes a product whenever one of its reviews is created/updated/deleted, since a review write doesn't touch the Product row and so never fires the product's own model events.
  • Modules\Core\Search\ProductSearchService: locale-aware full-text product search on top of the same Meilisearch index, for use by a storefront's search bar — separate from ProductService, which is for browsing/filtering without a query term.
  • docs/product-listing.md and docs/product-search.md — usage, full field reference, and design notes for the two services above.
  • docs/lunar.md "Gotchas": three new entries hit while building this — ProductOption/ProductOptionValue::name isn't attribute_data (so translateAttribute() silently returns null for it), a running queue:work process not picking up an edited Scout indexer class, and Scout's paginateRaw()->items() on the Meilisearch driver returning the whole raw response rather than a hit list.

Fixed

  • The admin login form (Modules\Core\Auth\Filament\Pages\Login) had no way back from the OTP-entry step to the email step short of reloading the page. A back() method resets to the email step; a "← Back" link/button is shown on the OTP step only.

[0.4.0] - 2026-08-06

Added

  • Locale-prefixed routing (Modules\Core\Localization\LocaleMiddleware): a locale route-middleware alias, opt-in per shop (not pushed onto the web group globally, since admin/Livewire/webhook routes must not be locale-redirected). Reads the first URL segment against Lunar's own languages table, sets App::setLocale(), and redirects unprefixed/unknown-locale requests to a resolved locale (Accept-Language match → default language → first language). Every locale is prefixed, including the default (/el/..., /en/...), never a bare root — avoids the hreflang/duplicate-content ambiguity of a bare-root default locale.
    • Language list cached with Cache::rememberForever(), invalidated via Modules\Core\Localization\LanguageCacheObserver dispatching LanguageCreated/LanguageUpdated/LanguageDeleted events (see below) rather than doing the work itself.
    • Language rename safety: renaming a Language::code (e.g. el → gr) no longer strands existing translations. MigrateTranslationsForRenamedLanguage (listening on LanguageUpdated) migrates every affected LanguageLine.text key from the old code to the new one and flushes both codes' translation caches — closing a real data-loss gap where a rename would otherwise make existing LanguageLine translations permanently unreachable.
  • Storefront UI label translations: pulled in spatie/laravel-translation-loader (self-registers via Composer package auto-discovery; its loader extends Laravel's file-based FileLoader and merges DB translations on top — existing Filament/Lunar vendor lang/ strings are unaffected). Labels are looked up via Laravel's native __('storefront.nav.cart'), kept in its own storefront group so nothing collides with Lunar/Filament's own translation groups.
    • Modules\Core\Command\InstallLunarCommand (overriding lunar:install) seeds a starter set of ~15 common e-shop labels (nav.*, cart.*, product.*, auth.*, search.*, English + Greek), idempotently guarded so it's safe on every boot.
    • Modules\Core\Localization\TranslationReader::group('storefront') returns the whole reduced/cached label array for a locale (backed by LanguageLine's own forever-cache) — for sharing to a view as $labels or @json()-ing to JS, on top of __() for single-key Blade lookups.
    • Admin UI: Modules\Core\Localization\Filament\Resources\LanguageLineResource (registered in CorePlugin) lists/searches/filters language_lines and edits each row's group, key, and one text input per locale currently in lunar_languages — locale columns/inputs are generated dynamically from the language list, so a new language needs no resource changes.
    • Event-driven writes: Modules\Core\Localization\TranslationService (create/update/delete) is the single write path for LanguageLine — the Filament resource's Create/Edit/Delete pages route through it rather than Filament's default direct-model writes. Dispatches TranslationCreated/TranslationUpdated (carries the full pre-update {group, key, text} snapshot, so a bare rename is tracked the same as a text edit)/TranslationDeleted, each handled by two listeners:
      • FlushTranslationCache — closes a real gap in LanguageLine's own self-invalidation, which only flushes locales/groups present after a save. Flushes the union of old and new group+locale combinations, so a locale removed from text, or a group/key rename, can't leave a stale cached array behind.
      • LogTranslationActivity — audits every write via the existing Modules\Core\Logging\ActivityLogService (lunar activity log channel), same created/updated/deleted shape as every other domain write in this project. Properties are flattened with Arr::dot() before logging (text.en, text.el instead of a nested text object) since Filament's Activity resource renders properties with a flat KeyValue field that can't display nested arrays.
  • Modules\Core\Providers\LocalizationServiceProvider — split out of the growing CoreServiceProvider (per this project's own "split when a provider does too much" convention) to own all locale/translation middleware, observer, and event-listener registration.

[0.3.0] - 2026-07-12

Added

  • Meilisearch product search: pulled in lunarphp/search (Lunar's driver-agnostic search abstraction — database/meilisearch/typesense engines, selectable via Scout's own SCOUT_DRIVER config) and lunarphp/meilisearch, wiring Meilisearch in as the search engine for products.
    • Search\ProductIndexer overrides Lunar's own indexer to strip HTML tags from string fields (e.g. name_en, description_en) before they reach the search index — Lunar's default indexer sends raw attribute HTML straight through, which pollutes relevance ranking and highlighting with markup.
    • Meilisearch itself is treated as app-level infrastructure, not a boboko-core concern: the actual Meilisearch container, host port, and master key live in each consuming app's own docker-compose.yml/.env (e.g. 3dealer), the same way Postgres and Valkey do — boboko-core only declares the PHP package dependency and the indexing code.

[0.2.0] - 2026-07-10

Added

  • Product reviews (Modules\Core\Review): a new ProductReview model + product_reviews table (plain, unprefixed — same convention as import_mappings), linked to Lunar's Product via a Product::reviews() macro (registered in CorePlugin, since Lunar\Models\Product is a vendor model and can't be edited directly).
    • JudgeMe CSV review importer (MigrateImport\JudgeMe\JudgeMeExportImporter), wired into the existing boboko:migrate:import --source=judgeme --type=export command: reads a Judge.me review export, resolves each row's product_handle to a Lunar product via Lunar\Models\Url, and creates/updates ProductReview rows idempotently via import_mappings (source=judgeme, source_type=review, keyed on Judge.me's metaobject_handle). Rows with no matching product are skipped with a logged warning rather than failing the whole import.
    • Review images (picture_urls in the CSV) are downloaded and stored as real media via Spatie MediaLibrary (ProductReview::IMAGES_COLLECTION), not just linked by URL — consistent with how product images are handled.
    • Admin UI: a new "Reviews" sub-navigation page on the product edit screen (Review\Pages\ManageProductReviews, wired via Review\Extensions\ProductResourceExtension), listing rating/title/reviewer with View, Reply, and Delete actions. The Reply action lets staff write/edit a reply directly from the table, setting replied_at. The View modal shows full review detail (body, reviewer email, location, source, dates, reply, downloaded images).

Fixed

  • Shopify\ShopifyExportImporter never wrote a Lunar Url (slug) row for imported products, despite docs/shopify-import.md specifying it should — meaning no code outside the importer itself could resolve "which Lunar product has handle X" (only the importer's own private import_mappings bookkeeping could). It now creates/updates a default Url row (slug = Shopify handle) per product on every import, which the new JudgeMe review importer depends on for product resolution.

[0.1.0] - 2026-07-09

Added

  • Shipping: registered Lunar's lunarphp/table-rate-shipping plugin (ShippingPlugin) directly on CorePlugin, so table-rate shipping is available to every consumer app without per-app wiring.
  • Product migration/import framework (Modules\Core\MigrateImport): a source-agnostic pipeline for importing a vendor's product catalog into Lunar.
    • boboko:migrate:import Artisan command — interactively prompts for source, type (export/API), and credentials or file path, then dispatches the import as a queued job (RunMigrateImportJob) on the default queue. The file-path prompt resolves relative to storage/app/private/imports/, so answering e.g. shopify picks up the first CSV found in imports/shopify/ automatically.
    • ImportSpec, Importer interface, and ImporterFactory (source+type → importer class) as the extension points for future sources (WooCommerce, etc.) and mechanisms (API vs. file export).
    • import_mappings table + ImportMapping model: a polymorphic (source, source_type, external_id) → model mapping used by every resolver to make imports idempotent and safely re-runnable.
    • DefaultLocale helper wrapping Lunar's Language::getDefault()->code, used anywhere a translatable field needs a locale key, instead of assuming app()->getLocale() matches Lunar's configured default.
    • Shopify CSV export importer (Shopify\ShopifyExportImporter), the first working source/type combination, verified end-to-end against a real 183-product/693-variant/332-image Shopify export (row counts in the CSV match 1:1 with imported Products/Variants/Media):
      • ShopifyCsvReader + ProductGroup group Shopify's flat, repeated-handle CSV rows into one row-group per product (product row, variant rows, image rows).
      • Ten resolvers under Shopify\Resolvers, each responsible for idempotently resolving-or-creating one Lunar entity: TaxClassResolver, ProductTypeResolver (auto-attaches system attributes to new types), BrandResolver, TagResolver, CollectionResolver (multi-level, multi-collection support via >-delimited breadcrumbs), ProductOptionResolver (dedupes options/values by slugified name so case variants like "Size"/"size" resolve to one row), AssetResolver (Spatie MediaLibrary via Product::addMedia(), matches local export images by UUID first, filename fallback), PriceResolver (minor-unit conversion per currency), ImportAttributeResolver and ProductAttributeResolver (custom cost_per_item/seo_title/seo_description attributes, field-type-aware attribute_data writing).
    • docs/shopify-import.md — full CSV-to-Lunar field mapping reference and import design notes.
    • docs/lunar.md — new "Gotchas" section documenting non-obvious Lunar behavior hit while building the importer (table-prefix/nested-set race, required ProductOption.handle, per-group Attribute.position, etc.).
    • CONTRIBUTE.md — local dev setup (path-repo + bin/dc-core.sh), and the manual DB-verification workflow used to build this feature.

Fixed

  • ProductOptionResolver created duplicate ProductOption/ProductOptionValue rows when the same option or value appeared with different casing across products (e.g. Shopify export rows using both "Size" and "size"), and could create a duplicate value within a single product's own variant rows due to relying on a stale lazy-loaded relation. Both now resolve by normalized (slugified) identity queried fresh from the database.
  • boboko:migrate:import could dispatch an import job with a blank file path (silent no-op failure) if the file-path prompt was answered empty; it now re-prompts until a valid, existing file is given.

[0.0.1] - 2026-07-03

First release.

Added

  • OTP-based authentication built around User instead of Customer (UserOtpService, UserOtpMail), replacing the earlier customer-scoped OTP flow.
  • UserCreated event with a CreateCustomerForUser listener to provision a Lunar customer automatically when a user is created.
  • UserRelationManager for managing users from the customer resource in the panel.
  • Stoic image UI component (resources/views/ui/stoic-image.blade.php) and its YAML-driven config/service (see Stoic::class).
  • config/core.php for module-level configuration.
  • AuthServiceProvider and CustomerServiceProvider now register alongside CoreServiceProvider.
  • Migrations: add OTP to users, drop OTP from Lunar customers, drop password from users, make name nullable on users and Lunar customers.
  • docs/modules.md documenting module structure.

Removed

  • CustomerOtpMail and CustomerOtpService, superseded by the user-based OTP flow.

Dependencies

  • Added explicit symfony/yaml requirement (used directly by Stoic::loadConfig()).