Compare commits

...
31 Commits
Author SHA1 Message Date
arvanitakis 547f07f01e Feat: Extracting Cart and Checout from 3dealer 2026-09-25 20:19:11 +03:00
arvanitakis ccab9bbb8e Chore: Storing UserOtp in cache before he is registered 2026-09-25 20:10:29 +03:00
arvanitakis 985f53efa2 Bump Version to 0.22.0 2026-09-25 15:58:41 +03:00
arvanitakis 23643db996 Feat: Adding More Storefront Labels 2026-09-25 15:53:29 +03:00
arvanitakis 099271e0a8 Feat: Pending Email Change Updates, Moving Mailables to core 2026-09-25 15:37:18 +03:00
arvanitakis 01c49485be Feat: Recording Legal Acceptance 2026-09-25 15:19:39 +03:00
arvanitakis 935b1d02f9 Feature: Adding Customer Recovery Consent to core, assigning it to the customer 2026-09-25 14:12:55 +03:00
arvanitakis 8fdaeda0ba Fix: Correcting writable profile fields from vat_no to tax_identifier 2026-09-25 14:03:04 +03:00
arvanitakis f416e207eb Bump version to 0.21.1 2026-09-25 13:59:30 +03:00
arvanitakis c55019d04a Chore: Claiming Guest Orders moved from 3dealer to core 2026-09-25 13:59:16 +03:00
arvanitakis 910d4c5df0 Bump version to 0.21.0 2026-09-25 13:50:46 +03:00
arvanitakis f1a0322d3f Feat: Upload Controller and Prune Commands Extraction from 3dealer 2026-09-25 13:48:42 +03:00
arvanitakis 6025ea4304 Feat: Updating FIle Services, Updating Order Views to list product extra options 2026-09-25 10:08:57 +03:00
arvanitakis 2b8fe5764c Feat: Creating Migration Models And Adapters for file Service 2026-09-25 09:10:56 +03:00
arvanitakis 69fdd0b4b8 Bump version to 0.20.2 2026-09-25 08:55:10 +03:00
arvanitakis 5347e01f0e Chore: Updating ProductDocumentLocalizer to translate Custom Fields 2026-09-25 08:39:15 +03:00
arvanitakis 78b46e5594 Chore: Adding Locales to Product Custom Fields 2026-09-24 23:42:43 +03:00
arvanitakis 621381beaa Bump Version to 0.20.1 2026-09-24 22:53:03 +03:00
arvanitakis 8f4156cfe8 Feat: Restructuring MigrateImport, Dispatching a per product job for import 2026-09-24 22:50:41 +03:00
arvanitakis a9b993182b Fix: Product Localizer now checks if a value is filled, or, not to show the fallback 2026-09-24 22:00:28 +03:00
arvanitakis 5a7fcd9f51 Fix: Removing Cart Lines along Products, so that the frontend loads 2026-09-24 21:57:31 +03:00
arvanitakis b4e9b8a4a9 Fix: Updating MIgrateImportCommand to accept language for import 2026-09-24 21:41:11 +03:00
arvanitakis c7035d6782 Bump version to 0.20.0 2026-09-23 09:47:28 +03:00
arvanitakis 4c0974bf84 Feat: Updating Product Indexer, and Product Sort 2026-09-23 09:41:21 +03:00
arvanitakis 4e15d8ef8c Fix: Updating Wipe Catalog Command to force delete products instead of the soft delete 2026-09-22 21:17:35 +03:00
arvanitakis 1c7efc6e4d Feature: Adding Custom Fields to Products 2026-09-22 21:17:01 +03:00
arvanitakis 59c57b37fc Feat: Updating ShopifyExportImporter and WipeCatalogCommand to handle images 2026-09-22 15:29:03 +03:00
arvanitakis 37b49963f6 Feat: Adding Backfill Skus to the Migrate Import Job 2026-09-22 14:55:18 +03:00
arvanitakis 050204f063 Feature: Adding Wipe Catalog Command for all products 2026-09-22 14:36:57 +03:00
arvanitakis c0ae9d8996 Feat: Adding Purchasable to 'in_stock' when importing a new product 2026-09-22 13:48:08 +03:00
arvanitakis cc1cf6ea7f Feat: Displaying Draft Products, only when AppDebug = true 2026-09-22 13:46:26 +03:00
105 changed files with 6915 additions and 186 deletions
+241
View File
@@ -4,6 +4,247 @@ 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.22.0] - 2026-09-25
### Added
- `Modules\Core\Customer\Services\CustomerEmailChangeService` — changing an account's login
email (core's login is passwordless, so the email IS the login): `request()` validates the new
address is free and throttled (3 codes/10min), `confirm()` allows 5 wrong guesses per code,
re-checks the address is still free, switches it, notifies the old address (masked new
address), and claims guest orders for the new email. The pending change lives on the user's
own row (`pending_email`/`pending_email_code_hash`/`pending_email_expires_at`/
`pending_email_attempts` — new migration), the same convention as the existing OTP login
columns, rather than the session — a code arrives by email and is often opened on a different
device/session than the one that requested it. New core-owned mailables
(`Auth\Mail\EmailChangeCodeMail`/`EmailChangedNoticeMail`) with default views, overridable
per-app the same way `UserOtpMail`'s already is. Dispatches a new `Auth\Events\
UserEmailChanged` event.
- `Modules\Core\Customer\Services\CustomerAccountService::setRecoveryConsent()` — the account's
standing "email me a reminder if I don't finish my order" opt-in, written to the customer's
meta in the same shape `Checkout\Services\CheckoutService::setRecoveryConsent()` already writes
on the cart. Skips the write when nothing changed; dispatches a new `Customer\Events\
CustomerRecoveryConsentSet` event (also wired into the existing account-activity audit log).
3dealer's own duplicated implementations in `CheckoutController`/`AccountController` now call
this instead.
- `terms_accepted_at`/`terms_version`/`privacy_policy_version` columns on `users` — recorded once,
by a new `Auth\Listeners\RecordLegalAcceptanceForNewUser` (listening on `UserCreated`), the
moment a genuinely new signup requests their first OTP code; never touched again for an
existing user. Included in the User-scope privacy export (`CustomerDataProvider::
exportForUser()`).
- ~90 previously-unseeded `storefront.*` translation keys (login/OTP copy, account profile and
email-change flow, order history, contact form, product custom-fields and stock-error
messages, reviews, wishlist) added to `Localization\Services\StorefrontLabels` — these were
already called via `__()`/`trans_choice()` across a consuming app's views with no seeded
value at all, silently rendering the raw translation key in production.
### Fixed
- `CustomerAccountService::WRITABLE_PROFILE_FIELDS` listed `vat_no`, but Lunar's `customers`
column has been `tax_identifier` since a 2025 Lunar migration — passing `vat_no` was silently
dropped by the allowlist, and `tax_identifier` couldn't be written through `updateProfile()` at
all. Consuming code was working around this with a separate direct `$customer->update(...)`
call that bypassed `CustomerProfileUpdated`'s audit trail entirely; that workaround is no
longer needed now that the field is correctly allowlisted.
## [0.21.1] - 2026-09-25
### Changed
- `GuestOrderClaimer` and its `UserAuthenticated` listener moved from 3dealer's own
`App\Services`/`App\Listeners` into `Modules\Core\Customer\Services\GuestOrderClaimer` /
`Listeners\ClaimGuestOrdersOnLogin`, registered in `CustomerServiceProvider` — attaching a
placed guest order to an account once its billing `contact_email` case-insensitively matches
the account's email (only ever safe right after the shopper has proved they own that email: a
login code, or 3dealer's own email-change confirmation) was already core-appropriate logic
with no 3dealer-specific behavior. `Account\EmailController::verify()` now calls the core
service directly.
## [0.21.0] - 2026-09-25
### Added
- `Modules\Core\File` — a generic, storage-backend-agnostic file registry: `Models\File` (a
`files` table row per stored file — disk, path, original name, mime, size, a `purpose` tag,
and a nullable polymorphic owner), `Services\FileService` (store/retrieve/download/exists/
delete/list/`pruneUnowned`, delegating every actual byte-level operation to a
`Contracts\FileAdapterInterface` resolved per disk — `Adapters\LocalFileAdapter` today, the
same contextual-binding pattern `Shipping\Contracts\CarrierFulfillmentInterface` already uses
per carrier, so a future `S3FileAdapter` is one class and one more match arm, nothing else
changes), and `Http\Controllers\DownloadFileController` — a signed-URL-only route
(`files.download`) any consuming app can mint a link to, serving either inline (a preview) or
as a forced download (`?download=1`).
- `Modules\Core\File\Http\Controllers\UploadFileController` — an abstract base for "accept an
upload, validate it, store it via `FileService`, return its id" endpoints. Which
extensions/sizes are acceptable is deliberately left to a concrete subclass's own
`purpose()`/`validationRules()` overrides (ordinary server-side PHP, never trusting anything
the request itself claims about its own limits) — a real policy decision that can differ per
site and even per product/field, not something a shared base class or config file could
express safely.
- `boboko:file:prune-unowned {purpose}` — deletes every unowned `File` of a given purpose past
its grace period (`--hours`, default 24). Generic: any consuming app schedules it once per
purpose string it stores files under.
- `Modules\Core\Cart\Events\CartLineAdded`/`Checkout\Events\OrderPlaced` listeners
(`File\Listeners\AttachCustomFieldFileToCartLine`/`TransferCustomFieldFileOwnership`) that
re-point a `File`'s ownership from unowned → the real `CartLine` once one exists, then from
that `CartLine` → the `OrderLine` an order is placed with — so a File referenced by a product
custom field survives the cart it originated from being cleared, without ever being copied.
### Changed
- The admin order-lines table's collapsible details dropdown (next to the existing price
breakdown) now shows a product's custom-field answers (`OrderLine.meta.custom_fields`) — a
bordered table matching the existing price-breakdown one, with a thumbnail preview and a
download-icon link for a file answer, resolved through `File\Services\FileService`'s signed
route. Previously never shown anywhere in the admin.
- 3dealer's product custom-field photo upload (`CustomFieldUploadController`), cart line meta
(`CartController::customFieldsMeta()`), and pruning (formerly its own `PruneCustomFieldUploads`
command) now go through `Modules\Core\File` instead of a bespoke `Crypt::encryptString({disk,
path, name, mime})` reference scheme — a cart/order line's file answer is now just a `File`
row's `file_id`, with `File` as the single source of truth for every other detail.
## [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
### Fixed
- `Modules\Core\Catalog\Support\ProductDocumentLocalizer::withLocalizedFields()` — a translated
attribute (name, description, ...) saved blank for the current locale kept the empty string
instead of falling back to the store's default language, since `??` only falls back on a
missing/null key, not an empty one. A product with no English copy yet showed a blank
title/description on `/en/` instead of its Greek content.
- `Modules\Core\Command\WipeCatalogCommand` — now also deletes every `CartLine` referencing a
`product_variant` purchasable as part of the wipe (line items only, `Cart` records themselves
are left alone). Previously, any cart still holding a line for a wiped variant crashed the
entire storefront on every page load (`PricingManager::for()` throws when the variant a line
points at no longer exists) until those dangling lines were removed by hand.
- `Modules\Core\Command\MigrateImportCommand` now asks which language a Shopify export file's
own text is written in before importing, instead of silently assuming it matches the store's
default language — the two are independent facts, and a mismatch used to save every imported
product's name/description under the wrong language. Backing class renamed `DefaultLocale` →
`Modules\Core\MigrateImport\Services\ImportLocale` to stop implying that assumption.
### Changed
- `Modules\Core\MigrateImport` reorganized to match every other module's layout
(`Contracts/`, `DTOs/`, `Jobs/`, `Models/`, `Services/`) instead of loose files at each
namespace root — no behavior change, but every `use` of `Importer`, `ImportSpec`,
`ImporterFactory`, `ImportLocale`, `RunMigrateImportJob`, `ShopifyExportImporter`,
`ShopifyCsvReader`, `ProductGroup`, `JudgeMeExportImporter`, and `JudgeMeCsvReader` moved to
its new namespace.
- `Modules\Core\MigrateImport\Shopify\Services\ShopifyExportImporter::import()` now dispatches
one `Jobs\ImportShopifyProductJob` per product (via `Bus::batch()`) instead of importing every
product inline in a single queued job. A large export's variants, resolvers, and media
downloads accumulating in one long-lived process routinely exceeded `queue:work`'s
`--memory` limit; the worker died mid-run, the container restarted, and the entire import
started over from the first row every time, never actually finishing. Splitting into one job
per product resets memory between products, and a restart now only repeats whichever single
product was in flight. `Modules\Core\Catalog\Services\SkuBackfillService::backfill()` moved
from running right after the import loop to the batch's `then()` callback, since it must wait
for every product job to finish rather than firing the moment jobs are merely queued.
## [0.20.0] - 2026-09-23
### Added
- `Modules\Core\Catalog\Support\ProductFilterBuilder::withVisibility()` — every storefront
product read (`ProductService::list()`/`getById()`/`getBySlug()`/`random()`/`facets()`/
`priceRange()`, and `ProductSearchService`) now excludes `status = "draft"` products unless
`APP_DEBUG` is true. Previously nothing filtered by status anywhere in this service — a draft
product was fully visible on the storefront in every environment, always.
- Per-product custom input fields (`Modules\Core\Catalog\Models\Product::$custom_fields`) — a
repeater on the product edit form lets a merchant define extra input a shopper fills in on
that product's page before adding it to cart (a reference photo upload, personalization
text, etc.), each field a `{key, type: text|textarea|file, label, required}` entry.
Deliberately not a Lunar `ProductOption`: an option's values are a fixed, admin-authored list
that define variants, which doesn't fit "the shopper uploads their own unique photo."
Required a new first-party `Product` model (registered via `ModelManifest::replace()`) purely
to add a cast and `$fillable` entry Lunar's own base model doesn't have for this column — see
that class's own docblock for two real Lunar-integration bugs this surfaced (below).
- `boboko:wipe-catalog` (`Modules\Core\Command\WipeCatalogCommand`) — irreversibly deletes every
Product and everything that only exists because of a product (variants, variant prices,
product-option assignments, images/media, associations, the `ImportMapping` rows tying them
back to an external source, the Meilisearch index), leaving catalog structure other products
could still reference untouched (ProductOption/ProductOptionValue definitions, Brands,
Collections, Tags, Customer Groups). Gated by an OTP emailed to a real Staff account (reusing
`Auth\Services\OtpService`, the same mechanism admin login already uses) plus typing the exact
product count back — not a plain yes/no confirm.
- `Modules\Core\Auth\Services\OtpService::generateAndSend()` gained an optional `$purpose`
parameter (default `'login'`, fully backward compatible) — `OtpMail` picks its subject/intro
copy from a small fixed set of known purposes, so a destructive-command confirmation code
reads as "Confirm: Wipe Catalog," not the login flow's "Your login code."
- `Modules\Core\Catalog\Services\SkuBackfillService` — the actual backfill logic behind
`boboko:catalog:backfill-skus`, extracted so `MigrateImport\RunMigrateImportJob` can also call
it automatically right after a Shopify import (gated on `$spec->source === 'shopify'`, the
only source that creates variants at all) — no separate manual step needed after a migration.
- `ProductSort::Popularity` — sorts by a new `order_count` field Meilisearch now indexes per
product (trailing-year, physical order lines only, aggregated across a product's variants) —
the same "popular" definition Lunar's own admin dashboard "Popular Products" widget already
uses. Not wired into the storefront's sort dropdown yet; callable directly via
`ProductService::list(sort: ProductSort::Popularity)`.
### Fixed
- `MigrateImport\Shopify\ShopifyExportImporter` created every variant with Lunar's own column
default `purchasable = 'always'` (purchasable regardless of stock) rather than respecting the
real `Variant Inventory Qty` the import itself provides — now explicitly set to `'in_stock'`.
Forward-only; does not retroactively touch variants from a prior import run.
- `WipeCatalogCommand::wipe()` used `chunkById()` while deleting rows inside the loop — a known
pitfall where `chunkById()` re-queries "id > lastSeenId" every iteration, so deleting rows
shrinks the table out from under it and can silently skip products that were never actually
deleted at all. Fixed by always re-querying the first N remaining rows instead of advancing
an id cursor, so every product is visited exactly once regardless of how many are deleted out
from under the query as it goes.
- `WipeCatalogCommand` called `delete()`, not `forceDelete()`, on `Product`/`ProductVariant` —
both use `SoftDeletes`, so an "irreversible" wipe only trashed rows, leaving them sitting in
the table. Combined with the `chunkById` bug above, this left ~185 zero-variant ghost
`Product` rows in practice, which then crashed the admin's own global search (Lunar's
`ProductResource::getGlobalSearchResultDetails()` assumes every returned product — trashed
ones deliberately included, by Lunar's own design — has at least one variant). Fixed to
`forceDelete()`; the existing ghost rows were removed directly (none had live order/cart
references). `wipe()` also now clears `'image'`-type `ImportMapping` rows, not just
`'product'`/`'variant'`.
- `ShopifyExportImporter::resolveOrImportImage()` trusted a cached `ImportMapping`'d `Media`
object unconditionally — now verifies the row still exists and is still attached to the
current product before reusing it, falling through to a fresh import/attach otherwise. Makes
a re-import robust to orphaned media regardless of what left them behind (e.g. a prior
`WipeCatalogCommand` run, before the fix above).
- Cart admin view (`Cart\Filament\Resources\CartResource\Pages\ViewCart`) 500'd
(`Lunar\Exceptions\MissingCurrencyPriceException`) for any cart still holding a line whose
purchasable no longer exists (e.g. after `boboko:wipe-catalog`) — `Cart::calculate()` now has
that exception caught, falling back to an uncalculated cart; every total field already
rendered `?->formatted() ?? '—'`, so the page degrades to showing "—" instead of a 500.
- Creating or editing a Payment Method offered "Capture mode" (Charge immediately / Hold now,
charge later) even for `cash-on-delivery`, whose driver has no `authorize()` method at
all — selecting "authorize" there would have fatally errored at checkout. Now hidden/
non-required unless the resolved driver implements `SupportsAuthorization`.
- `Lunar\Base\Traits\Searchable::indexer()` (and its sibling filterable/sortable-attribute
methods) resolve their configured indexer via `$config[self::class]` — but `self::class`
inside a trait method is a compile-time literal bound to whichever class first `use`s the
trait, so it always evaluates to `Lunar\Models\Product`, never a subclass, regardless of
which instance calls it. `config/lunar/search.php`'s `'indexers'` map must stay keyed by
`Lunar\Models\Product::class`, not the new `Product` subclass — keying it by the subclass
made the lookup miss entirely and silently fall back to a near-empty default indexer, wiping
every filterable/sortable attribute the index had. Caught live, reverted; documented in the
config file itself so it isn't repeated.
- `CustomerServiceProvider`/`CatalogServiceProvider` called `ModelManifest::replace()` directly
from `boot()` — `LunarServiceProvider` (lunarphp/core) calls `Facades\ModelManifest::
register()` from its OWN `boot()`, re-discovering every `Lunar\Models\*` class and silently
overwriting any `replace()` registered earlier in the provider boot order. Both now defer to
`$this->app->booted()`, which only runs once every provider's `boot()` has completed.
## [0.19.0] - 2026-09-18 ## [0.19.0] - 2026-09-18
### Added ### Added
+3 -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.19.0", "version": "0.22.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -38,11 +38,13 @@
"Modules\\Core\\Providers\\AuthServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\CheckoutServiceProvider", "Modules\\Core\\Providers\\CheckoutServiceProvider",
"Modules\\Core\\Providers\\CheckoutModuleServiceProvider",
"Modules\\Core\\Providers\\PaymentServiceProvider", "Modules\\Core\\Providers\\PaymentServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider", "Modules\\Core\\Providers\\LocalizationServiceProvider",
"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"
+44
View File
@@ -0,0 +1,44 @@
<?php
/*
* Per-site settings for the cart + checkout module (see
* Modules\Core\Providers\CheckoutModuleServiceProvider). Publishable —
* artisan vendor:publish --tag=core-config.
*/
return [
/*
* Name of the storefront's login route. The checkout's login tab and the
* confirmation page link to it with `?redirect=<checkout path>`, so the
* login page must send the shopper back there afterwards. null: no login
* offered in checkout at all.
*/
'login_route' => 'login',
/*
* Name of the storefront's product-listing route — where confirmation()
* redirects a visit with no placed order to look at (session expired,
* direct navigation, a bookmark). route($this, $locale) must resolve.
*/
'products_route' => 'products',
/*
* ISO 3166-1 alpha-3 code fixing checkout to a single country (a hidden
* field, forced server-side — no country picker shown at all). null (the
* default) gives the full country/region picker, for a multi-country
* store.
*/
'store_country_iso3' => null,
/*
* The `purpose` tag CartController expects a product custom field's
* `file` answer to already carry (see Modules\Core\File\Models\File) —
* matches whatever purpose string the host's own upload endpoint
* (extending Modules\Core\File\Http\Controllers\UploadFileController)
* tags its stored files with. This module never reaches into that
* host controller directly; this config value is the one shared
* source of truth between the two.
*/
'custom_field_upload_purpose' => 'custom-field-upload',
];
+11
View File
@@ -125,6 +125,17 @@ return [
'generation_limit' => 3, 'generation_limit' => 3,
'generation_decay_minutes' => 10, 'generation_decay_minutes' => 10,
], ],
// Modules\Core\Customer\Services\CustomerEmailChangeService — same
// shape/reasoning as auth.otp above, independent limits since this
// is a separate flow (changing an existing account's login email,
// not logging in).
'email_change' => [
'max_attempts' => 5,
'generation_limit' => 3,
'generation_decay_minutes' => 10,
'expiry_minutes' => 10,
],
], ],
]; ];
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Per-product, customer-authored input fields — a personalized-statue
* product needing a reference photo upload and an optional engraving
* textarea, for example. Deliberately NOT modeled as a Lunar ProductOption
* (see Modules\Core\Catalog\Contracts\ProductOptionTypeInterface's own
* docblock): an option's values are a fixed, admin-authored list that
* define variants (Red/Green/Blue) — a photo upload has no such list, it's
* unique per order, and creates no variant at all. This is a genuinely
* different concept that happens to configure on the same product page.
*
* Array of {key, type: 'text'|'textarea'|'file', label, required} — `key`
* is what a submitted answer is keyed by in CartLine/OrderLine.meta (both
* already have a `meta` json column — see Modules\Core\Cart\Services\
* CartService::addLine()'s own $meta parameter), not a new table, since
* this is small, rarely-queried per-product config, the same reasoning
* ShippingMethod.data/PaymentMethod.data already follow for their own
* per-row settings.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
$table->json('custom_fields')->nullable()->after('attribute_data');
});
}
public function down(): void
{
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
$table->dropColumn('custom_fields');
});
}
};
@@ -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\File\
* 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('files', 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('files');
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Which terms/privacy policy version an account was created under — the
* storefront login page shows a notice ("By continuing, you accept the
* Terms of Use and have read the Privacy Policy") that a new signup
* implicitly agrees to just by requesting an OTP code, so this is
* recorded the moment Modules\Core\Auth\Services\UserOtpService::
* generateAndSend()'s firstOrCreate() actually creates the row — never
* for an existing user, whose original acceptance (whatever version was
* live at the time) must not be silently overwritten by a later config
* value. Nullable: every user created before this migration has none of
* the three, which is the honest answer ("we don't know what they saw"),
* not something to backfill with today's config values.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('terms_accepted_at')->nullable()->after('otp_attempts');
$table->string('terms_version')->nullable()->after('terms_accepted_at');
$table->string('privacy_policy_version')->nullable()->after('terms_version');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['terms_accepted_at', 'terms_version', 'privacy_policy_version']);
});
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Backs Modules\Core\Customer\Services\CustomerEmailChangeService — the
* pending new-email change lives on the user's own row, same convention
* as the existing otp_code/otp_expires_at/otp_attempts columns (Auth\
* Services\UserOtpService), rather than the session: a change requested
* on one device/session must still be confirmable from another (a code
* arrives by email, which is often opened somewhere else entirely), and
* a request-scoped session can't survive that.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('pending_email')->nullable()->after('privacy_policy_version');
$table->string('pending_email_code_hash')->nullable()->after('pending_email');
$table->timestamp('pending_email_expires_at')->nullable()->after('pending_email_code_hash');
$table->unsignedTinyInteger('pending_email_attempts')->default(0)->after('pending_email_expires_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'pending_email',
'pending_email_code_hash',
'pending_email_expires_at',
'pending_email_attempts',
]);
});
}
};
+856
View File
@@ -0,0 +1,856 @@
/*
* Cart + checkout module — generic default styling.
*
* Deliberately NOT wrapped in a Tailwind-style `@layer`. An earlier version
* put these rules in `@layer bbk-checkout`, positioned (via a cross-file
* @layer ordering statement) to sit between Tailwind's `base` and
* `components` — in theory enough to beat Preflight's element resets while
* still losing to a host override. In practice a build tool processing each
* CSS file in isolation (Vite/Lightning CSS here) optimizes away exactly the
* cross-file ordering information that trick depends on, so it silently
* didn't work: Preflight's `button { background-color: transparent }`,
* `* { border-width: 0 }` etc. (layered, in `base`) were beating every
* `.bbk-*` rule below regardless of specificity — buttons with no
* background, no border, wrong font-size.
*
* Plain, unlayered CSS sidesteps the whole problem: an unlayered rule always
* beats ANY layered rule (Preflight included), full stop, no ordering tricks,
* nothing a bundler can silently invalidate. This file is loaded BEFORE the
* host's own stylesheet (see the @vite call in the layout <head>), so:
*
* - a later PLAIN (unlayered) `.bbk-*` rule in the host stylesheet wins —
* same specificity, later in source order
* - a later host rule with a MORE specific selector wins regardless
* - a host rule inside `@layer components`/`@layer utilities` does NOT
* win — unlayered always beats layered. Theme this module from plain
* rules in app.css, not from inside a Tailwind layer.
*
* Two ways to theme this, cheapest first:
*
* 1. Redefine the --bbk-* custom properties below (from :root, or scoped to
* .bbk-cart for a cart-only override) — covers colour, radius, shadow,
* font without touching a single selector below.
*
* :root { --bbk-color-accent: var(--color-brand); --bbk-radius: 0; }
*
* 2. Override individual `.bbk-*` rules directly (as plain rules, per
* above) for anything structural (spacing, layout) the variables don't
* cover.
*
* This file's own look is a deliberately neutral placeholder — inoffensive,
* not "designed" — so a project always has something reasonable before it
* themes; it is not meant to be edited per project.
*/
:root {
--bbk-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--bbk-color-text: #18181b;
--bbk-color-muted: #71717a;
--bbk-color-bg: #ffffff;
--bbk-color-bg-muted: #f4f4f5;
--bbk-color-border: #e4e4e7;
--bbk-color-accent: #18181b;
--bbk-color-accent-text: #ffffff;
--bbk-color-danger: #dc2626;
--bbk-radius: 8px;
--bbk-radius-sm: 4px;
--bbk-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
}
.bbk-cart[hidden] { display: none; }
.bbk-cart {
position: fixed;
inset: 0;
z-index: 1000;
font-family: var(--bbk-font);
font-size: 0.9375rem;
line-height: 1.4;
color: var(--bbk-color-text);
}
.bbk-cart-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.25s ease;
}
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-backdrop { opacity: 1; }
.bbk-cart-panel {
position: absolute;
top: 0;
right: 0;
display: flex;
flex-direction: column;
width: min(420px, 100vw);
height: 100%;
background: var(--bbk-color-bg);
box-shadow: var(--bbk-shadow);
transform: translateX(100%);
transition: transform 0.25s ease;
}
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-panel { transform: translateX(0); }
.bbk-cart-panel-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.5rem 1.5rem 1.25rem;
border-bottom: 1px solid var(--bbk-color-border);
}
.bbk-cart-heading {
margin: 0;
font-size: 1.375rem;
font-weight: 700;
}
.bbk-cart-dismiss,
.bbk-cart-item-remove,
.bbk-cart-qty-btn {
cursor: pointer;
background: none;
border: 0;
padding: 0;
font: inherit;
line-height: 1;
color: var(--bbk-color-muted);
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
.bbk-cart-dismiss {
font-size: 1.75rem;
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--bbk-radius-sm);
flex-shrink: 0;
}
.bbk-cart-dismiss:hover { color: var(--bbk-color-text); background: var(--bbk-color-bg-muted); }
.bbk-cart-item-remove:hover { color: var(--bbk-color-danger); }
.bbk-cart-dismiss:focus-visible,
.bbk-cart-item-remove:focus-visible,
.bbk-cart-qty-btn:focus-visible,
.bbk-cart-qty-input:focus-visible,
.bbk-cart-checkout:focus-visible,
.bbk-cart-coupon-input:focus-visible,
.bbk-cart-coupon-submit:focus-visible,
.bbk-cart-coupon-remove:focus-visible {
outline: 2px solid var(--bbk-color-accent);
outline-offset: 2px;
}
.bbk-visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.bbk-cart-panel-body {
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
padding: 1.5rem;
}
.bbk-cart-items {
list-style: none;
margin: 0 0 2rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.bbk-cart-item {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 0.875rem;
align-items: start;
}
.bbk-cart-item-media img {
display: block;
width: 72px;
height: 72px;
object-fit: cover;
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
}
.bbk-cart-item-detail { min-width: 0; }
.bbk-cart-item-title {
display: block;
margin: 0 0 0.25rem;
font-weight: 600;
color: inherit;
text-decoration: none;
}
a.bbk-cart-item-title:hover { text-decoration: underline; }
.bbk-cart-item-variant {
margin: 0 0 0.25rem;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
/* A line's custom-field answers (checkout::partials.line-custom-fields). */
.bbk-line-fields {
display: grid;
gap: 0.25rem;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.bbk-line-field dt {
color: var(--bbk-color-muted);
}
.bbk-line-field dd {
margin: 0;
white-space: pre-line;
overflow-wrap: anywhere;
}
.bbk-line-field-file {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: inherit;
}
.bbk-line-field-file img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 0;
}
.bbk-cart-item-unit {
margin: 0 0 0.625rem;
color: var(--bbk-color-muted);
}
.bbk-cart-item-aside {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.bbk-cart-item-total { margin: 0; font-weight: 600; }
.bbk-cart-item-remove {
font-size: 1.125rem;
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.bbk-cart-qty {
display: inline-flex;
align-items: center;
gap: 0;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
overflow: hidden;
}
.bbk-cart-qty-btn {
width: 1.75rem;
height: 1.75rem;
background: var(--bbk-color-bg-muted);
}
.bbk-cart-qty-btn:hover { background: var(--bbk-color-border); color: var(--bbk-color-text); }
.bbk-cart-qty-input {
width: 2.25rem;
height: 1.75rem;
border: 0;
border-left: 1px solid var(--bbk-color-border);
border-right: 1px solid var(--bbk-color-border);
text-align: center;
font: inherit;
color: inherit;
background: var(--bbk-color-bg);
appearance: textfield;
-moz-appearance: textfield;
}
.bbk-cart-qty-input::-webkit-outer-spin-button,
.bbk-cart-qty-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.bbk-cart-summary {
padding-top: 1.25rem;
border-top: 1px solid var(--bbk-color-border);
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.bbk-cart-summary-row {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); }
.bbk-cart-summary-pending {
color: var(--bbk-color-muted);
font-size: 0.8125rem;
}
.bbk-cart-summary-row--total {
margin-top: 0.375rem;
padding-top: 0.875rem;
border-top: 1px solid var(--bbk-color-border);
font-size: 1.0625rem;
font-weight: 700;
}
.bbk-cart-coupon-form {
display: flex;
gap: 0.5rem;
}
.bbk-cart-coupon-input {
flex: 1 1 auto;
min-width: 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
font: inherit;
color: inherit;
background: var(--bbk-color-bg);
}
.bbk-cart-coupon-submit {
flex: 0 0 auto;
padding: 0.5rem 0.875rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.bbk-cart-coupon-submit:hover { background: var(--bbk-color-border); }
.bbk-cart-coupon-applied {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
}
.bbk-cart-coupon-code {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.bbk-cart-coupon-remove {
flex: 0 0 auto;
background: none;
border: 0;
padding: 0;
font: inherit;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
text-decoration: underline;
cursor: pointer;
transition: color 0.15s ease;
}
.bbk-cart-coupon-remove:hover { color: var(--bbk-color-danger); }
.bbk-cart-coupon-error {
margin: 0.5rem 0 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-cart-error {
margin: 0;
padding: 0.75rem 1.5rem 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-add-to-cart-error {
margin: 0.375rem 0 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-cart-checkout {
display: block;
width: 100%;
padding: 0.875rem 1.25rem;
border: 1px solid var(--bbk-color-accent);
border-radius: var(--bbk-radius);
background: var(--bbk-color-accent);
color: var(--bbk-color-accent-text);
font: inherit;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s ease;
}
.bbk-cart-checkout:hover { opacity: 0.85; }
.bbk-cart-checkout:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.bbk-cart-empty {
text-align: center;
color: var(--bbk-color-muted);
padding: 2.5rem 0;
}
/* ───────────────────────────────────────────────────────────────────
Checkout page — two columns: fields on the left, order summary (the
same cart-body partial the drawer uses) on the right.
─────────────────────────────────────────────────────────────────── */
.bbk-checkout-page {
max-width: 1100px;
margin: 0 auto;
padding: 2.5rem 1.5rem 5rem;
font-family: var(--bbk-font);
font-size: 0.9375rem;
line-height: 1.4;
color: var(--bbk-color-text);
}
.bbk-checkout-heading {
margin: 0 0 2rem;
font-size: 1.75rem;
font-weight: 700;
}
.bbk-checkout {
display: grid;
grid-template-columns: 1fr 380px;
gap: 3rem;
align-items: start;
}
@media (max-width: 860px) {
.bbk-checkout { grid-template-columns: 1fr; }
}
.bbk-checkout-main {
display: flex;
flex-direction: column;
gap: 2rem;
}
.bbk-checkout-section {
padding-bottom: 2rem;
border-bottom: 1px solid var(--bbk-color-border);
display: flex;
flex-direction: column;
gap: 1rem;
}
.bbk-checkout-section-heading {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.bbk-checkout-note {
margin: 0;
color: var(--bbk-color-muted);
font-size: 0.875rem;
}
/* Contact: "logged in as" line, or the guest login prompt */
.bbk-checkout-logged-in,
.bbk-checkout-login-prompt { margin: 0; }
.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; }
/* Fields */
.bbk-field { display: flex; flex-direction: column; gap: 0.375rem; }
.bbk-field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 480px) {
.bbk-field-row { grid-template-columns: 1fr; }
}
.bbk-field-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--bbk-color-muted);
}
.bbk-field-input {
padding: 0.625rem 0.75rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
font: inherit;
color: inherit;
background: var(--bbk-color-bg);
}
.bbk-field-input:focus-visible {
outline: 2px solid var(--bbk-color-accent);
outline-offset: 2px;
}
.bbk-field-input:disabled {
background: var(--bbk-color-bg-muted);
color: var(--bbk-color-muted);
}
.bbk-field-input--error { border-color: var(--bbk-color-danger); }
.bbk-field-error {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
/* A fixed, non-editable field value (e.g. the store's single country). */
.bbk-field-static {
margin: 0;
padding: 0.625rem 0.75rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
color: var(--bbk-color-muted);
}
textarea.bbk-field-input { resize: vertical; }
.bbk-checkbox {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
/* For a full-sentence label that can wrap — align the box to the first line. */
/* "I want an invoice": company/ΑΦΜ only while ticked */
.bbk-invoice { display: flex; flex-direction: column; gap: 1rem; }
.bbk-invoice:not(:has(input[name="wants_invoice"]:checked)) .bbk-invoice-fields { display: none; }
.bbk-checkbox--stacked {
display: flex;
align-items: flex-start;
margin-top: 0.75rem;
color: var(--bbk-color-muted);
}
.bbk-checkbox--stacked input { margin-top: 0.15rem; flex-shrink: 0; }
.bbk-checkout-shipping-fields {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Shipping method */
.bbk-checkout-shipping-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-checkout-shipping-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
cursor: pointer;
transition: border-color 0.15s ease;
}
.bbk-checkout-shipping-option:has(input:checked) { border-color: var(--bbk-color-accent); }
.bbk-checkout-shipping-option-detail {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.bbk-checkout-shipping-option-name { font-weight: 600; }
.bbk-checkout-shipping-option-description {
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-shipping-option-price { font-weight: 600; }
/* The single auto-selected option — a fixed line, not a choosable radio. */
.bbk-checkout-shipping-confirmed {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-accent);
border-radius: var(--bbk-radius-sm);
}
/* Autosave status line under the address form. */
.bbk-checkout-status {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
/* Continue / submit buttons — same look as the drawer's checkout CTA */
.bbk-checkout-continue {
display: block;
width: 100%;
padding: 0.875rem 1.25rem;
border: 1px solid var(--bbk-color-accent);
border-radius: var(--bbk-radius);
background: var(--bbk-color-accent);
color: var(--bbk-color-accent-text);
font: inherit;
font-weight: 600;
text-align: center;
text-decoration: none;
box-sizing: border-box;
cursor: pointer;
transition: opacity 0.15s ease;
}
.bbk-checkout-continue:hover { opacity: 0.85; }
.bbk-checkout-continue:disabled {
cursor: not-allowed;
opacity: 0.4;
}
/* Order summary column */
.bbk-checkout-aside { position: sticky; top: 1.5rem; }
.bbk-checkout-summary {
padding: 1.5rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius);
background: var(--bbk-color-bg);
}
.bbk-checkout-summary-heading {
margin: 0 0 1.25rem;
font-size: 1.125rem;
font-weight: 700;
}
/* Already on the checkout page — the drawer's own "go to checkout" CTA has
nowhere further to send you from here. */
.bbk-checkout-summary .bbk-cart-checkout { display: none; }
/* ── Payment ───────────────────────────────────────────────────────── */
.bbk-checkout-payment-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-checkout-payment-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
cursor: pointer;
transition: border-color 0.15s ease;
}
.bbk-checkout-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); }
.bbk-checkout-payment-option-name { font-weight: 600; }
.bbk-payment-element { margin: 0.25rem 0; }
.bbk-checkout-withdrawal {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-withdrawal a { color: inherit; }
.bbk-checkout-error {
margin: 0;
font-size: 0.875rem;
color: var(--bbk-color-danger);
}
/* Processing overlay — fixed, covers the page while a payment confirms. */
.bbk-checkout-processing {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent);
text-align: center;
padding: 1.5rem;
}
.bbk-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--bbk-color-border);
border-top-color: var(--bbk-color-accent);
border-radius: 50%;
animation: bbk-spin 0.8s linear infinite;
}
@keyframes bbk-spin {
to { transform: rotate(360deg); }
}
/* ── Confirmation page ─────────────────────────────────────────────── */
.bbk-confirmation {
max-width: 720px;
margin: 0 auto;
padding: 3rem 1.5rem 5rem;
font-family: var(--bbk-font);
color: var(--bbk-color-text);
}
.bbk-confirmation-heading {
margin: 0 0 1rem;
font-size: 1.75rem;
font-weight: 700;
}
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
.bbk-confirmation-meta {
margin: 0 0 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bbk-confirmation-meta-row {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.9375rem;
}
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
.bbk-confirmation-body {
margin: 2rem 0;
display: grid;
gap: 2.5rem;
}
@media (min-width: 640px) {
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
}
.bbk-confirmation-lines {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-confirmation-line {
display: grid;
grid-template-columns: 72px 1fr auto;
align-items: start;
gap: 0.875rem;
}
.bbk-confirmation-line-detail { min-width: 0; }
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
.bbk-confirmation-addresses {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.bbk-confirmation-address-heading {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
}
.bbk-address-lines {
font-style: normal;
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.875rem;
color: var(--bbk-color-muted);
}
@@ -0,0 +1,57 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Sits on an <x-checkout::add-to-cart> <form>. Submits the line to the cart
// via fetch and hands the server-rendered cart body to the drawer through the
// `bbk-cart:changed` window event. No DOM building here — the drawer
// (bbk-cart-controller) owns rendering.
export default class extends Controller {
static targets = ['error']
async add(event) {
event.preventDefault()
const form = this.element
const submit = form.querySelector('[type="submit"]')
this.clearError()
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
if (submit) submit.disabled = true
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (!response.ok) {
const data = await response.json().catch(() => null)
this.showError(data?.error)
return
}
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: await response.text() },
}))
} finally {
form.removeAttribute('data-bbk-add-to-cart-state')
if (submit) submit.disabled = false
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
}
@@ -0,0 +1,161 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Drives the slide-in cart drawer. One instance, on the drawer root in
// checkout/drawer.blade.php.
//
// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from
// this drawer's own line forms) and swaps in the server-rendered cart body
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
// (e.g. the header bag icon) can react
//
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
// attribute on the root, nothing here touches styles or class lists.
export default class extends Controller {
static targets = ['panel', 'body', 'error']
connect() {
this.onChanged = this.onChanged.bind(this)
this.onKeydown = this.onKeydown.bind(this)
this.updateTimers = new Map() // line id -> pending debounce timer
window.addEventListener('bbk-cart:changed', this.onChanged)
window.addEventListener('bbk-cart:open', this.open.bind(this))
document.addEventListener('keydown', this.onKeydown)
// Prime the host with the count rendered server-side on page load.
this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]'))
}
disconnect() {
window.removeEventListener('bbk-cart:changed', this.onChanged)
document.removeEventListener('keydown', this.onKeydown)
this.updateTimers.forEach((timer) => clearTimeout(timer))
}
onChanged(event) {
if (event.detail?.html) this.replaceBody(event.detail.html)
this.open()
}
onKeydown(event) {
if (event.key === 'Escape' && !this.element.hidden) this.close()
}
open() {
if (!this.element.hidden) return
this.element.hidden = false
// Next frame, so the panel transitions from its off-canvas start.
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
}
close() {
this.element.removeAttribute('data-bbk-cart-state')
const panel = this.panelTarget
const done = () => {
this.element.hidden = true
panel.removeEventListener('transitionend', done)
}
panel.addEventListener('transitionend', done)
}
// change on a line quantity input, or submit of a line's remove form
submit(event) {
event.preventDefault()
const form = event.target.closest('form')
if (!form) return
// A remove is a deliberate, one-shot action — only the quantity form
// (typing, or the +/- stepper below) benefits from debouncing.
form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form)
}
// +/- stepper buttons inside a line
step(event) {
event.preventDefault()
const form = event.target.closest('form')
const input = form.querySelector('input[type="number"]')
const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir))
input.value = String(next)
this.scheduleSend(form)
}
// Repeated clicks (or spinner nudges) update the input instantly but only
// send once they settle for 300ms — sending on every single click was
// firing overlapping requests that raced each other and made the drawer
// visibly flicker/lag under quick clicking.
scheduleSend(form) {
const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId
if (!lineId) return this.send(form)
clearTimeout(this.updateTimers.get(lineId))
this.updateTimers.set(lineId, setTimeout(() => {
this.updateTimers.delete(lineId)
this.send(form)
}, 300))
}
async send(form) {
this.bodyTarget.setAttribute('aria-busy', 'true')
this.clearError()
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (response.ok) {
this.replaceBody(await response.text())
return
}
const data = await response.json().catch(() => null)
this.showError(data?.error)
// The rejected quantity (typed, or from a +/- click) is left
// sitting in the input with nothing to correct it — the update
// never reached the cart, so the input must be put back to what
// the cart actually still holds, not just left showing whatever
// was rejected.
const input = form.querySelector('[data-bbk-cart-confirmed-quantity]')
if (input) input.value = input.dataset.bbkCartConfirmedQuantity
} finally {
this.bodyTarget.removeAttribute('aria-busy')
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
replaceBody(html) {
this.bodyTarget.innerHTML = html
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
}
emitUpdated(node) {
if (!node) return
window.dispatchEvent(new CustomEvent('bbk-cart:updated', {
detail: {
count: parseInt(node.dataset.bbkCartCount || '0', 10),
total: parseInt(node.dataset.bbkCartTotal || '0', 10),
},
}))
}
}
@@ -0,0 +1,204 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Drives the checkout page's left column: contact tabs, the same-as-billing
// toggle, and — the bulk of it — autosaving the address form and the shipping
// method with no submit buttons.
//
// Flow: any `change` in the address form is debounced ~400ms, then the whole
// form is POSTed to saveUrl. The server persists leniently and returns
// { errors, shippingOptionsHtml, summaryHtml }. We swap the shipping-options
// block in place and hand the summary fragment to the drawer's bbk-cart
// controller via the `bbk-cart:changed` window event (same mechanism the drawer
// already uses). Shipping-method radios post to selectShippingUrl the same way.
export default class extends Controller {
static targets = [
'sameAsBilling', 'shippingFields',
'form', 'shippingOptions', 'status',
]
static values = {
saveUrl: String,
selectShippingUrl: String,
statusSaving: String,
statusSaved: String,
statusError: String,
}
connect() {
this.saveTimer = null
this.saveController = null
this.statusTimer = null
this.shippingPromise = null
if (this.hasSameAsBillingTarget) this.applySameAsBilling()
}
disconnect() {
clearTimeout(this.saveTimer)
clearTimeout(this.statusTimer)
this.saveController?.abort()
}
// ── Same as billing ────────────────────────────────────────────────
toggleSameAsBilling() {
this.applySameAsBilling()
}
applySameAsBilling() {
const on = this.sameAsBillingTarget.checked
// Checked: shipping *is* billing — copy every value across, then hide +
// disable so the browser doesn't submit them; the server reuses billing.
// Unchecked: reveal them pre-filled from billing wherever still empty.
this.element.querySelectorAll('[name^="billing_"]').forEach((billingField) => {
const shippingField = this.element.querySelector(
`[name="${billingField.name.replace(/^billing_/, 'shipping_')}"]`,
)
if (shippingField && (on || !shippingField.value)) {
shippingField.value = billingField.value
}
})
this.shippingFieldsTarget.hidden = on
this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => {
field.disabled = on
})
}
// ── Autosave ───────────────────────────────────────────────────────
scheduleSave(event) {
// The shipping-method and payment radios live inside this controller's
// element too, and this action is bound on .bbk-checkout-main to also
// catch the contact email/consent that sit outside the <form>. Only
// react to fields that actually belong to the address form.
const el = event.target
const belongsToForm = el.form?.id === 'bbk-address-form'
if (!belongsToForm) return
// No status during the wait — it only shows once the request is in flight,
// so the indicator isn't flickering "saving" on every keystroke.
clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(() => this.save(), 700)
}
// Called by bbk-payment right before place-order — a debounced save (and
// the shipping-option auto-select that happens as part of it) might still
// be pending when the shopper clicks "place order"; this guarantees the
// server has processed the current form state first.
async flush() {
clearTimeout(this.saveTimer)
await this.save()
// A shipping-method radio click fires its own (undebounced) request —
// still async, still racy against an immediate "place order" click.
if (this.shippingPromise) await this.shippingPromise
}
async save() {
this.saveController?.abort()
this.saveController = new AbortController()
this.setStatus('saving')
try {
const response = await fetch(this.saveUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(this.formTarget),
signal: this.saveController.signal,
})
if (!response.ok) return this.setStatus('error')
this.applyResult(await response.json())
this.setStatus('saved')
} catch (error) {
if (error.name !== 'AbortError') this.setStatus('error')
}
}
async selectShipping(event) {
// Tracked so flush() can await it — nothing else stops "place order"
// (a separate, unrelated click) from racing ahead of this request.
this.shippingPromise = this.doSelectShipping(event.target.value)
await this.shippingPromise
}
async doSelectShipping(value) {
this.saveController?.abort()
this.setStatus('saving')
const body = new FormData()
body.append('shipping_option', value)
try {
const response = await fetch(this.selectShippingUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body,
})
if (!response.ok) return this.setStatus('error')
this.applyResult(await response.json())
this.setStatus('saved')
} catch {
this.setStatus('error')
} finally {
this.shippingPromise = null
}
}
applyResult(data) {
this.applyErrors(data.errors || {})
if (data.shippingOptionsHtml != null) {
this.shippingOptionsTarget.innerHTML = data.shippingOptionsHtml
}
if (data.summaryHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: data.summaryHtml },
}))
}
}
applyErrors(errors) {
this.element.querySelectorAll('[data-bbk-field-error]').forEach((el) => {
const message = errors[el.dataset.bbkFieldError]
el.textContent = message || ''
el.hidden = !message
const field = this.element.querySelector(`[name="${el.dataset.bbkFieldError}"]`)
field?.classList.toggle('bbk-field-input--error', Boolean(message))
})
}
setStatus(state) {
if (!this.hasStatusTarget) return
const text = {
saving: this.statusSavingValue,
saved: this.statusSavedValue,
error: this.statusErrorValue,
}[state]
this.statusTarget.textContent = text
this.statusTarget.hidden = false
this.statusTarget.dataset.state = state
clearTimeout(this.statusTimer)
if (state === 'saved') {
this.statusTimer = setTimeout(() => { this.statusTarget.hidden = true }, 2000)
}
}
}
@@ -0,0 +1,289 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
const STRIPE_JS = 'https://js.stripe.com/v3/'
const POLL_INTERVAL = 1500
const POLL_TIMEOUT = 30000
// The payment step of the checkout page. Sits alongside bbk-checkout-form on
// .bbk-checkout-main.
//
// - selectMethod: radio change -> persist via /payment-method, refresh the
// summary (COD fee), mount/unmount the Stripe Payment Element
// - placeOrder: the real submit. For Stripe, builds a PaymentMethod client-side
// and POSTs it to /place-order, then routes on the JSON result:
// { redirect } -> order placed, go to confirmation
// { status:'pending', clientSecret } -> 3-D Secure: handleNextAction, then
// poll /order-status until the webhook places it
// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable
export default class extends Controller {
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText']
static values = {
selectUrl: String,
placeOrderUrl: String,
orderStatusUrl: String,
stripeKey: String,
amount: Number,
currency: String,
termsRequired: String,
chooseMethod: String,
genericError: String,
processingSlow: String,
}
connect() {
this.stripe = null
this.elements = null
this.paymentElement = null
this.onSummaryUpdate = (event) => {
const total = event.detail?.total
if (typeof total === 'number' && this.elements) {
this.amountValue = total
this.elements.update({ amount: Math.max(total, 1) })
}
// Removing the last line while sitting on the checkout page (via
// the order summary's own remove form) must not leave "place
// order" clickable with nothing left to charge for — this fires
// from both the drawer and the checkout page's own summary
// instance, whichever the shopper actually used.
const count = event.detail?.count
if (typeof count === 'number' && this.hasSubmitTarget) {
this.submitTarget.disabled = count === 0
}
}
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)
if (this.selectedIsStripe()) this.mountStripe()
}
disconnect() {
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
this.unmountStripe()
}
// ── Method selection ──────────────────────────────────────────────
async selectMethod(event) {
const isStripe = event.target.dataset.paymentDriver === 'stripe'
try {
const response = await fetch(this.selectUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new URLSearchParams({ payment_type: event.target.value }),
})
if (response.ok) {
const data = await response.json()
if (data.summaryHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-cart:changed', { detail: { html: data.summaryHtml } }))
}
}
} catch {
// summary just won't refresh — non-fatal
}
isStripe ? this.mountStripe() : this.unmountStripe()
}
selectedRadio() {
return this.element.querySelector('input[name="payment_type"]:checked')
}
selectedIsStripe() {
return this.selectedRadio()?.dataset.paymentDriver === 'stripe'
}
// ── Stripe Payment Element ────────────────────────────────────────
async loadStripe() {
if (window.Stripe) return window.Stripe
await new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${STRIPE_JS}"]`)
if (existing) {
existing.addEventListener('load', resolve)
existing.addEventListener('error', reject)
return
}
const script = document.createElement('script')
script.src = STRIPE_JS
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
return window.Stripe
}
async mountStripe() {
if (this.paymentElement || !this.stripeKeyValue) return
const Stripe = await this.loadStripe()
this.stripe = this.stripe || Stripe(this.stripeKeyValue)
this.elements = this.stripe.elements({
mode: 'payment',
amount: Math.max(this.amountValue, 1),
currency: this.currencyValue,
paymentMethodCreation: 'manual',
// Card only — matches the server confirming with
// automatic_payment_methods.allow_redirects = 'never' (no
// return_url in our flow: 3-D Secure resolves in-page via
// handleNextAction, never a full-page redirect).
paymentMethodTypes: ['card'],
})
this.paymentElement = this.elements.create('payment')
this.paymentElement.mount(this.elementTarget)
this.elementTarget.hidden = false
}
unmountStripe() {
this.paymentElement?.unmount()
this.paymentElement = null
this.elements = null
if (this.hasElementTarget) {
this.elementTarget.innerHTML = ''
this.elementTarget.hidden = true
}
}
// ── Place order ──────────────────────────────────────────────────
// Sibling controller on the same element (.bbk-checkout-main) — used to
// flush a pending debounced address autosave before placing the order.
get checkoutForm() {
return this.application.getControllerForElementAndIdentifier(this.element, 'bbk-checkout-form')
}
async placeOrder() {
this.clearError()
this.submitTarget.disabled = true
// A debounced address save (and the shipping-option auto-select that
// happens as part of it) might still be pending — make sure the
// server has the latest state before we ask it to place the order.
await this.checkoutForm?.flush()
if (!this.termsTarget.checked) {
this.submitTarget.disabled = false
this.showError(this.termsRequiredValue)
return
}
const radio = this.selectedRadio()
if (!radio) {
this.submitTarget.disabled = false
this.showError(this.chooseMethodValue)
return
}
let paymentMethodId = null
if (radio.dataset.paymentDriver === 'stripe') {
const { error: submitError } = await this.elements.submit()
if (submitError) return this.fail(submitError.message)
const { error: pmError, paymentMethod } = await this.stripe.createPaymentMethod({ elements: this.elements })
if (pmError) return this.fail(pmError.message)
paymentMethodId = paymentMethod.id
}
let data
try {
const response = await fetch(this.placeOrderUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new URLSearchParams({
payment_type: radio.value,
payment_method: paymentMethodId ?? '',
terms_accepted: '1',
}),
})
data = await response.json()
} catch {
return this.fail(this.genericErrorValue)
}
if (data.redirect) {
window.location.assign(data.redirect)
return
}
if (data.status === 'pending' && data.clientSecret) {
await this.resolvePending(data.clientSecret)
return
}
// Points at the section that actually needs attention, rather than
// leaving a generic error and making the shopper hunt for it — e.g. a
// region with 2+ shipping methods needs an explicit pick, easy to miss.
if (data.field === 'shipping_option') {
document.getElementById('bbk-shipping-options')?.scrollIntoView({ block: 'center', behavior: 'smooth' })
this.fail(data.message || data.error || this.genericErrorValue, { scroll: false })
return
}
this.fail(data.message || data.error || this.genericErrorValue)
}
async resolvePending(clientSecret) {
this.processingTarget.hidden = false
const { error } = await this.stripe.handleNextAction({ clientSecret })
if (error) {
this.processingTarget.hidden = true
return this.fail(error.message)
}
// 3-D Secure cleared client-side — the webhook places the order. Poll.
const startedAt = Date.now()
const tick = async () => {
try {
const response = await fetch(this.orderStatusUrlValue, { headers: { Accept: 'application/json' } })
const data = await response.json()
if (data.placed && data.redirect) {
window.location.assign(data.redirect)
return
}
} catch {
// keep polling
}
if (Date.now() - startedAt > POLL_TIMEOUT) {
this.processingTextTarget.textContent = this.processingSlowValue
return
}
setTimeout(tick, POLL_INTERVAL)
}
tick()
}
// ── helpers ──────────────────────────────────────────────────────
fail(message, { scroll = true } = {}) {
this.showError(message, { scroll })
this.submitTarget.disabled = false
}
showError(message, { scroll = true } = {}) {
this.errorTarget.textContent = message
this.errorTarget.hidden = false
if (scroll) this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
clearError() {
this.errorTarget.textContent = ''
this.errorTarget.hidden = true
}
}
+6
View File
@@ -0,0 +1,6 @@
// Reads the CSRF token from the standard <meta name="csrf-token"> tag every
// boboko host renders in its layout <head>. Kept as its own module so both
// checkout controllers share one source.
export function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
+19
View File
@@ -0,0 +1,19 @@
import BbkAddToCartController from './bbk-add-to-cart-controller'
import BbkCartController from './bbk-cart-controller'
import BbkCheckoutFormController from './bbk-checkout-form-controller'
import BbkPaymentController from './bbk-payment-controller'
// Registers the checkout module's Stimulus controllers onto the host app's
// Stimulus application. Call once from the host's JS entry point:
//
// import { registerCheckout } from './checkout'
// registerCheckout(application)
//
// When this module moves to boboko-core this file ships with it unchanged;
// only that one import line in the host entry point differs per project.
export function registerCheckout(application) {
application.register('bbk-add-to-cart', BbkAddToCartController)
application.register('bbk-cart', BbkCartController)
application.register('bbk-checkout-form', BbkCheckoutFormController)
application.register('bbk-payment', BbkPaymentController)
}
@@ -0,0 +1,17 @@
@extends('emails.layout')
@section('content')
<p style="margin: 0 0 24px 0;">Use the code below to confirm this address as your account's new email.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0; background-color: #f7f6f5; border-radius: 8px;">
<tr>
<td style="padding: 16px 20px; text-align: center; font-size: 28px; font-weight: bold; letter-spacing: 0.25rem;">
{{ $code }}
</td>
</tr>
</table>
<p style="margin: 0 0 16px 0;">This code expires in 10 minutes.</p>
<p style="margin: 0;">If you didn't request this change, you can ignore this email — nothing will change.</p>
@endsection
@@ -0,0 +1,9 @@
@extends('emails.layout')
@section('content')
<p style="margin: 0 0 16px 0;">Your account's login email was changed to <strong>{{ $maskedEmail }}</strong>.</p>
<p style="margin: 0 0 24px 0;">From now on, login codes will be sent to the new address.</p>
<p style="margin: 0;">If you didn't make this change, please contact us right away.</p>
@endsection
+1 -1
View File
@@ -1,5 +1,5 @@
<p>Hi {{ $name }},</p> <p>Hi {{ $name }},</p>
<p>Your login code is:</p> <p>{{ $intro }}</p>
<p style="font-size: 2rem; font-weight: bold; letter-spacing: 0.25rem;">{{ $code }}</p> <p style="font-size: 2rem; font-weight: bold; letter-spacing: 0.25rem;">{{ $code }}</p>
@@ -0,0 +1,44 @@
{{--
<x-checkout::add-to-cart :purchasable="$variantId" />
A self-contained add-to-cart form. Posts the line via bbk-add-to-cart-controller
(fetch) and hands the rendered cart body to the drawer over the
`bbk-cart:changed` window event.
Props:
purchasable ProductVariant id. Omit to render no hidden id field — the host
must then supply [data-bbk-purchasable-input] itself (e.g. a
variant picker writing the selected id into it).
quantity Integer for the hidden quantity field, or false to omit it
(the host then puts its own name="quantity" control in the slot).
The button and any quantity control come from the slot, so the host owns all
appearance. Extra attributes (class, etc.) land on the <form>.
--}}
@props([
'purchasable' => null,
'quantity' => 1,
'action' => null,
])
<form
method="POST"
action="{{ $action ?? route('checkout.cart.add', app()->getLocale()) }}"
data-controller="bbk-add-to-cart"
data-action="bbk-add-to-cart#add"
{{ $attributes->class('bbk-add-to-cart') }}
>
@csrf
@if (! is_null($purchasable))
<input type="hidden" name="purchasable_id" value="{{ $purchasable }}" data-bbk-purchasable-input>
@endif
@if ($quantity !== false)
<input type="hidden" name="quantity" value="{{ $quantity }}">
@endif
{{ $slot }}
<p class="bbk-add-to-cart-error" data-bbk-add-to-cart-target="error" hidden role="alert"></p>
</form>
@@ -0,0 +1,15 @@
{{--
Read-only formatted address. $address is any Lunar address model
(OrderAddress / CartAddress) — same column names on both.
--}}
@props(['address'])
<address class="bbk-address-lines">
<span>{{ trim(($address->first_name ?? '') . ' ' . ($address->last_name ?? '')) }}</span>
@if ($address->company_name)<span>{{ $address->company_name }}</span>@endif
<span>{{ $address->line_one }}</span>
@if ($address->line_two)<span>{{ $address->line_two }}</span>@endif
<span>{{ trim(($address->postcode ?? '') . ' ' . ($address->city ?? '')) }}</span>
@if ($address->state)<span>{{ $address->state }}</span>@endif
@if ($address->contact_phone)<span>{{ $address->contact_phone }}</span>@endif
</address>
@@ -0,0 +1,29 @@
{{--
<x-checkout::field name="billing_first_name" label="First name" required />
Generic labelled text input with old-input repopulation and validation
error display — the module's own equivalent of a host x-ui.field, used
instead of it per the module's independence rule. All styling is .bbk-field*
(resources/css/checkout.css); no host classes.
--}}
@props([
'name',
'label',
'type' => 'text',
'value' => null,
'required' => false,
])
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<input
type="{{ $type }}"
name="{{ $name }}"
id="bbk-{{ $name }}"
value="{{ old($name, $value) }}"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>
{{-- Always present so bbk-checkout-form can fill it live on an autosave. --}}
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -0,0 +1,52 @@
{{--
The state/region + country pair for one address (billing or shipping).
Single-country store ($storeCountry set): region is a <select> of that
country's Lunar states, submitting `->name` (table-rate-shipping resolves
zones with State::whereName()), and country is a fixed hidden field + label.
Otherwise: free-text region + full country <select>, as before.
--}}
@props([
'prefix',
'storeCountry' => null,
'regions' => [],
'countries' => [],
'address' => null,
])
<div class="bbk-field-row">
@if ($storeCountry)
<x-checkout::select
:name="$prefix . '_state'"
label="{{ __('checkout.page.state') }}"
:options="$regions"
value-field="name"
translation-group="states"
:value="$address?->state"
placeholder="{{ __('checkout.page.state_placeholder') }}"
required
/>
<div class="bbk-field">
<span class="bbk-field-label">{{ __('checkout.page.country') }}</span>
<p class="bbk-field-static">
{{ \Illuminate\Support\Facades\Lang::has("core::countries.{$storeCountry->name}")
? __("core::countries.{$storeCountry->name}")
: $storeCountry->name }}
</p>
<input type="hidden" name="{{ $prefix }}_country_id" value="{{ $storeCountry->id }}">
</div>
@else
<x-checkout::field :name="$prefix . '_state'" label="{{ __('checkout.page.state') }}" :value="$address?->state" />
<x-checkout::select
:name="$prefix . '_country_id'"
label="{{ __('checkout.page.country') }}"
:options="$countries"
translation-group="countries"
:value="$address?->country_id"
placeholder="{{ __('checkout.page.country_placeholder') }}"
required
/>
@endif
</div>
@@ -0,0 +1,62 @@
{{--
<x-checkout::select name="billing_country_id" label="Country" :options="$countries" required />
<x-checkout::select name="shipping_state" label="Region" :options="$regions" value-field="name" translation-group="states" required />
`options` is an iterable of models/objects; `label` is always read from
`->name`, the submitted value from `->{$valueField}` (default `id`, but e.g.
`name` for Lunar states — table-rate-shipping resolves those with
State::whereName(), so the address must carry the exact name string).
`translationGroup` (optional, e.g. "countries"/"states") looks the raw
`->name` up in boboko-core's `core::{group}.{name}` lang file (see
boboko-core's lang/el/countries.php, lang/el/states.php) for the
DISPLAYED label only — the submitted `value` is always the untranslated
`->{$valueField}`, since table-rate-shipping/Lunar's Country lookups key
off the original English name. Falls back to the raw name when no
translation exists for the current locale (e.g. English, or a country
outside the covered set).
--}}
@props([
'name',
'label',
'options' => [],
'value' => null,
'placeholder' => null,
'required' => false,
'valueField' => 'id',
'translationGroup' => null,
])
@php
$optionLabel = function ($option) use ($translationGroup) {
if (! $translationGroup) {
return $option->name;
}
$key = "core::{$translationGroup}.{$option->name}";
return \Illuminate\Support\Facades\Lang::has($key) ? __($key) : $option->name;
};
@endphp
@php($selected = old($name, $value))
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<select
name="{{ $name }}"
id="bbk-{{ $name }}"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>
@if ($placeholder)
<option value="" @selected(! $selected)>{{ $placeholder }}</option>
@endif
@foreach ($options as $option)
<option value="{{ $option->{$valueField} }}" @selected((string) $selected === (string) $option->{$valueField})>
{{ $optionLabel($option) }}
</option>
@endforeach
</select>
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -0,0 +1,18 @@
@props([
'name',
'label',
'value' => null,
'required' => false,
])
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<textarea
name="{{ $name }}"
id="bbk-{{ $name }}"
rows="3"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>{{ old($name, $value) }}</textarea>
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -0,0 +1,132 @@
{{--
Order confirmation. Reached only via a session flash of the placed order id
(CheckoutController::confirmation) — not deep-linkable. $order is a
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
--}}
@extends('layouts.app')
@section('title', __('checkout.page.confirmation_title') . ' — ' . config('app.name'))
@section('content')
<div class="bbk-confirmation">
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
<dl class="bbk-confirmation-meta">
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
<dd>{{ $order->reference }}</dd>
</div>
@if ($order->billingAddress?->contact_email)
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.email_label') }}</dt>
<dd>{{ $order->billingAddress->contact_email }}</dd>
</div>
@endif
@if ($paymentMethodName)
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.payment_heading') }}</dt>
<dd>{{ $paymentMethodName }}</dd>
</div>
@endif
@if ($shippingLine = $order->lines->firstWhere('type', 'shipping'))
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.shipping_method_heading') }}</dt>
<dd>{{ $shippingLine->description }}</dd>
</div>
@endif
</dl>
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
{{-- Guests: logging in with the order's email attaches it to an account
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
so it shows in their history. --}}
@guest
@if ($loginRoute = config('checkout.login_route'))
<p class="bbk-checkout-note">
{{ __('checkout.page.confirmation_login_hint') }}
<a href="{{ route($loginRoute) }}">{{ __('checkout.page.login_link') }}</a>
</p>
@endif
@endguest
<div class="bbk-confirmation-body">
<div class="bbk-confirmation-lines">
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
<div class="bbk-confirmation-line">
<div class="bbk-cart-item-media">
@if ($thumb = $line->purchasable?->getThumbnailImage())
<img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy">
@endif
</div>
<div class="bbk-confirmation-line-detail">
<span class="bbk-confirmation-line-name">
{{ $line->description }}
<span class="bbk-confirmation-line-qty">&times; {{ $line->quantity }}</span>
</span>
@if ($line->option)
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
@endif
@include('checkout::partials.line-custom-fields', ['line' => $line])
</div>
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
</div>
@endforeach
<div class="bbk-cart-summary">
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $order->sub_total?->formatted() }}</span>
</div>
@if ($order->discount_total?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $order->discount_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $order->shipping_total?->formatted() }}</span>
</div>
@if ($order->tax_total?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $order->tax_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $order->total?->formatted() }}</span>
</div>
</div>
</div>
<div class="bbk-confirmation-addresses">
@if ($order->shippingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<x-checkout::address-lines :address="$order->shippingAddress" />
</div>
@endif
@if ($order->billingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<x-checkout::address-lines :address="$order->billingAddress" />
</div>
@endif
</div>
</div>
</div>
@endsection
+33
View File
@@ -0,0 +1,33 @@
{{--
Slide-in cart drawer. Rendered once, globally, from the app layout
(@include('checkout::drawer')). Structure only — all styling lives in
resources/css/checkout.css under @layer bbk-checkout; the host restyles the
.bbk-* classes from its own stylesheet. No host components, no Tailwind.
--}}
<div class="bbk-cart" data-controller="bbk-cart" hidden>
<div class="bbk-cart-backdrop" data-action="click->bbk-cart#close"></div>
<aside
class="bbk-cart-panel"
role="dialog"
aria-modal="true"
aria-labelledby="bbk-cart-heading"
data-bbk-cart-target="panel"
>
<header class="bbk-cart-panel-header">
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
<button
type="button"
class="bbk-cart-dismiss"
data-action="bbk-cart#close"
aria-label="{{ __('checkout.cart.close') }}"
>&times;</button>
</header>
@include('checkout::partials.cart-error')
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
</aside>
</div>
+279
View File
@@ -0,0 +1,279 @@
{{--
The checkout page. Two columns: left is contact + billing + shipping +
shipping method, right is the order summary (the same cart-body partial the
drawer uses, minus its own "Checkout" CTA — see .bbk-checkout-summary in
checkout.css). Stops short of payment for this slice — see
CheckoutController's class docblock.
$cart, $lines, $billingAddress, $shippingAddress, $shippingOptions,
$countries come from CheckoutController::show().
--}}
@extends('layouts.app')
@section('title', __('checkout.page.title') . ' — ' . config('app.name'))
@section('content')
<div class="bbk-checkout-page">
<h1 class="bbk-checkout-heading">{{ __('checkout.page.title') }}</h1>
<div class="bbk-checkout">
<div
class="bbk-checkout-main"
data-controller="bbk-checkout-form bbk-payment"
data-action="input->bbk-checkout-form#scheduleSave"
data-bbk-checkout-form-save-url-value="{{ route('checkout.address.save', app()->getLocale()) }}"
data-bbk-checkout-form-select-shipping-url-value="{{ route('checkout.shipping-option.select', app()->getLocale()) }}"
data-bbk-checkout-form-status-saving-value="{{ __('checkout.page.saving') }}"
data-bbk-checkout-form-status-saved-value="{{ __('checkout.page.saved') }}"
data-bbk-checkout-form-status-error-value="{{ __('checkout.page.save_error') }}"
data-bbk-payment-select-url-value="{{ route('checkout.payment-method.select', app()->getLocale()) }}"
data-bbk-payment-place-order-url-value="{{ route('checkout.place-order', app()->getLocale()) }}"
data-bbk-payment-order-status-url-value="{{ route('checkout.order-status', app()->getLocale()) }}"
data-bbk-payment-stripe-key-value="{{ config('services.stripe.public_key') }}"
data-bbk-payment-amount-value="{{ $cart?->total?->value ?? 0 }}"
data-bbk-payment-currency-value="{{ strtolower($cart?->total?->currency?->code ?? 'eur') }}"
data-bbk-payment-terms-required-value="{{ __('checkout.page.terms_required') }}"
data-bbk-payment-choose-method-value="{{ __('checkout.page.choose_payment_method') }}"
data-bbk-payment-generic-error-value="{{ __('checkout.page.payment_failed') }}"
data-bbk-payment-processing-slow-value="{{ __('checkout.page.payment_processing_slow') }}"
>
{{-- Contact. Logged in: the order email is the account's (forced
server-side in saveAddress()), so there's no field. Guests type
their email, plus a login link when config('checkout.login_route')
is set; the storefront's login page sends them back here and Lunar
merges the guest cart into the account. --}}
@php($loginRoute = config('checkout.login_route'))
<section class="bbk-checkout-section">
@auth
<p class="bbk-checkout-logged-in">
{{ __('checkout.page.logged_in_as') }} <strong>{{ auth()->user()->email }}</strong>
</p>
@else
@if ($loginRoute)
<p class="bbk-checkout-login-prompt">
{{ __('checkout.page.login_prompt') }}
<a href="{{ route($loginRoute, ['redirect' => route('checkout.show', app()->getLocale(), false)]) }}">{{ __('checkout.page.login_link') }}</a>
</p>
@endif
<x-checkout::field
name="contact_email"
label="{{ __('checkout.page.email_label') }}"
type="email"
:value="$shippingAddress?->contact_email ?? $billingAddress?->contact_email"
required
form="bbk-address-form"
/>
@endauth
{{-- Abandoned-cart-recovery opt-in. Optional, unticked, never
required — direct marketing under ePrivacy (GR L. 3471/2006
art. 11), so it needs an explicit opt-in and checkout can't be
gated on it. Narrow scope by design (boboko-core's
setRecoveryConsent) — a general newsletter opt-in, if wanted,
is a separate checkbox. --}}
<label class="bbk-checkbox bbk-checkbox--stacked">
<input
type="checkbox"
name="recovery_consent"
value="1"
form="bbk-address-form"
{{ old('recovery_consent', data_get($cart, 'meta.recovery_consent')) ? 'checked' : '' }}
>
{{ __('checkout.page.recovery_consent') }}
</label>
</section>
{{-- Autosaves — no submit button. Any `change` inside .bbk-checkout-main
(this form, plus the contact email/consent which sit outside it but
link via form="bbk-address-form") is debounced and POSTed as the whole
form; the shipping-method radios are excluded in scheduleSave(). --}}
<form
id="bbk-address-form"
method="POST"
action="{{ route('checkout.address.save', app()->getLocale()) }}"
data-bbk-checkout-form-target="form"
>
@csrf
{{-- Billing --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.billing_heading') }}</h2>
<div class="bbk-field-row">
<x-checkout::field name="billing_first_name" label="{{ __('checkout.page.first_name') }}" :value="$billingAddress?->first_name" required />
<x-checkout::field name="billing_last_name" label="{{ __('checkout.page.last_name') }}" :value="$billingAddress?->last_name" required />
</div>
{{-- Company/ΑΦΜ only when an invoice is wanted. Revealed by CSS
(:has on the checkbox), saved/cleared by saveAddress(), and
required at place-order. --}}
<div class="bbk-invoice">
<label class="bbk-checkbox">
<input
type="checkbox"
name="wants_invoice"
value="1"
aria-controls="bbk-invoice-fields"
@checked($wantsInvoice)
>
{{ __('checkout.page.wants_invoice') }}
</label>
<div class="bbk-field-row bbk-invoice-fields" id="bbk-invoice-fields">
<x-checkout::field name="billing_company_name" label="{{ __('checkout.page.company_name') }}" :value="$billingAddress?->company_name" />
<x-checkout::field name="billing_tax_identifier" label="{{ __('checkout.page.tax_identifier') }}" :value="$billingAddress?->tax_identifier" />
</div>
</div>
<x-checkout::field name="billing_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$billingAddress?->line_one" required />
<div class="bbk-field-row">
<x-checkout::field name="billing_city" label="{{ __('checkout.page.city') }}" :value="$billingAddress?->city" required />
<x-checkout::field name="billing_postcode" label="{{ __('checkout.page.postcode') }}" :value="$billingAddress?->postcode" required />
</div>
<x-checkout::region-country
prefix="billing"
:store-country="$storeCountry"
:regions="$regions"
:countries="$countries"
:address="$billingAddress"
/>
<x-checkout::field name="billing_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$billingAddress?->contact_phone" />
</section>
{{-- Shipping --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_heading') }}</h2>
<label class="bbk-checkbox">
<input
type="checkbox"
name="same_as_billing"
value="1"
data-bbk-checkout-form-target="sameAsBilling"
data-action="bbk-checkout-form#toggleSameAsBilling"
@checked($shipToBilling)
>
{{ __('checkout.page.same_as_billing') }}
</label>
<div class="bbk-checkout-shipping-fields" data-bbk-checkout-form-target="shippingFields">
<div class="bbk-field-row">
<x-checkout::field name="shipping_first_name" label="{{ __('checkout.page.first_name') }}" :value="$shippingAddress?->first_name" required />
<x-checkout::field name="shipping_last_name" label="{{ __('checkout.page.last_name') }}" :value="$shippingAddress?->last_name" required />
</div>
<x-checkout::field name="shipping_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$shippingAddress?->line_one" required />
<div class="bbk-field-row">
<x-checkout::field name="shipping_city" label="{{ __('checkout.page.city') }}" :value="$shippingAddress?->city" required />
<x-checkout::field name="shipping_postcode" label="{{ __('checkout.page.postcode') }}" :value="$shippingAddress?->postcode" required />
</div>
<x-checkout::region-country
prefix="shipping"
:store-country="$storeCountry"
:regions="$regions"
:countries="$countries"
:address="$shippingAddress"
/>
<x-checkout::field name="shipping_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$shippingAddress?->contact_phone" />
</div>
<x-checkout::textarea
name="shipping_delivery_instructions"
label="{{ __('checkout.page.delivery_instructions') }}"
:value="$shippingAddress?->delivery_instructions"
/>
</section>
</form>
<p
class="bbk-checkout-status"
data-bbk-checkout-form-target="status"
role="status"
aria-live="polite"
hidden
></p>
{{-- Shipping method — resolves from the saved shipping address;
re-rendered as a fragment by bbk-checkout-form after each
autosave / option change. --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2>
<div id="bbk-shipping-options" data-bbk-checkout-form-target="shippingOptions">
@include('checkout::partials.shipping-options', [
'shippingAddress' => $shippingAddress,
'shippingOptions' => $shippingOptions,
])
</div>
</section>
{{-- Payment --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
<div id="bbk-payment-methods">
@include('checkout::partials.payment-methods', [
'paymentMethods' => $paymentMethods,
'cart' => $cart,
])
</div>
{{-- Stripe Payment Element mounts here when a Stripe method is picked. --}}
<div class="bbk-payment-element" data-bbk-payment-target="element" hidden></div>
<label class="bbk-checkbox bbk-checkbox--stacked">
<input type="checkbox" data-bbk-payment-target="terms">
{!! __('checkout.page.terms_accept', [
'terms' => route('legal.terms', app()->getLocale()),
'privacy' => route('legal.privacy', app()->getLocale()),
]) !!}
</label>
<p class="bbk-checkout-withdrawal">
{!! __('checkout.page.withdrawal_notice', [
'link' => route('legal.shipping-returns', app()->getLocale()),
]) !!}
</p>
<p class="bbk-checkout-error" data-bbk-payment-target="error" role="alert" hidden></p>
<button
type="button"
class="bbk-checkout-continue"
data-bbk-payment-target="submit"
data-action="bbk-payment#placeOrder"
@disabled($lines->isEmpty())
>
{{ __('checkout.page.place_order') }}
</button>
</section>
{{-- Fixed overlay while a payment is confirming (3-D Secure / webhook
poll). Inside .bbk-checkout-main so bbk-payment can target it. --}}
<div class="bbk-checkout-processing" data-bbk-payment-target="processing" hidden>
<span class="bbk-spinner" aria-hidden="true"></span>
<p data-bbk-payment-target="processingText">{{ __('checkout.page.payment_processing') }}</p>
</div>
</div>
<aside class="bbk-checkout-aside">
<div class="bbk-checkout-summary" data-controller="bbk-cart">
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
@include('checkout::partials.cart-error')
<div data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
</div>
</aside>
</div>
</div>
@endsection
@@ -0,0 +1,121 @@
{{--
Server-rendered cart contents. Rendered inline on first page load inside
checkout/drawer.blade.php, and re-fetched + swapped into the drawer by
bbk-cart-controller after every mutation. $cart / $lines come from the view
composer in CheckoutModuleServiceProvider.
The data-bbk-cart-* attributes on the root are the module's read API for the
host (e.g. the header bag-icon count) — bbk-cart-controller reads them after
each swap and re-emits them on the `bbk-cart:updated` window event.
--}}
@php($count = $lines->sum('quantity'))
{{-- @dump($lines) --}}
<div
class="bbk-cart-content"
data-bbk-cart-count="{{ $count }}"
data-bbk-cart-total="{{ $cart?->total?->value ?? 0 }}"
>
@if ($lines->isEmpty())
<p class="bbk-cart-empty">{{ __('checkout.cart.empty') }}</p>
@else
<ul class="bbk-cart-items">
@each('checkout::partials.cart-line', $lines, 'line')
</ul>
<div class="bbk-cart-summary">
<div class="bbk-cart-coupon">
@if ($cart?->coupon_code)
<div class="bbk-cart-coupon-applied">
<span class="bbk-cart-coupon-code">{{ $cart->coupon_code }}</span>
<form
method="POST"
action="{{ route('checkout.cart.coupon.remove', app()->getLocale()) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button type="submit" class="bbk-cart-coupon-remove">
{{ __('checkout.cart.coupon_remove') }}
</button>
</form>
</div>
@else
<form
class="bbk-cart-coupon-form"
method="POST"
action="{{ route('checkout.cart.coupon.apply', app()->getLocale()) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
<label class="bbk-visually-hidden" for="bbk-coupon-code">
{{ __('checkout.cart.coupon_label') }}
</label>
<input
type="text"
name="code"
id="bbk-coupon-code"
class="bbk-cart-coupon-input"
placeholder="{{ __('checkout.cart.coupon_placeholder') }}"
autocomplete="off"
required
>
<button type="submit" class="bbk-cart-coupon-submit">
{{ __('checkout.cart.coupon_apply') }}
</button>
</form>
@if ($couponError ?? false)
<p class="bbk-cart-coupon-error" role="alert">{{ __('checkout.cart.coupon_invalid') }}</p>
@endif
@endif
</div>
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $cart?->subTotal?->formatted() }}</span>
</div>
@if ($cart?->discountTotal?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $cart->discountTotal->formatted() }}</span>
</div>
@endif
{{-- Shipping + tax appear once the shopper has a shipping address
(i.e. they're on the checkout page). In the drawer, where no
address is set yet, only subtotal + total show. --}}
@if ($cart?->shippingAddress)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
@if ($cart->shippingAddress->shipping_option)
<span>{{ $cart->shippingTotal?->formatted() }}</span>
@else
<span class="bbk-cart-summary-pending">{{ __('checkout.cart.shipping_pending') }}</span>
@endif
</div>
@endif
@if ($cart?->taxTotal?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $cart->taxTotal->formatted() }}</span>
</div>
@endif
{{-- Always shown — equals subtotal with nothing else applied,
diverges as discount / shipping / tax come in. --}}
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $cart?->total?->formatted() }}</span>
</div>
<a class="bbk-cart-checkout" href="{{ route('checkout.show', app()->getLocale()) }}">
{{ __('checkout.cart.checkout') }}
</a>
</div>
@endif
</div>
@@ -0,0 +1,9 @@
{{--
Shared error slot for any host wrapping cart-body in a bbk-cart controller
instance (the drawer, and the checkout page's own order summary) —
bbk-cart-controller.js#showError() writes into whichever one is present.
Without this element in a given host, a rejected quantity update (e.g.
over stock) still gets rejected server-side, but the shopper never sees
why.
--}}
<p class="bbk-cart-error" data-bbk-cart-target="error" hidden role="alert"></p>
@@ -0,0 +1,103 @@
{{--
One cart line. $line is a Lunar\Models\CartLine (iteration var set by
@each in cart-body). The two forms post through bbk-cart-controller
(fetch + method spoofing) and the response re-renders cart-body.
--}}
@php
$variant = $line->purchasable;
$product = $variant?->product;
$name = $product?->translateAttribute('name') ?? $variant?->sku ?? '—';
// The variant's own image (falls back to the product's thumbnail
// internally — see ProductVariant::getThumbnail()) — the specific option
// the shopper picked, not just the product in general.
$thumb = $variant?->getThumbnailImage() ?: null;
$variantLabel = $variant?->getOption();
// Not routed through checkout::'s own locale-explicit convention — this
// is a storefront route, so it follows the storefront's own (implicit
// locale) call shape, same as App\Catalog\ProductCard. Carries the
// variant id along so the product page can restore the same option the
// shopper actually has in their cart, not just default to the first one
// (see product-form-controller.js reading ?variant= on connect()).
$productUrl = $product ? route('product.show', ['id' => $product->id, 'variant' => $variant?->id]) : null;
@endphp
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
<div class="bbk-cart-item-media">
@if ($thumb)
@if ($productUrl)
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
</a>
@else
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
@endif
@endif
</div>
<div class="bbk-cart-item-detail">
@if ($productUrl)
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a>
@else
<p class="bbk-cart-item-title">{{ $name }}</p>
@endif
@if ($variantLabel)
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
@endif
@include('checkout::partials.line-custom-fields', ['line' => $line])
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
<form
class="bbk-cart-qty"
method="POST"
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
>
@csrf
@method('PATCH')
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}"
>&minus;</button>
<input
type="number"
name="quantity"
value="{{ $line->quantity }}"
min="0"
inputmode="numeric"
class="bbk-cart-qty-input"
data-action="change->bbk-cart#submit"
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
aria-label="{{ __('checkout.cart.quantity') }}"
>
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}"
>+</button>
</form>
</div>
<div class="bbk-cart-item-aside">
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
<form
method="POST"
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
>&times;</button>
</form>
</div>
</li>
@@ -0,0 +1,50 @@
{{--
@include('checkout::partials.line-custom-fields', ['line' => $line])
A cart or order line's custom-field answers (meta.custom_fields, written by
Cart\Http\Controllers\CartController::customFieldsMeta()). A file answer
only carries a File id (Modules\Core\File\Models\File is the source of
truth for name/mime/disk/path — never duplicated into meta), resolved
here and linked through the signed download route (files.download),
minted fresh on every render, with a thumbnail when the browser can
display the format (HEIC can't be shown outside Safari, so it gets the
name only).
--}}
@php
$fields = $line->meta['custom_fields'] ?? [];
$previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
@endphp
@if (! empty($fields))
<dl class="bbk-line-fields">
@foreach ($fields as $field)
<div class="bbk-line-field">
<dt>{{ $field['label'] }}</dt>
<dd>
@if ($field['type'] === 'file')
@php
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
@endphp
@if ($file)
@php
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
'files.download',
now()->addHours(2),
['file' => $file->id],
);
@endphp
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
@if (in_array($file->mime, $previewable, true))
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
@endif
<span>{{ $file->original_name }}</span>
</a>
@endif
@else
{{ $field['value'] }}
@endif
</dd>
</div>
@endforeach
</dl>
@endif
@@ -0,0 +1,30 @@
{{--
Payment method radios. $paymentMethods is Collection<Modules\Core\Payment\
Models\PaymentMethod> from CheckoutService::getPaymentMethods() (already
filtered to enabled + driver-resolves + isConfigured()). Selecting one
autosaves via bbk-payment#selectMethod; `data-payment-driver` tells the
controller whether to mount the Stripe Element.
$paymentMethods, $cart come from the page / controller.
--}}
@php($selected = $cart?->meta['payment_method'] ?? null)
@if ($paymentMethods->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.payment_method_none') }}</p>
@else
<div class="bbk-checkout-payment-options">
@foreach ($paymentMethods as $method)
<label class="bbk-checkout-payment-option">
<input
type="radio"
name="payment_type"
value="{{ $method->type }}"
data-payment-driver="{{ $method->driver }}"
@checked($selected === $method->type)
data-action="change->bbk-payment#selectMethod"
>
<span class="bbk-checkout-payment-option-name">{{ $method->translate('name') }}</span>
</label>
@endforeach
</div>
@endif
@@ -0,0 +1,50 @@
{{--
Shipping methods for the checkout page. Rendered inline by page.blade.php on
load, and re-rendered as a fragment by CheckoutController after every
address save / option change (bbk-checkout-form swaps it in). Radios
autosave via bbk-checkout-form#selectShipping — no submit button. A single
resolved option is auto-selected server-side and shown as a fixed line.
$shippingAddress, $shippingOptions come from the controller / page scope.
--}}
@php($selected = $shippingAddress?->shipping_option)
{{-- Rate resolution needs country (always Greece here) + postcode; until a
postcode is saved there's nothing to quote against yet. --}}
@if (! $shippingAddress?->postcode)
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p>
@elseif ($shippingOptions->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_none') }}</p>
@elseif ($shippingOptions->count() === 1)
@php($only = $shippingOptions->first())
<div class="bbk-checkout-shipping-confirmed">
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $only->name }}</span>
@if ($only->description)
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($only->description) }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $only->price->formatted() }}</span>
</div>
@else
<div class="bbk-checkout-shipping-options">
@foreach ($shippingOptions as $option)
<label class="bbk-checkout-shipping-option">
<input
type="radio"
name="shipping_option"
value="{{ $option->identifier }}"
@checked($selected === $option->identifier)
data-action="change->bbk-checkout-form#selectShipping"
>
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $option->name }}</span>
@if ($option->description)
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($option->description) }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $option->price->formatted() }}</span>
</label>
@endforeach
</div>
@endif
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Auth\Events;
use Illuminate\Contracts\Auth\Authenticatable;
/**
* Dispatched by Customer\Services\CustomerEmailChangeService::confirm()
* once a login-email change actually takes effect — $oldEmail is what the
* account's login used to be, already overwritten on $user by the time
* this fires.
*/
class UserEmailChanged
{
public function __construct(
public readonly Authenticatable $user,
public readonly string $oldEmail,
) {}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\Auth\Listeners;
use Modules\Core\Auth\Events\UserCreated;
/**
* The storefront login page shows a terms/privacy notice ("By continuing,
* you accept the Terms of Use and have read the Privacy Policy") that
* requesting an OTP code implicitly accepts — recorded once, right here,
* for a genuinely new signup only (UserCreated fires exactly once per
* user, from Auth\Services\UserOtpService::generateAndSend()'s own
* wasRecentlyCreated check). An existing user's original acceptance
* (whatever version was live when THEY signed up) must never be
* overwritten by whatever config('legal.*') says today, which is exactly
* why this only ever runs from UserCreated and nowhere else.
*/
class RecordLegalAcceptanceForNewUser
{
public function handle(UserCreated $event): void
{
$event->user->forceFill([
'terms_accepted_at' => now(),
'terms_version' => config('legal.terms_version'),
'privacy_policy_version' => config('legal.privacy_policy_version'),
])->save();
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Auth\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Sent to the NEW address a shopper is trying to switch their login email
* to (Customer\Services\CustomerEmailChangeService::request()) — proves
* they can actually receive mail there before the switch takes effect.
* View overridable per-app the same way UserOtpMail's is (resources/
* views/vendor/core/auth/mail/email-change-code.blade.php).
*/
class EmailChangeCodeMail extends Mailable
{
public function __construct(
public readonly string $code,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Confirm your new email address');
}
public function content(): Content
{
return new Content(view: 'core::auth.mail.email-change-code');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Auth\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Sent to the OLD address once a login-email change actually takes
* effect (Customer\Services\CustomerEmailChangeService::confirm()) — lets
* the previous owner notice if someone else changed it from a hijacked
* session. Shows the new address masked (first character + domain only),
* never the full new address — this notice's whole point is alerting the
* OLD owner, not handing them the new address outright. View overridable
* per-app the same way UserOtpMail's is (resources/views/vendor/core/
* auth/mail/email-changed-notice.blade.php).
*/
class EmailChangedNoticeMail extends Mailable
{
public readonly string $maskedEmail;
public function __construct(string $newEmail)
{
[$local, $domain] = explode('@', $newEmail, 2);
$this->maskedEmail = mb_substr($local, 0, 1).'•••@'.$domain;
}
public function envelope(): Envelope
{
return new Envelope(subject: 'Your account email was changed');
}
public function content(): Content
{
return new Content(view: 'core::auth.mail.email-changed-notice');
}
}
+29 -2
View File
@@ -6,20 +6,47 @@ use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope; use Illuminate\Mail\Mailables\Envelope;
/**
* The one OTP email template for every use of Auth\Services\OtpService —
* not just admin login. A code confirming a destructive Artisan command
* (e.g. Command\WipeCatalogCommand) reuses the exact same generation/
* validation mechanism as login, but "Your login code" as the subject
* would be actively misleading for that — the recipient never initiated a
* login. $purpose is a small, fixed set of known keys (see
* COPY_BY_PURPOSE), not free text — a typo'd/unknown purpose falls back
* to 'login' rather than rendering a blank subject/intro.
*/
class OtpMail extends Mailable class OtpMail extends Mailable
{ {
private const COPY_BY_PURPOSE = [
'login' => [
'subject' => 'Your login code',
'intro' => 'Your login code is:',
],
'wipe-catalog' => [
'subject' => 'Confirm: Wipe Catalog',
'intro' => 'Someone requested to permanently delete every product in the catalog. If this was you, enter this code to confirm:',
],
];
public function __construct( public function __construct(
public readonly string $name, public readonly string $name,
public readonly string $code, public readonly string $code,
public readonly string $purpose = 'login',
) {} ) {}
public function envelope(): Envelope public function envelope(): Envelope
{ {
return new Envelope(subject: 'Your login code'); return new Envelope(subject: $this->copy()['subject']);
} }
public function content(): Content public function content(): Content
{ {
return new Content(view: 'core::auth.mail.otp'); return new Content(view: 'core::auth.mail.otp', with: ['intro' => $this->copy()['intro']]);
}
private function copy(): array
{
return self::COPY_BY_PURPOSE[$this->purpose] ?? self::COPY_BY_PURPOSE['login'];
} }
} }
+8 -2
View File
@@ -11,7 +11,13 @@ class OtpService
private const EXPIRY_MINUTES = 10; private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6; private const CODE_LENGTH = 6;
public function generateAndSend(string $email): bool /**
* $purpose is forwarded as-is to OtpMail, which only recognizes a
* fixed set of keys (see its own COPY_BY_PURPOSE) — an unrecognized
* value there just falls back to 'login' rather than failing here, so
* this method has nothing of its own to validate.
*/
public function generateAndSend(string $email, string $purpose = 'login'): bool
{ {
$staff = Staff::where('email', $email)->first(); $staff = Staff::where('email', $email)->first();
@@ -25,7 +31,7 @@ class OtpService
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); $staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$staff->save(); $staff->save();
Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code)); Mail::to($staff->email)->send(new OtpMail($staff->first_name, $code, $purpose));
return true; return true;
} }
+107 -34
View File
@@ -5,6 +5,7 @@ namespace Modules\Core\Auth\Services;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
@@ -29,17 +30,25 @@ use Modules\Core\Auth\Mail\UserOtpMail;
* here: doing our own on top would run a SECOND merge attempt with a * here: doing our own on top would run a SECOND merge attempt with a
* hardcoded policy that ignores whatever the consumer configured. * hardcoded policy that ignores whatever the consumer configured.
* *
* generateAndSend()'s find-or-create already triggers the full * generateAndSend() does NOT create a User row for an email it hasn't
* Customer/User pairing cascade for a genuinely new email — see * seen before — it used to (firstOrCreate() ran unconditionally), which
* Modules\Core\Auth\Events\UserCreated's own docblock and * meant this login FORM was effectively a registration form: anyone could
* Modules\Core\Customer\Listeners\CreateCustomerForUser. * create a real User (and, via UserCreated's own cascade, a paired
* Customer) for any email address they liked, whether or not a single
* correct code was ever entered. A genuinely new email's pending code now
* lives in the cache (see pendingKey()), keyed by email, with no DB row
* at all — firstOrCreate() and UserCreated only fire from validate(), and
* only once the code has actually been proven correct. An email that
* already has a User row is unaffected: its OTP state still lives on that
* row's own otp_code/otp_expires_at/otp_attempts columns exactly as
* before, so a returning shopper's login is unchanged.
* *
* Two independent throttles, both configured under core.auth.otp — see * Two independent throttles, both configured under core.auth.otp — see
* config/core.php's own comment for why they're separate: max_attempts * config/core.php's own comment for why they're separate: max_attempts
* caps wrong guesses against ONE code; generation_limit caps how often a * caps wrong guesses against ONE code; generation_limit caps how often a
* NEW code can be requested for the same email at all (closes both the * NEW code can be requested for the same email at all (closes both the
* "regenerate to reset my guess count" loophole and mail-bombing one * "regenerate to reset my guess count" loophole and mail-bombing one
* inbox). * inbox). Both apply identically whether or not a User row exists yet.
* *
* validate() also records a UserSessionService entry for the new login — * validate() also records a UserSessionService entry for the new login —
* see that class's own docblock for the "logout everywhere" registry * see that class's own docblock for the "logout everywhere" registry
@@ -74,28 +83,27 @@ class UserOtpService
RateLimiter::hit($limiterKey, (int) config('core.auth.otp.generation_decay_minutes', 10) * 60); RateLimiter::hit($limiterKey, (int) config('core.auth.otp.generation_decay_minutes', 10) * 60);
$model = config('auth.providers.users.model'); $model = config('auth.providers.users.model');
$user = $model::firstOrCreate(['email' => $email]); $user = $model::where('email', $email)->first();
// wasRecentlyCreated is Eloquent's own "did firstOrCreate() just
// INSERT, or did it find an existing row" flag — the only reliable
// way to tell them apart from firstOrCreate()'s return value alone.
// Without this check, a genuinely new signup never fired
// UserCreated at all (this class's own docblock claimed the
// Customer/User pairing cascade "already triggers" here, which was
// false as written — see Modules\Core\Customer\Listeners\
// CreateCustomerForUser, which depends entirely on this event).
if ($user->wasRecentlyCreated) {
Event::dispatch(new UserCreated($user));
}
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT); $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
if ($user) {
$user->otp_code = $code; $user->otp_code = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); $user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->otp_attempts = 0; $user->otp_attempts = 0;
$user->save(); $user->save();
} else {
// No row yet — deliberately not created here. See this
// class's own docblock for why: creating one on every
// generateAndSend() call let anyone mint real User/Customer
// rows for an email nobody proved they owned.
Cache::put($this->pendingKey($email), [
'code' => $code,
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
'attempts' => 0,
], now()->addMinutes(self::EXPIRY_MINUTES));
}
Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code)); Mail::to($email)->send(new UserOtpMail($user->name ?? $email, $code));
return true; return true;
} }
@@ -106,19 +114,44 @@ class UserOtpService
* a fresh one via generateAndSend() (itself throttled independently * a fresh one via generateAndSend() (itself throttled independently
* — see this class's own docblock) rather than being able to keep * — see this class's own docblock) rather than being able to keep
* guessing against a still-live code for the rest of its 10-minute * guessing against a still-live code for the rest of its 10-minute
* expiry window. * expiry window. Applies identically to the cache-backed (no User row
* yet) and DB-backed (existing User row) paths.
*/ */
public function validate(string $email, string $code, ?Request $request = null): ?Authenticatable public function validate(string $email, string $code, ?Request $request = null): ?Authenticatable
{ {
$model = config('auth.providers.users.model'); $model = config('auth.providers.users.model');
$existing = $model::where('email', $email)->exists();
// lockForUpdate() + a transaction make the read-check-increment-save $result = $existing
// below atomic across concurrent requests for the same user — without ? $this->validateExisting($model, $email, $code)
// it, two guesses fired in parallel can each read the same : $this->validatePending($model, $email, $code);
// pre-increment otp_attempts value and both save past
// max_attempts, letting an attacker exceed the lockout by if (! $result) {
// parallelizing requests instead of sending them serially. return null;
$result = DB::transaction(function () use ($model, $email, $code) { }
RateLimiter::clear($this->generationLimiterKey($email));
Auth::login($result);
$this->sessions->record($result, $request);
Event::dispatch(new UserAuthenticated($result));
return $result;
}
/**
* lockForUpdate() + a transaction make the read-check-increment-save
* atomic across concurrent requests for the same user — without it,
* two guesses fired in parallel can each read the same pre-increment
* otp_attempts value and both save past max_attempts, letting an
* attacker exceed the lockout by parallelizing requests instead of
* sending them serially.
*/
private function validateExisting(string $model, string $email, string $code): ?Authenticatable
{
return DB::transaction(function () use ($model, $email, $code) {
$user = $model::where('email', $email)->lockForUpdate()->first(); $user = $model::where('email', $email)->lockForUpdate()->first();
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) { if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
@@ -146,24 +179,64 @@ class UserOtpService
return $user; return $user;
}); });
}
if (! $result) { /**
* No User row exists yet, so there's nothing to lockForUpdate() —
* Cache::lock() is the equivalent guard against two parallel guesses
* against the same pending signup both reading the same pre-increment
* attempts count. The User (and, via UserCreated, its paired Customer)
* is only ever created here, once the code has actually been proven
* correct — never from generateAndSend().
*/
private function validatePending(string $model, string $email, string $code): ?Authenticatable
{
$key = $this->pendingKey($email);
return Cache::lock("{$key}:lock", 10)->block(5, function () use ($model, $email, $code, $key) {
$pending = Cache::get($key);
if (! $pending || now()->timestamp > $pending['expires_at']) {
return null; return null;
} }
RateLimiter::clear($this->generationLimiterKey($email)); if (! hash_equals((string) $pending['code'], $code)) {
$pending['attempts']++;
Auth::login($result); if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) {
Cache::forget($key);
} else {
Cache::put($key, $pending, now()->addMinutes(self::EXPIRY_MINUTES));
}
$this->sessions->record($result, $request); return null;
}
Event::dispatch(new UserAuthenticated($result)); Cache::forget($key);
return $result; $user = $model::firstOrCreate(['email' => $email]);
// wasRecentlyCreated is Eloquent's own "did firstOrCreate()
// just INSERT, or did it find an existing row" flag. Always
// true here in practice (validatePending() only runs when no
// row existed moments ago), but checked anyway rather than
// assumed, in case of an extremely unlikely race with a
// signup completed through some other path in between.
if ($user->wasRecentlyCreated) {
Event::dispatch(new UserCreated($user));
}
return $user;
});
} }
private function generationLimiterKey(string $email): string private function generationLimiterKey(string $email): string
{ {
return 'otp-generate:'.strtolower($email); return 'otp-generate:'.strtolower($email);
} }
private function pendingKey(string $email): string
{
return 'otp-pending:'.strtolower($email);
}
} }
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Lunar\Admin\Filament\Resources\CustomerResource; use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct; use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct;
use Lunar\Exceptions\MissingCurrencyPriceException;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Lunar\Models\CartLine; use Lunar\Models\CartLine;
use Lunar\Models\ProductVariant; use Lunar\Models\ProductVariant;
@@ -47,6 +48,17 @@ class ViewCart extends ViewRecord
* own OrderItemsTable loads for an order's line items (`with(['purchasable'])`, * own OrderItemsTable loads for an order's line items (`with(['purchasable'])`,
* see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so * see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so
* rendering the product grid doesn't N+1 per line. * rendering the product grid doesn't N+1 per line.
*
* calculate() throws Lunar\Exceptions\MissingCurrencyPriceException
* (vendor PricingManager) the moment ANY line's purchasable has no
* price row for the cart's currency — including a line whose
* purchasable no longer exists at all (a deleted ProductVariant still
* referenced by cart_lines.purchasable_id), which 500'd this whole
* page rather than just leaving that one line unpriced. The Lines
* section below already guards every purchasable-derived field with
* `instanceof ProductVariant` and renders fine with $cart left
* uncalculated — subTotal/total/etc. simply won't be populated, which
* reads as a stale/pending state rather than a broken page.
*/ */
protected function resolveRecord(int|string $key): Cart protected function resolveRecord(int|string $key): Cart
{ {
@@ -58,7 +70,11 @@ class ViewCart extends ViewRecord
EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant)) EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant))
->loadMissing(['product.thumbnail', 'images', 'values']); ->loadMissing(['product.thumbnail', 'images', 'values']);
try {
return $cart->calculate(); return $cart->calculate();
} catch (MissingCurrencyPriceException) {
return $cart;
}
} }
public function infolist(Schema $schema): Schema public function infolist(Schema $schema): Schema
@@ -0,0 +1,253 @@
<?php
namespace Modules\Core\Cart\Http\Controllers;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Models\CartLine;
use Lunar\Models\ProductVariant;
use Modules\Core\Cart\Exceptions\InvalidCouponException;
use Modules\Core\Cart\Services\CartService;
use Modules\Core\File\Models\File;
use Modules\Core\File\Services\FileService;
use Modules\Core\Localization\Services\LanguageCache;
/**
* Thin storefront cart endpoints for the checkout module. Every action mutates
* the session cart via CartService and returns the same server-rendered
* `cart-body` partial — the drawer's Stimulus controller swaps that fragment
* in place (no JSON, no client-side templating). $cart / $lines for the
* partial come from the view composer in Providers\CheckoutModuleServiceProvider.
*/
class CartController extends Controller
{
public function __construct(
private readonly CartService $cart,
) {}
/**
* CartException here is Lunar's own add_to_cart validation pipeline
* (CartLineQuantity/CartLineStock) rejecting the line — most commonly
* "not enough stock at this quantity" for a tracked (purchasable =
* in_stock) variant. Its own message is an untranslated, hardcoded
* English string not meant for storefront display, so this returns our
* own translated one instead rather than passing it through — a
* storefront.* key rather than checkout.*, since this is a catalog/stock
* concern the storefront owns, not something specific to the portable
* checkout module.
*/
public function add(string $locale, Request $request): View|JsonResponse
{
$data = $request->validate([
'purchasable_id' => ['required', 'integer'],
'quantity' => ['nullable', 'integer', 'min:1'],
'custom_fields' => ['nullable', 'array'],
]);
$variant = ProductVariant::findOrFail($data['purchasable_id']);
try {
$meta = $this->customFieldsMeta($variant, $data['custom_fields'] ?? []);
} catch (ValidationException $e) {
return response()->json(['error' => collect($e->errors())->flatten()->first()], 422);
}
try {
$this->cart->addLine($variant, $data['quantity'] ?? 1, $meta);
} catch (CartException) {
return $this->stockError($variant);
}
return view('checkout::partials.cart-body');
}
/**
* The shopper's answers to the product's custom fields (Catalog\Models\
* Product::$custom_fields — {key, type: text|textarea|file, label,
* required}), as cart line meta. Lunar copies CartLine.meta onto the
* OrderLine at order creation, so this is also what the order keeps.
*
* Only keys the product actually defines are kept, nested under
* `custom_fields` — line meta also carries behavior flags (core's
* `saved_for_later` zeroes the line's price), so shopper input must never
* be merged into it directly. Label and type are snapshotted alongside
* each value so the cart/order still reads correctly if the product's
* fields are edited later.
*
* A `file` answer is the id of a File row the host's own upload endpoint
* already created via FileService — never the file's bytes, disk, or
* path, all of which FileService alone is the source of truth for. A
* shopper can't point this at someone else's file: the id must resolve
* to a File that is BOTH unowned (isFileAnswerValid()) and tagged with
* config('checkout.custom_field_upload_purpose') — the host's own
* upload endpoint sets its File rows to this same purpose string, so
* this stays a single source of truth without this module reaching
* into a host controller class directly (an inverted dependency this
* module can't have — a host app's upload endpoint is deliberately its
* own concern, see config/checkout.php's own comment). Attaching the
* File to the real CartLine it belongs to happens afterward, in File\
* Listeners\AttachCustomFieldFileToCartLine (listening for Cart\Events\
* CartLineAdded) — not here, since this method only builds the meta
* $this->cart->addLine() is about to receive, before any CartLine
* actually exists to own anything.
*
* Two adds with identical answers merge into one line (Lunar matches
* existing lines on meta); different answers stay separate lines.
*/
private function customFieldsMeta(ProductVariant $variant, array $input): array
{
$fields = collect($variant->product?->custom_fields ?? [])
->keyBy('key')
->map(fn (array $field) => [...$field, 'label' => $this->resolveLabel($field['label'])]);
if ($fields->isEmpty()) {
return [];
}
$validated = Validator::make(
$input,
$fields->map(fn (array $field) => [
($field['required'] ?? false) ? 'required' : 'nullable',
...match ($field['type']) {
'textarea' => ['string', 'max:2000'],
'file' => [function (string $attribute, mixed $value, Closure $fail) {
if (! $this->isFileAnswerValid($value)) {
$fail('validation.uploaded')->translate();
}
}],
default => ['string', 'max:255'],
},
])->all(),
[],
$fields->map(fn (array $field) => $field['label'])->all(),
)->validate();
$answers = $fields
->filter(fn (array $field) => filled($validated[$field['key']] ?? null))
->map(fn (array $field) => [
'key' => $field['key'],
'label' => $field['label'],
'type' => $field['type'],
...($field['type'] === 'file'
? ['file_id' => (int) $validated[$field['key']]]
: ['value' => $validated[$field['key']]]),
])
->values()
->all();
return $answers === [] ? [] : ['custom_fields' => $answers];
}
/**
* Product::$custom_fields stores `label` as {locale: string} (see
* Catalog\Filament\Pages\ManageProductCustomFields) — this resolves it
* to the single current-locale string cart/order line meta actually
* needs, the same filled()-over-?? fallback ProductDocumentLocalizer
* uses for every other translated field (an empty string for the
* current locale still falls through to the store's default language,
* rather than showing blank). A product saved before labels became
* translatable still has a plain string here, returned as-is.
*/
private function resolveLabel(mixed $label): string
{
if (! is_array($label)) {
return (string) $label;
}
$locale = App::getLocale();
$fallbackLocale = app(LanguageCache::class)->defaultLocale();
return filled($label[$locale] ?? null)
? $label[$locale]
: ($label[$fallbackLocale] ?? '');
}
private function isFileAnswerValid(mixed $fileId): bool
{
$file = File::find($fileId);
return $file !== null
&& $file->purpose === config('checkout.custom_field_upload_purpose')
&& $file->owner_id === null
&& app(FileService::class)->exists($file);
}
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
{
$quantity = (int) $request->validate([
'quantity' => ['required', 'integer', 'min:0'],
])['quantity'];
try {
$quantity === 0
? $this->cart->removeLine($line)
: $this->cart->updateLine($line, $quantity);
} catch (CartException) {
$variant = CartLine::find($line)?->purchasable;
return $this->stockError($variant instanceof ProductVariant ? $variant : null);
}
return view('checkout::partials.cart-body');
}
/**
* getTotalInventory() is the same number canBeFulfilledAtQuantity()
* checked against (stock, for a tracked in_stock variant) — telling the
* shopper how many are actually left beats a generic "not enough stock"
* they'd otherwise have to guess around by trial and error.
*/
private function stockError(?ProductVariant $variant): JsonResponse
{
$available = $variant?->getTotalInventory() ?? 0;
return response()->json([
'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]),
], 422);
}
public function remove(string $locale, int $line): View
{
$this->cart->removeLine($line);
return view('checkout::partials.cart-body');
}
/**
* A bad code is a normal, expected outcome here (typo, expired code), not
* an error state for the request — it re-renders the same cart-body
* partial with $couponError set, rather than a 4xx/redirect, so the fetch
* + swap in bbk-cart-controller stays the one code path for every cart
* mutation.
*/
public function applyCoupon(string $locale, Request $request): View
{
$code = $request->validate([
'code' => ['required', 'string'],
])['code'];
$couponError = false;
try {
$this->cart->applyCoupon($code);
} catch (InvalidCouponException) {
$couponError = true;
}
return view('checkout::partials.cart-body', ['couponError' => $couponError]);
}
public function removeCoupon(string $locale): View
{
$this->cart->removeCoupon();
return view('checkout::partials.cart-body');
}
}
+7
View File
@@ -14,6 +14,7 @@ enum ProductSort: string
case PriceAsc = 'price_asc'; case PriceAsc = 'price_asc';
case PriceDesc = 'price_desc'; case PriceDesc = 'price_desc';
case Newest = 'newest'; case Newest = 'newest';
case Popularity = 'popularity';
public function toMeilisearchSort(): string public function toMeilisearchSort(): string
{ {
@@ -21,6 +22,12 @@ enum ProductSort: string
self::PriceAsc => 'price:asc', self::PriceAsc => 'price:asc',
self::PriceDesc => 'price:desc', self::PriceDesc => 'price:desc',
self::Newest => 'created_at:desc', self::Newest => 'created_at:desc',
// order_count — see Modules\Core\Catalog\Services\
// ProductIndexer::toSearchableArray()'s own docblock: the same
// trailing-year, physical-order-line-count definition Lunar's
// own admin dashboard "Popular Products" widget already uses,
// aggregated per product rather than per variant.
self::Popularity => 'order_count:desc',
}; };
} }
} }
@@ -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)),
]),
]);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\Catalog\Models;
/**
* Registered via Lunar\Facades\ModelManifest::replace(Lunar\Models\
* Product::class, self::class) — see Providers\CatalogServiceProvider —
* purely to add a cast AND fillable entry for `custom_fields` (see the
* migration adding that column: database/migrations/
* ..._add_custom_fields_to_products_table.php). Without the fillable
* entry, Lunar\Models\Product's own $fillable allowlist (attribute_data,
* product_type_id, status, brand_id — custom_fields isn't in it) silently
* drops the field on every mass-assignment save (Filament's own
* $record->update($data)) — no error, no exception, the admin form shows
* the repeater's rows as saved right up until the next page load, when
* they're simply gone. Caught in practice.
*
* ModelManifest::replace() only changes what code resolving Product
* through the CONTRACT (app(Contracts\Product::class), Filament's own
* ProductResource — its $model is ProductContract::class, not the
* concrete class) or the morph map receives — it does NOT retroactively
* change what a hardcoded `Lunar\Models\Product::query()`/`::find()`
* elsewhere in this codebase (or Lunar's own internals, e.g. the
* scheduled Meilisearch reindex command — see CatalogServiceProvider,
* which references this subclass by name specifically so that path picks
* it up too) resolves to. Most of this codebase's existing Product
* references are plain type-hints (they accept whichever instance is
* handed to them, subclass included) or don't touch `custom_fields` at
* all, so they're unaffected either way.
*/
class Product extends \Lunar\Models\Product
{
// NOT `protected $casts = [...]` — that property assignment REPLACES
// the parent's own $casts array wholesale rather than merging with
// it (PHP class property redeclaration has no merge semantics), which
// would silently drop every cast Lunar\Models\Product already
// defines (attribute_data, status, etc.). mergeCasts() is Eloquent's
// own documented mechanism for a subclass adding to, not replacing,
// its parent's casts.
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->mergeCasts([
'custom_fields' => 'array',
]);
$this->mergeFillable([
'custom_fields',
]);
}
}
+26
View File
@@ -5,6 +5,7 @@ namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Lunar\Models\Currency; use Lunar\Models\Currency;
use Lunar\Models\OrderLine;
use Lunar\Models\Price; use Lunar\Models\Price;
use Lunar\Models\Product; use Lunar\Models\Product;
use Lunar\Models\ProductVariant; use Lunar\Models\ProductVariant;
@@ -103,6 +104,7 @@ class ProductIndexer extends BaseProductIndexer
return [ return [
...parent::getSortableFields(), ...parent::getSortableFields(),
'price', 'price',
'order_count',
]; ];
} }
@@ -139,6 +141,12 @@ class ProductIndexer extends BaseProductIndexer
->all(); ->all();
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); $data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
$data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all(); $data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all();
// Only decoded correctly when $model is an instance of
// Modules\Core\Catalog\Models\Product (the custom_fields cast
// lives there, not on the base Lunar\Models\Product) — see
// CatalogServiceProvider's own comment on why the scheduled
// reindex command references that subclass by name specifically.
$data['custom_fields'] = $model->custom_fields ?? [];
$data['tags'] = $model->tags->pluck('value')->all(); $data['tags'] = $model->tags->pluck('value')->all();
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all(); $data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all(); $data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
@@ -155,6 +163,24 @@ class ProductIndexer extends BaseProductIndexer
$data['in_stock'] = $model->variants->contains( $data['in_stock'] = $model->variants->contains(
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1) fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
); );
// Same "popular" definition as Lunar's own admin dashboard widget
// (Lunar\Admin\Filament\Widgets\Dashboard\Orders\
// PopularProductsTable) — order-line COUNT, not summed quantity,
// over the trailing year, physical lines only — just aggregated
// per PRODUCT here (across all its variants) rather than per
// variant/identifier, since a storefront "sort by popularity"
// ranks products, not individual variant SKUs. Necessarily as
// stale as any other reindex-time field here (in_stock, price) —
// there's no live equivalent without a query per page load.
$data['order_count'] = OrderLine::query()
->whereIn('purchasable_id', $model->variants->pluck('id'))
->where('purchasable_type', 'product_variant')
->where('type', 'physical')
->whereHas('order', fn ($query) => $query->whereBetween('placed_at', [
now()->subYear()->startOfDay(),
now()->endOfDay(),
]))
->count();
$data['recommendations'] = app(RecommendationService::class) $data['recommendations'] = app(RecommendationService::class)
->recommend($model) ->recommend($model)
->load(['media', 'variants.prices']) ->load(['media', 'variants.prices'])
+5 -2
View File
@@ -254,7 +254,10 @@ class ProductService
public function random(int $limit): array public function random(int $limit): array
{ {
$raw = Product::search('') $raw = Product::search('')
->options(['attributesToRetrieve' => ['id']]) ->options([
'attributesToRetrieve' => ['id'],
'filter' => $this->filterBuilder->withVisibility(),
])
->raw(); ->raw();
$ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values(); $ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values();
@@ -287,7 +290,7 @@ class ProductService
private function findAllWhere(string $filter, int $limit = 1000): array private function findAllWhere(string $filter, int $limit = 1000): array
{ {
$paginator = Product::search('') $paginator = Product::search('')
->options(['filter' => $filter]) ->options(['filter' => $this->filterBuilder->withVisibility($filter)])
->paginateRaw(perPage: $limit, page: 1); ->paginateRaw(perPage: $limit, page: 1);
return collect($this->localizer->hitsFrom($paginator)) return collect($this->localizer->hitsFrom($paginator))
@@ -0,0 +1,57 @@
<?php
namespace Modules\Core\Catalog\Services;
use Lunar\Models\ProductVariant;
/**
* Generates a SKU for every ProductVariant missing one — extracted out of
* Command\BackfillMissingSkusCommand (which becomes a thin CLI wrapper
* around this, keeping --dry-run/progress-bar concerns out of the
* reusable logic) so MigrateImport\Shopify\Services\ShopifyExportImporter can
* also call it directly, once every product job in its import batch has
* finished (see that class's own import()), with no CLI concerns at all.
*
* Format is "SKU-P{product_id}-V{variant_id}": deterministic and
* guaranteed unique without a uniqueness check, since product_id/
* variant_id already are. Only variants with a null `sku` are touched —
* not an importer bug when one shows up after a Shopify import, the
* source CSV rows genuinely had no `Variant SKU` value (see
* MigrateImport\Shopify\Services\ShopifyExportImporter).
*/
class SkuBackfillService
{
/**
* @param ?callable(ProductVariant, string): void $onEach invoked
* once per variant with the sku about to be written (or, when
* $dryRun is true, that WOULD be written) — the command's own
* --dry-run listing and progress bar hook in here without this
* service knowing anything about console output.
* @return int the number of variants processed
*/
public function backfill(bool $dryRun = false, ?callable $onEach = null): int
{
$query = ProductVariant::query()->whereNull('sku');
$total = $query->count();
if ($total === 0) {
return 0;
}
$query->chunkById(500, function ($variants) use ($dryRun, $onEach) {
foreach ($variants as $variant) {
$sku = "SKU-P{$variant->product_id}-V{$variant->id}";
if (! $dryRun) {
$variant->update(['sku' => $sku]);
}
if ($onEach !== null) {
$onEach($variant, $sku);
}
}
});
return $total;
}
}
@@ -51,16 +51,62 @@ class ProductDocumentLocalizer
$availableLocales = $this->languages->availableLocales(); $availableLocales = $this->languages->availableLocales();
foreach ($this->translatedAttributeHandles() as $handle) { foreach ($this->translatedAttributeHandles() as $handle) {
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null; // filled(), not ?? - a translated attribute saved blank for
// the current locale still has that {handle}_{locale} key in
// the document, just set to '' rather than absent. ?? only
// falls back on a missing/null key, so it kept the empty
// string instead of falling through to a locale that actually
// has content.
$product[$handle] = filled($product[$handle.'_'.$locale] ?? null)
? $product[$handle.'_'.$locale]
: ($product[$handle.'_'.$fallbackLocale] ?? null);
foreach ($availableLocales as $availableLocale) { foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]); unset($product[$handle.'_'.$availableLocale]);
} }
} }
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
+31 -3
View File
@@ -10,6 +10,13 @@ use Modules\Core\Catalog\DTOs\ProductFilters;
* out of ProductService (where it originated, scoped to browsing/filtering * out of ProductService (where it originated, scoped to browsing/filtering
* without a search term) so ProductSearchService can apply the exact same * without a search term) so ProductSearchService can apply the exact same
* filter semantics to a text query too, rather than reimplementing it. * filter semantics to a text query too, rather than reimplementing it.
*
* Also the single place that composes the draft-visibility clause (see
* withVisibility()) — every Meilisearch `filter` string ProductService
* constructs, including the handful of ad-hoc ones that don't call build()
* at all (getById()/getBySlug()'s id lookup, random()'s id-only fetch),
* goes through this class so none of them can silently omit it the way a
* status filter was missing everywhere until now.
*/ */
class ProductFilterBuilder class ProductFilterBuilder
{ {
@@ -19,10 +26,10 @@ class ProductFilterBuilder
* ProductService::priceRange() excludes 'price' so a price slider's own * ProductService::priceRange() excludes 'price' so a price slider's own
* bounds don't shrink to whatever range is already selected on it. * bounds don't shrink to whatever range is already selected on it.
*/ */
public function build(?ProductFilters $filters, array $exclude = []): ?string public function build(?ProductFilters $filters, array $exclude = []): string
{ {
if ($filters === null) { if ($filters === null) {
return null; return $this->withVisibility();
} }
$clauses = Collection::make([ $clauses = Collection::make([
@@ -36,6 +43,27 @@ class ProductFilterBuilder
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null, 'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
])->except($exclude)->filter(); ])->except($exclude)->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND '); return $this->withVisibility($clauses->isEmpty() ? null : $clauses->join(' AND '));
}
/**
* A draft product (status = 'draft', see Lunar\Filament\Resources\
* ProductResource's own status Select) is only ever visible while
* APP_DEBUG is true — a merchant/developer previewing an unfinished
* product locally or on a staging box, never a real storefront
* visitor. Every ProductService method that builds a Meilisearch
* `filter` string, build() included, calls this rather than passing
* $rawClause straight to Product::search() — the one seam that
* guarantees none of them can omit the visibility rule.
*
* Always returns a non-empty string (never null) — a bare
* 'status = "published"' is itself a complete, valid Meilisearch
* filter on its own when $rawClause is null.
*/
public function withVisibility(?string $rawClause = null): string
{
$visibility = config('app.debug') ? null : 'status = "published"';
return Collection::make([$visibility, $rawClause])->filter()->join(' AND ');
} }
} }
@@ -0,0 +1,185 @@
<?php
namespace Modules\Core\Checkout\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
/**
* Default `checkout` translation lines for the cart drawer and the checkout
* page (see the checkout module under resources/views/checkout).
*
* Additive and idempotent: a group/key that already exists is left untouched,
* so anything edited in the Filament Language Lines UI wins on a re-run. Runs
* explicitly — `php artisan db:seed --class="Modules\Core\Checkout\Database\
* Seeders\CheckoutTranslationsSeeder"` — it is not wired into any app's own
* DatabaseSeeder.
*
* Greek copy uses an informal register (εσύ/σου) — a consuming app with a
* different house style overrides individual lines from the Filament
* Language Lines UI same as any other translation, rather than forking
* this class.
*/
class CheckoutTranslationsSeeder extends Seeder
{
public function run(): void
{
$translations = app(TranslationService::class);
foreach ($this->lines() as $key => [$en, $el]) {
$exists = LanguageLine::query()
->where('group', 'checkout')
->where('key', $key)
->exists();
if ($exists) {
$this->command?->warn("checkout.{$key} already exists — skipped");
continue;
}
$translations->create('checkout', $key, ['en' => $en, 'el' => $el]);
$this->command?->info("checkout.{$key} added");
}
}
/**
* key => [English, Greek].
*
* @return array<string, array{0: string, 1: string}>
*/
private function lines(): array
{
return [
// ── Cart drawer + order summary ──────────────────────────────
'cart.title' => ['Your cart', 'Το καλάθι σου'],
'cart.close' => ['Close', 'Κλείσιμο'],
'cart.empty' => ['Your cart is empty', 'Το καλάθι σου είναι άδειο'],
'cart.quantity' => ['Quantity', 'Ποσότητα'],
'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'],
'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'],
'cart.remove' => ['Remove', 'Αφαίρεση'],
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
'cart.discount' => ['Discount', 'Έκπτωση'],
'cart.shipping' => ['Shipping', 'Μεταφορικά'],
'cart.shipping_pending' => ['Not selected yet', 'Δεν έχει επιλεγεί ακόμη'],
'cart.tax' => ['VAT', 'ΦΠΑ'],
'cart.total' => ['Total', 'Σύνολο'],
'cart.checkout' => ['Checkout', 'Ολοκλήρωση παραγγελίας'],
'cart.coupon_label' => ['Coupon code', 'Κωδικός κουπονιού'],
'cart.coupon_placeholder' => ['Coupon code', 'Κωδικός κουπονιού'],
'cart.coupon_apply' => ['Apply', 'Εφαρμογή'],
'cart.coupon_remove' => ['Remove', 'Αφαίρεση'],
'cart.coupon_invalid' => ["That coupon code isn't valid", 'Ο κωδικός κουπονιού δεν είναι έγκυρος'],
// ── Checkout page ────────────────────────────────────────────
'page.title' => ['Checkout', 'Ολοκλήρωση παραγγελίας'],
'page.contact_heading' => ['Contact', 'Στοιχεία επικοινωνίας'],
'page.guest_tab' => ['Guest', 'Ως επισκέπτης'],
'page.login_tab' => ['Log in', 'Σύνδεση'],
'page.email_label' => ['Email', 'Email'],
'page.recovery_consent' => [
"Email me a reminder if I don't finish my order",
'Στείλε μου μια υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
],
'page.login_email_label' => ['Email', 'Email'],
'page.send_code' => ['Send code', 'Αποστολή κωδικού'],
'page.login_coming_soon' => [
'Login is coming soon — continue as a guest for now.',
'Η σύνδεση θα είναι διαθέσιμη σύντομα — προς το παρόν συνέχισε ως επισκέπτης.',
],
'page.billing_heading' => ['Billing information', 'Στοιχεία τιμολόγησης'],
'page.shipping_heading' => ['Shipping information', 'Στοιχεία αποστολής'],
'page.same_as_billing' => ['Same as billing address', 'Ίδια με τη διεύθυνση τιμολόγησης'],
'page.first_name' => ['First name', 'Όνομα'],
'page.last_name' => ['Last name', 'Επώνυμο'],
'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'],
'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'],
'page.address_line_one' => ['Address', 'Διεύθυνση'],
'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'],
'page.city' => ['City', 'Πόλη'],
'page.state' => ['Region / Prefecture', 'Νομός / Περιοχή'],
'page.state_placeholder' => ['Select a region', 'Επίλεξε νομό'],
'page.postcode' => ['Postcode', 'Ταχυδρομικός κώδικας'],
'page.country' => ['Country', 'Χώρα'],
'page.country_placeholder' => ['Select a country', 'Επίλεξε χώρα'],
'page.phone' => ['Phone', 'Τηλέφωνο'],
'page.delivery_instructions' => ['Delivery notes', 'Σχόλια για την παράδοση'],
'page.save_address' => ['Save and continue', 'Αποθήκευση και συνέχεια'],
'page.saving' => ['Saving…', 'Αποθήκευση…'],
'page.saved' => ['Saved', 'Αποθηκεύτηκε'],
'page.save_error' => ["Couldn't save — check your connection", 'Δεν αποθηκεύτηκε — έλεγξε τη σύνδεσή σου'],
'page.shipping_method_heading' => ['Shipping method', 'Τρόπος αποστολής'],
'page.shipping_method_empty' => [
'Add your shipping address to see delivery options.',
'Συμπλήρωσε τη διεύθυνση αποστολής για να δεις τις διαθέσιμες επιλογές.',
],
'page.shipping_method_none' => [
'No delivery options are available for this address.',
'Δεν υπάρχουν διαθέσιμες επιλογές αποστολής για αυτή τη διεύθυνση.',
],
'page.select_shipping_method' => ['Continue', 'Συνέχεια'],
'page.shipping_option_invalid' => [
'That shipping option is no longer available.',
'Αυτός ο τρόπος αποστολής δεν είναι πλέον διαθέσιμος.',
],
'page.continue_to_payment' => ['Continue to payment', 'Συνέχεια στην πληρωμή'],
'page.order_summary_heading' => ['Order summary', 'Σύνοψη παραγγελίας'],
// ── Payment step ────────────────────────────────────────────
'page.payment_heading' => ['Payment', 'Πληρωμή'],
'page.payment_method_none' => [
'No payment methods are available right now.',
'Δεν υπάρχουν διαθέσιμοι τρόποι πληρωμής αυτή τη στιγμή.',
],
'page.terms_accept' => [
"I accept the <a href=':terms' target='_blank'>Terms of Sale</a> and the <a href=':privacy' target='_blank'>Privacy Policy</a>",
"Αποδέχομαι τους <a href=':terms' target='_blank'>Όρους Πώλησης</a> και την <a href=':privacy' target='_blank'>Πολιτική Απορρήτου</a>",
],
'page.terms_required' => [
'You must accept the terms to place your order.',
'Πρέπει να αποδεχτείς τους όρους για να ολοκληρώσεις την παραγγελία.',
],
'page.withdrawal_notice' => [
"You have a 14-day right of withdrawal. <a href=':link' target='_blank'>See details</a>.",
"Έχεις δικαίωμα υπαναχώρησης εντός 14 ημερών. <a href=':link' target='_blank'>Δες λεπτομέρειες</a>.",
],
'page.place_order' => ['Place order — payment obligation', 'Παραγγελία με υποχρέωση πληρωμής'],
'page.choose_payment_method' => ['Choose a payment method.', 'Επίλεξε τρόπο πληρωμής.'],
'page.shipping_method_required' => [
'Choose a shipping method below to continue.',
'Επίλεξε τρόπο αποστολής παρακάτω για να συνεχίσεις.',
],
'page.payment_failed' => ['Payment failed. Please try again.', 'Η πληρωμή απέτυχε. Δοκίμασε ξανά.'],
'page.payment_incomplete_details' => [
'Complete your billing and shipping details above.',
'Συμπλήρωσε τα στοιχεία χρέωσης και αποστολής παραπάνω.',
],
'page.payment_cart_changed' => [
'Your cart changed. Refresh the page and place your order again.',
'Το καλάθι σου άλλαξε. Ανανέωσε τη σελίδα και ολοκλήρωσε ξανά.',
],
'page.payment_processing' => ['Confirming your payment…', 'Επιβεβαίωση πληρωμής…'],
'page.payment_processing_slow' => [
"Your payment is still processing. You'll get an email once it's confirmed.",
'Η πληρωμή σου επεξεργάζεται ακόμη. Θα λάβεις email μόλις επιβεβαιωθεί.',
],
// ── Confirmation page ──────────────────────────────────────
'page.confirmation_title' => ['Your order', 'Η παραγγελία σου'],
'page.confirmation_heading' => [
'Thank you! Your order is confirmed.',
'Ευχαριστούμε! Η παραγγελία σου καταχωρήθηκε.',
],
'page.confirmation_order_number' => ['Order number', 'Αριθμός παραγγελίας'],
'page.confirmation_email_note' => [
'A confirmation email will follow shortly.',
'Θα λάβεις email επιβεβαίωσης σύντομα.',
],
'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'],
'page.confirmation_billing' => ['Billing', 'Χρέωση'],
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
];
}
}
@@ -0,0 +1,676 @@
<?php
namespace Modules\Core\Checkout\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Facades\CartSession;
use Lunar\Models\Cart;
use Lunar\Models\Country;
use Lunar\Models\Order;
use Lunar\Models\State;
use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Checkout\Services\CheckoutService;
use Modules\Core\Customer\Services\CustomerAccountService;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* The checkout page — one page, sections (contact / billing / shipping /
* shipping method / payment), built on CheckoutService.
*
* The address form and the shipping-method radios **autosave** — no submit
* buttons. `saveAddress()` / `selectShippingOption()` are called by
* bbk-checkout-form (debounced fetch) and return a JSON envelope of
* server-rendered fragments (shipping options + order summary) plus any
* field errors, rather than redirecting. Address validation is deliberately
* lenient — nothing is rejected mid-typing; required-field enforcement is
* placeOrder()'s own gate.
*
* Guests type their email; the login tab links to config('checkout.login_route')
* and back. Logged in: the email is the account's (forced in saveAddress()),
* the first visit prefills addresses from the account (prefillFromAccount()),
* and Lunar's Login listener has already attached the cart, so the placed
* order lands in the account's history.
*
* config('checkout.store_country_iso3') fixes the country (hidden field,
* forced server-side) for a single-country store — null (the default) gives
* the full country picker, for a multi-country store.
*/
class CheckoutController extends Controller
{
public function __construct(
private readonly CartService $cart,
private readonly CheckoutService $checkout,
private readonly CustomerAccountService $account,
) {}
public function show(string $locale): View
{
$cart = $this->cart->current();
$lines = $cart ? $this->cart->activeLines($cart) : collect();
$storeCountry = $this->storeCountry();
$shippingOptions = collect();
// Captured before prefillFromAccount(), which may recreate the address
// row (dropping its shipping_option) — same reason as in saveAddress().
$previousOption = $cart?->shippingAddress?->shipping_option;
if ($cart && Auth::check()) {
$cart = $this->prefillFromAccount($cart);
// Nothing chosen on this cart yet: carry over the account's standing
// opt-in (an explicit earlier choice, recorded with its own
// timestamp/policy version). Never opts anyone in by default.
if (! array_key_exists('recovery_consent', $cart->meta?->toArray() ?? [])
&& data_get($this->account->customer(Auth::user()), 'meta.recovery_consent')) {
$cart = $this->checkout->setRecoveryConsent(true);
}
}
if ($cart?->shippingAddress) {
$shippingOptions = $this->syncShipping($cart, $previousOption);
// Cart's CachesProperties::refresh() explicitly nulls total/
// subTotal/shippingTotal/etc. back to their defaults — every
// Lunar call site pairs it with recalculate() for exactly that
// reason. Bare refresh() here was leaving $cart->total null on
// reload, which fed a 0 amount straight into the Stripe Element.
$cart->refresh()->recalculate();
}
$paymentMethods = $this->checkout->getPaymentMethods();
// Nothing checked yet (fresh cart), or the shopper's earlier pick is
// no longer offered (method disabled/removed since) — auto-select
// the first one, same as a manual click would, so the payment
// section (and the Stripe Element mounting under it) isn't sitting
// inert behind an unchecked radio. A still-valid previous choice is
// left alone.
$firstMethod = $paymentMethods->first();
if ($cart && $firstMethod && ! $paymentMethods->contains('type', data_get($cart, 'meta.payment_method'))) {
$cart = $this->checkout->selectPaymentMethod($firstMethod->type);
}
return view('checkout::page', [
'cart' => $cart,
'lines' => $lines,
'billingAddress' => $cart?->billingAddress,
'shippingAddress' => $cart?->shippingAddress,
'shippingOptions' => $shippingOptions,
'paymentMethods' => $paymentMethods,
'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true),
'wantsInvoice' => (bool) data_get($cart, 'meta.wants_invoice', false),
'storeCountry' => $storeCountry,
'countries' => $storeCountry
? collect()
: Country::orderBy('name')->get(['id', 'name']),
'regions' => $storeCountry
? State::where('country_id', $storeCountry->id)->orderBy('name')->get(['id', 'name'])
: collect(),
]);
}
public function saveAddress(string $locale, Request $request): JsonResponse
{
$storeCountry = $this->storeCountry();
$sameAsBilling = $request->boolean('same_as_billing');
// Only the fields shipping rates resolve against — if none of these
// changed (shopper edited their name, phone, email, …) there's no point
// re-quoting shipping or re-rendering the summary.
$addressBefore = $this->cart->current()?->shippingAddress;
$rateKeyBefore = $addressBefore?->only(['postcode', 'state', 'country_id']);
// setShippingAddress() below always deletes + recreates this row (see
// syncShipping()'s docblock) — capture what was selected NOW, before
// it's gone, so it can be carried forward onto the fresh row.
$previousOption = $addressBefore?->shipping_option;
$stateRule = $storeCountry
? ['nullable', 'string', Rule::exists((new State)->getTable(), 'name')->where('country_id', $storeCountry->id)]
: ['nullable', 'string', 'max:255'];
$countryRule = $storeCountry
? ['nullable']
: ['nullable', 'integer', 'exists:'.(new Country)->getTable().',id'];
// Lenient — only format checks. Anything that fails is simply left out
// of what gets persisted, and reported back for inline display.
$validator = Validator::make($request->all(), [
'contact_email' => ['nullable', 'email'],
'billing_first_name' => ['nullable', 'string', 'max:255'],
'billing_last_name' => ['nullable', 'string', 'max:255'],
'billing_company_name' => ['nullable', 'string', 'max:255'],
'billing_tax_identifier' => ['nullable', 'string', 'max:255'],
'billing_line_one' => ['nullable', 'string', 'max:255'],
'billing_city' => ['nullable', 'string', 'max:255'],
'billing_state' => $stateRule,
'billing_postcode' => ['nullable', 'string', 'max:20'],
'billing_country_id' => $countryRule,
'billing_contact_phone' => ['nullable', 'string', 'max:50'],
'shipping_first_name' => ['nullable', 'string', 'max:255'],
'shipping_last_name' => ['nullable', 'string', 'max:255'],
'shipping_line_one' => ['nullable', 'string', 'max:255'],
'shipping_city' => ['nullable', 'string', 'max:255'],
'shipping_state' => $stateRule,
'shipping_postcode' => ['nullable', 'string', 'max:20'],
'shipping_country_id' => $countryRule,
'shipping_contact_phone' => ['nullable', 'string', 'max:50'],
'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'],
]);
$errors = $validator->errors()->toArray();
$data = $validator->valid();
// Logged in: the order email is always the account's. It isn't a field
// on the page then, and a submitted value isn't trusted.
if ($user = Auth::user()) {
$data['contact_email'] = $user->email;
}
$billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null);
$shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId);
// Company/tax id only count when "I want an invoice" is ticked; the
// fields stay in the DOM (just hidden) when it isn't, so ignore what
// they send.
$wantsInvoice = $request->boolean('wants_invoice');
$billing = [
'first_name' => $data['billing_first_name'] ?? null,
'last_name' => $data['billing_last_name'] ?? null,
'company_name' => $wantsInvoice ? ($data['billing_company_name'] ?? null) : null,
'tax_identifier' => $wantsInvoice ? ($data['billing_tax_identifier'] ?? null) : null,
'line_one' => $data['billing_line_one'] ?? null,
'city' => $data['billing_city'] ?? null,
'state' => $data['billing_state'] ?? null,
'postcode' => $data['billing_postcode'] ?? null,
'country_id' => $billingCountryId,
'contact_email' => $data['contact_email'] ?? null,
'contact_phone' => $data['billing_contact_phone'] ?? null,
];
$shipping = $sameAsBilling
? [
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
]
: [
'first_name' => $data['shipping_first_name'] ?? null,
'last_name' => $data['shipping_last_name'] ?? null,
'line_one' => $data['shipping_line_one'] ?? null,
'city' => $data['shipping_city'] ?? null,
'state' => $data['shipping_state'] ?? null,
'postcode' => $data['shipping_postcode'] ?? null,
'country_id' => $shippingCountryId,
'contact_email' => $data['contact_email'] ?? null,
'contact_phone' => $data['shipping_contact_phone'] ?? null,
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
];
$this->checkout->setBillingAddress($billing);
$cart = $this->checkout->setShippingAddress($shipping);
$cart->meta = [
...($cart->meta?->toArray() ?? []),
'ship_to_billing' => $sameAsBilling,
'wants_invoice' => $wantsInvoice,
];
$cart->save();
// Abandoned-cart-recovery opt-in — boboko-core owns the record (bool +
// timestamp + policy version on Cart::meta, RecoveryConsentSet event).
// Deliberately its own scope, not merged with any future newsletter opt-in.
$this->checkout->setRecoveryConsent($request->boolean('recovery_consent'));
if (Auth::check()) {
$this->account->setRecoveryConsent(Auth::user(), $request->boolean('recovery_consent'));
}
$rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']);
$rateChanged = $rateKeyAfter != $rateKeyBefore;
// setShippingAddress() above always deletes and recreates the
// CartAddress row (Lunar's AddAddress action), which drops whatever
// shipping_option was previously selected — regardless of whether the
// rate-determining fields actually changed. So this always has to run
// to restore/re-validate it, even on a save that only touched e.g. the
// phone number. Only the fragment RE-RENDER is skippable when nothing
// rate-relevant moved — the re-select itself is not optional.
$options = $this->syncShipping($cart, $previousOption);
if (! $rateChanged) {
return $this->fragments($cart, null, $errors);
}
return $this->fragments($cart, $options, $errors);
}
public function selectShippingOption(string $locale, Request $request): JsonResponse
{
$identifier = (string) $request->input('shipping_option');
try {
$this->checkout->selectShippingOption($identifier);
} catch (InvalidShippingOptionException) {
// Re-render with whatever is currently valid; no hard error surfaced.
}
$cart = $this->cart->current();
$options = $cart?->shippingAddress
? $this->checkout->getShippingOptions()
: collect();
return $this->fragments($cart, $options);
}
/**
* Autosave-select a payment method (radio change). Persists it via
* CheckoutService (which also records it on Cart::meta and re-snapshots
* the fingerprint) so ApplyCashOnDeliveryFee etc. show in the summary.
*/
public function selectPaymentMethod(string $locale, Request $request): JsonResponse
{
$type = (string) $request->input('payment_type');
try {
$this->checkout->selectPaymentMethod($type);
} catch (UnknownPaymentTypeException) {
// Radio value out of sync with what's offered — ignore, the summary
// just won't reflect a method fee. place-order re-checks properly.
}
return response()->json([
'summaryHtml' => view('checkout::partials.cart-body')->render(),
]);
}
/**
* The real submit — the hard gate. Re-selects the payment method (fresh
* fingerprint), then hands off to CheckoutService::initiatePayment(), which
* creates the draft order, records terms acceptance, and charges the driver.
* Returns JSON the bbk-payment controller routes on:
* { redirect } — placed, go to confirmation
* { status: 'pending', clientSecret }— 3-D Secure; client does handleNextAction then polls
* { status: 'failed', message } — declined
* { status: 'invalid'|'stale', ... } — cart incomplete / changed since selection
*/
public function placeOrder(string $locale, Request $request): JsonResponse
{
if (! $request->boolean('terms_accepted')) {
return response()->json(['error' => __('checkout.page.terms_required')], 422);
}
try {
$this->checkout->selectPaymentMethod((string) $request->input('payment_type'));
} catch (UnknownPaymentTypeException) {
return response()->json(['error' => __('checkout.page.choose_payment_method')], 422);
}
$cart = $this->cart->current();
// Captured now, before initiatePayment() can place the order — Lunar's
// CartSessionManager::fetchOrCreate() silently swaps the session onto a
// BRAND NEW empty cart the moment the current one hasCompletedOrders()
// (i.e. has an order with placed_at set), which happens synchronously
// for an immediately-captured payment. Any later $this->cart->current()
// call in this same flow (here, or in a subsequent orderStatus() poll
// once the 3-D Secure webhook sets placed_at) would then resolve to
// that fresh, order-less cart instead of the one that was just placed.
// Storing the real cart id ourselves, under our own session key,
// sidesteps CartSession entirely for the rest of the placement flow.
session(['checkout.cart_id' => $cart?->id]);
// Lunar's own ValidateCartForOrderCreation (order_create validator)
// never checks for this — an empty cart with a valid billing address
// sails straight through it and would place a real, zero-line order.
// The disabled "place order" button is only the client-side half of
// this fix; this is the half that actually matters.
if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) {
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.cart_empty'),
], 422);
}
// Lenient autosave never requires these; this is the gate.
if (data_get($cart, 'meta.wants_invoice')
&& (blank($cart->billingAddress?->company_name) || blank($cart->billingAddress?->tax_identifier))) {
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.invoice_required'),
], 422);
}
// Same check Lunar's own ValidateCartForOrderCreation runs inside
// initiatePayment() (a product unpublished/deleted after it was
// added to the cart) — checked here first so the shopper is told
// which product is the problem, rather than falling into the
// catch-all "complete your billing/shipping details" message below,
// which is what actually happened and is generic to every
// CartException reason, misleading when the real cause is a line,
// not an address.
$unavailableLines = $this->cart->activeLines($cart)->filter(
fn ($line) => ! $line->purchasable || ! $line->purchasable->isPurchasable(),
);
if ($unavailableLines->isNotEmpty()) {
$names = $unavailableLines
->map(fn ($line) => $line->purchasable?->product?->translateAttribute('name') ?? $line->purchasable?->getIdentifier())
->filter()
->implode(', ');
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.cart_line_unavailable', ['name' => $names]),
], 422);
}
// The one incomplete-cart case worth a specific message + pointing the
// shopper at the right section: a region resolving 2+ methods needs an
// explicit pick (no auto-select), easy to miss since nothing else on
// the page demands it. Everything else CartException catches below.
if ($cart?->shippingAddress && ! $cart->shippingAddress->shipping_option) {
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.shipping_method_required'),
'field' => 'shipping_option',
], 422);
}
$fingerprint = (string) ($cart?->meta['checkout_fingerprint'] ?? '');
$data = $request->filled('payment_method')
? ['payment_method' => (string) $request->input('payment_method')]
: [];
try {
$result = $this->checkout->initiatePayment(
$fingerprint,
termsAccepted: true,
policyVersion: (string) config('legal.terms_version'),
data: $data,
);
} catch (FingerprintMismatchException) {
return response()->json(['status' => 'stale', 'message' => __('checkout.page.payment_cart_changed')], 409);
} catch (CartException $e) {
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.payment_incomplete_details'),
'errors' => collect($e->errors()->toArray())->map(fn ($m) => is_array($m) ? ($m[0] ?? null) : $m)->all(),
], 422);
} catch (TermsNotAcceptedException) {
return response()->json(['error' => __('checkout.page.terms_required')], 422);
}
// Pending with no continuation (cash-on-delivery, or any other
// deferred/offline method) means CheckoutService::initiatePayment()
// already created the placed order — money just hasn't changed
// hands yet. Only a Pending WITH a continuation (Stripe's client
// secret) means the shopper still has something to do before the
// order exists as far as the storefront is concerned.
return match (true) {
$result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
$result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale),
$result->status === PaymentResultStatus::Pending => response()->json([
'status' => 'pending',
'clientSecret' => $result->continuation?->value,
]),
default => response()->json([
'status' => 'failed',
'message' => $result->failureReason ?: __('checkout.page.payment_failed'),
'retriable' => $result->retriable,
], 422),
};
}
/**
* Poll target for the 3-D Secure path: has the webhook placed the order yet?
* StripeWebhookController -> handleCallback -> PaymentCaptured ->
* ApplyResolvedPaymentStatus sets placed_at.
*/
public function orderStatus(string $locale): JsonResponse
{
$order = $this->placedOrder();
if (! $order) {
return response()->json(['placed' => false]);
}
session(['checkout.order_id' => $order->id]);
CartSession::forget();
return response()->json(['placed' => true, 'redirect' => route('checkout.confirmation', $locale)]);
}
public function confirmation(string $locale): View|RedirectResponse
{
$orderId = session('checkout.order_id');
$order = $orderId
? Order::with(['lines.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId)
: null;
if (! $order) {
return redirect()->to(route((string) config('checkout.products_route', 'products'), $locale));
}
// Looked up by type rather than a stored relation — the method may since
// have been disabled/deleted, but the order still needs to show what was
// actually used at the time.
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
->first()
?->translate('name');
return view('checkout::confirmation', [
'order' => $order,
'paymentMethodName' => $paymentMethodName,
]);
}
private function orderPlacedResponse(string $locale): JsonResponse
{
if ($order = $this->placedOrder()) {
session(['checkout.order_id' => $order->id]);
}
CartSession::forget();
return response()->json(['redirect' => route('checkout.confirmation', $locale)]);
}
private function placedOrder(): ?Order
{
$cartId = session('checkout.cart_id');
if ($cartId === null) {
return null;
}
return Order::where('cart_id', $cartId)
->whereNotNull('placed_at')
->latest('placed_at')
->first();
}
/**
* Re-resolve shipping options for the cart's current address and keep the
* selection sane: auto-select when exactly one resolves, or carry a
* previous pick forward when it's still among the resolved options.
*
* $previousOption must be captured by the CALLER before setShippingAddress()
* runs — Lunar's AddAddress action always deletes and recreates the
* CartAddress row on every save (see saveAddress()), so by the time this
* runs, $address->shipping_option is unconditionally null regardless of
* what was selected a moment ago. There is nothing meaningful left to read
* off $address itself; $previousOption is the only source of truth for
* "what was chosen before this save wiped the row." show() passes the
* address's own (not-just-wiped) current value, since nothing recreated
* anything in that path.
*
* Always (re-)applies the resolved target via selectShippingOption() rather
* than comparing against the (always-blank, post-recreation) current value
* — the fresh row needs the write regardless of whether the decision
* "which option" actually changed.
*
* @return Collection<int, \Lunar\DataTypes\ShippingOption>
*/
private function syncShipping(Cart $cart, ?string $previousOption): Collection
{
if (! $cart->shippingAddress) {
return collect();
}
$options = $this->checkout->getShippingOptions();
$target = match (true) {
$options->count() === 1 => $options->first()->identifier,
$previousOption !== null && $options->contains(fn ($option) => $option->identifier === $previousOption) => $previousOption,
default => null,
};
if ($target !== null) {
try {
$this->checkout->selectShippingOption($target);
} catch (InvalidShippingOptionException) {
// $target came from $options itself — shouldn't happen, stay defensive
}
}
return $options;
}
/**
* $options === null means "nothing money-relevant changed" — acknowledge the
* save (and any field errors) without re-rendering the shipping options or
* the order summary, so a plain name/phone edit is a cheap round-trip.
*/
private function fragments(?Cart $cart, ?Collection $options, array $errors = []): JsonResponse
{
return response()->json([
'errors' => collect($errors)
->map(fn ($messages) => is_array($messages) ? ($messages[0] ?? null) : $messages)
->all(),
'shippingOptionsHtml' => $options === null ? null : view('checkout::partials.shipping-options', [
'shippingAddress' => $cart?->shippingAddress,
'shippingOptions' => $options,
])->render(),
// Composer (Providers\CheckoutModuleServiceProvider) fills $cart / $lines.
'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(),
]);
}
/**
* Logged-in shopper: fills any BLANK cart address field from the account
* (name, saved default address, phone, email), on every checkout load, so
* an account filled in after checkout started still shows up. Never
* overwrites anything already in the cart.
*
* Company/tax id (and ticking "I want an invoice") only on the first pass
* (meta.account_prefilled): someone who then clears them or unticks the
* box for this order shouldn't get them back on the next reload.
*
* Writes only when something actually changes, so a normal reload costs
* nothing extra.
*/
private function prefillFromAccount(Cart $cart): Cart
{
$user = Auth::user();
$customer = $this->account->customer($user);
$addresses = collect($this->account->addresses($user));
$saved = $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
$firstPass = ! data_get($cart, 'meta.account_prefilled');
$fromAccount = array_filter([
'first_name' => $customer?->first_name ?: $saved?->first_name,
'last_name' => $customer?->last_name ?: $saved?->last_name,
'line_one' => $saved?->line_one,
'city' => $saved?->city,
'state' => $saved?->state,
'postcode' => $saved?->postcode,
'country_id' => $this->storeCountry()?->id ?? $saved?->country_id,
'contact_email' => $user->email,
'contact_phone' => $saved?->contact_phone,
], 'filled');
$invoice = $firstPass
? array_filter([
'company_name' => $customer?->company_name,
'tax_identifier' => $customer?->tax_identifier,
], 'filled')
: [];
$fields = ['first_name', 'last_name', 'company_name', 'tax_identifier', 'line_one', 'city',
'state', 'postcode', 'country_id', 'contact_email', 'contact_phone'];
$fillBlanks = function (?array $current, array $values) {
$current ??= [];
foreach ($values as $key => $value) {
if (blank($current[$key] ?? null)) {
$current[$key] = $value;
}
}
return $current;
};
$billingBefore = $cart->billingAddress?->only($fields);
$billing = $fillBlanks($billingBefore, [...$fromAccount, ...$invoice]);
// Shipping has no company/tax id (same shape saveAddress() writes).
$shipToBilling = (bool) data_get($cart, 'meta.ship_to_billing', true);
$shippingFields = [...array_diff($fields, ['company_name', 'tax_identifier']), 'delivery_instructions'];
$shippingBefore = $cart->shippingAddress?->only($shippingFields);
$shipping = $shipToBilling
? [
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
'delivery_instructions' => $shippingBefore['delivery_instructions'] ?? null,
]
: $fillBlanks($shippingBefore, $fromAccount);
if ($billing != ($billingBefore ?? []) || $shipping != ($shippingBefore ?? [])) {
$this->checkout->setBillingAddress($billing);
$cart = $this->checkout->setShippingAddress($shipping);
}
if ($firstPass) {
$cart->meta = [
...($cart->meta?->toArray() ?? []),
'account_prefilled' => true,
'wants_invoice' => (bool) data_get($cart, 'meta.wants_invoice') || $invoice !== [],
];
$cart->save();
}
return $cart;
}
private function storeCountry(): ?Country
{
$iso3 = config('checkout.store_country_iso3');
if ($iso3 === null) {
return null;
}
return Country::where('iso3', $iso3)->first();
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Core\Cart\Http\Controllers\CartController;
use Modules\Core\Checkout\Http\Controllers\CheckoutController;
/*
* Cart + checkout module routes. The {locale} prefix and `locale`
* middleware (registered by Providers\LocalizationServiceProvider) are
* this module's own convention, not a host one — every action already
* declares $locale as its literal first parameter, per Laravel's
* ControllerDispatcher positional-args behavior.
*
* Loaded from Providers\CheckoutModuleServiceProvider inside the `web`
* middleware group.
*/
Route::prefix('{locale}')
->middleware('locale')
->group(function () {
// No standalone cart page — the drawer (checkout::drawer) is the cart.
Route::get('checkout', [CheckoutController::class, 'show'])
->name('checkout.show');
Route::post('checkout/address', [CheckoutController::class, 'saveAddress'])
->name('checkout.address.save');
Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption'])
->name('checkout.shipping-option.select');
Route::post('checkout/payment-method', [CheckoutController::class, 'selectPaymentMethod'])
->name('checkout.payment-method.select');
Route::post('checkout/place-order', [CheckoutController::class, 'placeOrder'])
->name('checkout.place-order');
Route::get('checkout/order-status', [CheckoutController::class, 'orderStatus'])
->name('checkout.order-status');
Route::get('checkout/confirmation', [CheckoutController::class, 'confirmation'])
->name('checkout.confirmation');
Route::post('cart/lines', [CartController::class, 'add'])
->name('checkout.cart.add');
Route::patch('cart/lines/{line}', [CartController::class, 'updateLine'])
->whereNumber('line')
->name('checkout.cart.update');
Route::delete('cart/lines/{line}', [CartController::class, 'remove'])
->whereNumber('line')
->name('checkout.cart.remove');
Route::post('cart/coupon', [CartController::class, 'applyCoupon'])
->name('checkout.cart.coupon.apply');
Route::delete('cart/coupon', [CartController::class, 'removeCoupon'])
->name('checkout.cart.coupon.remove');
});
+8 -17
View File
@@ -4,15 +4,13 @@ namespace Modules\Core\Command;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Lunar\Models\ProductVariant; use Lunar\Models\ProductVariant;
use Modules\Core\Catalog\Services\SkuBackfillService;
/** /**
* One-off backfill for variants the Shopify import left with a blank SKU — * CLI wrapper (--dry-run, a progress bar) around Catalog\Services\
* not an importer bug, the source CSV rows genuinely had no `Variant SKU` * SkuBackfillService — see that class's own docblock for the actual
* value (see Modules\MigrateImport\Shopify\ShopifyExportImporter) — so * backfill logic, also called automatically after a Shopify import (see
* this synthesizes one instead of re-running the import. Format is * MigrateImport\Jobs\RunMigrateImportJob).
* "SKU-P{product_id}-V{variant_id}": deterministic and guaranteed unique
* without a uniqueness check, since product_id/variant_id already are.
* Only variants with a null `sku` are touched.
*/ */
class BackfillMissingSkusCommand extends Command class BackfillMissingSkusCommand extends Command
{ {
@@ -20,12 +18,11 @@ class BackfillMissingSkusCommand extends Command
protected $description = 'Generate a SKU for every product variant that is missing one'; protected $description = 'Generate a SKU for every product variant that is missing one';
public function handle(): void public function handle(SkuBackfillService $backfill): void
{ {
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
$query = ProductVariant::query()->whereNull('sku'); $total = ProductVariant::query()->whereNull('sku')->count();
$total = $query->count();
if ($total === 0) { if ($total === 0) {
$this->info('No variants are missing a SKU.'); $this->info('No variants are missing a SKU.');
@@ -38,19 +35,13 @@ class BackfillMissingSkusCommand extends Command
$bar = $this->output->createProgressBar($total); $bar = $this->output->createProgressBar($total);
$bar->start(); $bar->start();
$query->chunkById(500, function ($variants) use ($dryRun, $bar) { $backfill->backfill($dryRun, function (ProductVariant $variant, string $sku) use ($dryRun, $bar) {
foreach ($variants as $variant) {
$sku = "SKU-P{$variant->product_id}-V{$variant->id}";
if ($dryRun) { if ($dryRun) {
$this->newLine(); $this->newLine();
$this->line("Variant {$variant->id}: sku => {$sku}"); $this->line("Variant {$variant->id}: sku => {$sku}");
} else {
$variant->update(['sku' => $sku]);
} }
$bar->advance(); $bar->advance();
}
}); });
$bar->finish(); $bar->finish();
+41 -2
View File
@@ -3,8 +3,9 @@
namespace Modules\Core\Command; namespace Modules\Core\Command;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Modules\Core\MigrateImport\ImportSpec; use Lunar\Models\Language;
use Modules\Core\MigrateImport\RunMigrateImportJob; use Modules\Core\MigrateImport\DTOs\ImportSpec;
use Modules\Core\MigrateImport\Jobs\RunMigrateImportJob;
class MigrateImportCommand extends Command class MigrateImportCommand extends Command
{ {
@@ -62,11 +63,29 @@ class MigrateImportCommand extends Command
$credentials = null; $credentials = null;
} }
// Shopify's own product export is a flat CSV — one Title/Body
// (HTML)/etc. column per row, no per-locale columns at all — so
// its text is necessarily written in exactly one language, and
// there is no reliable way to detect which one from the file
// itself. Modules\Core\MigrateImport\Services\ImportLocale::code() used to
// (as its former name, DefaultLocale, admits) assume it always
// matched this store's own Lunar\Models\
// Language::getDefault(), which is often wrong (a store's default
// admin/storefront language and the language a given export
// happens to be written in are two independent facts) — every
// imported product's name/description then saved silently under
// the wrong language, invisible unless that language happened to
// also be selected when viewing/editing the product afterward.
$locale = $source === 'shopify' && $type === 'export'
? $this->askImportLocale()
: null;
$spec = new ImportSpec( $spec = new ImportSpec(
source: $source, source: $source,
type: $type, type: $type,
filePath: $filePath, filePath: $filePath,
credentials: $credentials, credentials: $credentials,
locale: $locale,
); );
RunMigrateImportJob::dispatch($spec); RunMigrateImportJob::dispatch($spec);
@@ -74,6 +93,26 @@ class MigrateImportCommand extends Command
$this->info('Import queued.'); $this->info('Import queued.');
} }
/**
* Choices come from Language::all() — the same list an admin manages
* from the Filament panel (Settings > Languages) — not a hardcoded
* set, so a language this store doesn't have yet simply isn't
* offered here; the hint below says where to add it instead of this
* command silently accepting an arbitrary code Lunar has no row for.
*/
private function askImportLocale(): string
{
$languages = Language::orderBy('default', 'desc')->get(['code', 'name']);
return $this->choice(
"Which language is the export file's own text (product titles, descriptions, etc.) written in?\n".
' (Not necessarily this store\'s default language — the two are independent. '.
"If the language you need isn't listed, add it first from the admin panel under Languages.)",
$languages->mapWithKeys(fn (Language $language) => [$language->code => "{$language->name} ({$language->code})"])->all(),
$languages->first()?->code,
);
}
// Answers are relative to storage/app/private/imports (e.g. "shopify" or // Answers are relative to storage/app/private/imports (e.g. "shopify" or
// "shopify/products_export.csv"); absolute paths are used as-is. A // "shopify/products_export.csv"); absolute paths are used as-is. A
// directory answer picks the first CSV file found inside it. // directory answer picks the first CSV file found inside it.
+213
View File
@@ -0,0 +1,213 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Lunar\Models\CartLine;
use Lunar\Models\Product;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Auth\Services\OtpService;
use Modules\Core\MigrateImport\Models\ImportMapping;
use function Laravel\Prompts\password;
use function Laravel\Prompts\text;
/**
* Irreversibly deletes every Product and everything that only exists
* because of a product — variants, variant prices, product-option value
* assignments, product images/media, product associations, the
* ImportMapping rows tying them back to an external source, product-
* variant CartLine rows (line items only — Cart records themselves are
* left alone), and the Meilisearch product index. Deliberately does NOT
* touch catalog STRUCTURE other products could still reference: ProductOption/
* ProductOptionValue definitions ("Size", "Color" as reusable option
* types), Brands, Collections, Tags, Customer Groups — none of those are
* products, they're config a merchant would otherwise have to rebuild
* from scratch.
*
* Two gates a destructive, whole-catalog, irreversible operation
* warrants — deliberately NOT restricted to non-production on top of
* these; a real, legitimate use case is wiping a client's demo/seed
* catalog on a production database right before real launch, and the OTP
* below already proves the operator has real staff access, not just
* shell access to wherever `php artisan` happens to be runnable:
* 1. An OTP emailed to a real Staff account (reusing Auth\Services\
* OtpService — the exact mechanism admin login already uses).
* 2. Typing the literal product count back, not just "yes" — a plain
* confirm() is too easy to reflexively accept; forcing the operator
* to read and retype the actual number they're about to delete is a
* last check against running this against the wrong environment/
* database by mistake.
*
* Deletes via Eloquent model instances, not DB::table()->delete() —
* Product/ProductVariant use Spatie's InteractsWithMedia (see Lunar\Base\
* Traits\HasMedia), which only cleans up media files/rows on a real model
* `deleted` event, never on a raw query-builder delete.
*/
class WipeCatalogCommand extends Command
{
protected $signature = 'boboko:wipe-catalog {--email= : Staff email to send the confirmation code to}';
protected $description = 'Irreversibly delete every product, variant, and related catalog data';
public function handle(OtpService $otp): int
{
// withTrashed() — a prior soft-delete-only bug in this command
// (fixed in wipe() below) could leave ghost rows a plain count()
// would never see, silently reporting "nothing to do" while they
// sit there breaking other things (e.g. the admin's own global
// search, which assumes every returned product has variants).
$productCount = Product::withTrashed()->count();
if ($productCount === 0) {
$this->info('No products exist — nothing to do.');
return self::SUCCESS;
}
if (! $this->authorize($otp)) {
return self::FAILURE;
}
$this->warn("This will PERMANENTLY delete {$productCount} product(s) and everything that only exists because of them (variants, prices, images, product-option assignments, associations). This cannot be undone.");
$typed = text(label: "Type the product count ({$productCount}) to confirm");
if ($typed !== (string) $productCount) {
$this->error('Count did not match — aborted, nothing was deleted.');
return self::FAILURE;
}
$this->wipe();
$this->info("Deleted {$productCount} product(s) and all related data.");
return self::SUCCESS;
}
private function authorize(OtpService $otp): bool
{
$email = $this->option('email') ?? text(
label: 'Staff email to send a confirmation code to',
validate: fn (string $value) => Staff::where('email', $value)->exists()
? null
: 'No staff account with that email exists.',
);
if (! $otp->generateAndSend($email, purpose: 'wipe-catalog')) {
$this->error('Could not send a confirmation code to that email.');
return false;
}
$this->info("A confirmation code was sent to {$email}.");
$code = password(label: 'Enter the confirmation code');
if ($otp->validate($email, $code) === null) {
$this->error('Invalid or expired code — aborted, nothing was deleted.');
return false;
}
return true;
}
/**
* Every step below goes through a real Eloquent relation, never a raw
* table name — Lunar's own table prefix is configurable
* (config('lunar.database.table_prefix'), applied in BaseModel's
* constructor), so a hardcoded 'lunar_...' string would silently
* no-op on an install using a different one.
*
* Order matters: product_associations and the product/product_option
* pivot have a real FK to `products` but no ON DELETE CASCADE (both
* RESTRICT, Laravel's own default), so they're detached before the
* product/variant rows they reference — deleting a product that
* still has either would throw. ProductVariant's own `prices` (a
* plain morph, HasPrices trait — no FK constraint at all) would
* otherwise silently orphan rather than throw, so it's cleared the
* same way regardless. media_variant and product_option_value_
* product_variant DO cascade at the DB level (see their own
* migrations), so deleting the variant itself is enough for those two.
*
* Deliberately NOT chunkById() — that re-queries "id > lastSeenId"
* every iteration, but deleting rows inside the loop shrinks the
* table out from under it: any product whose id fell in a range
* chunkById() had already stepped past could be silently skipped and
* never actually deleted at all. Caught in practice — the first real
* run of this command left orphaned Media rows (Spatie's own
* deleteAllMedia(), fired from Product's `deleting` event, never ran
* for the skipped products) whose 'image' ImportMapping rows then
* caused a LATER Shopify re-import to silently reuse those now-
* orphaned Media objects instead of importing fresh ones — see
* MigrateImport\Shopify\Services\ShopifyExportImporter::resolveOrImportImage()'s
* own docblock for that half of the same incident. Always re-querying
* the first N remaining rows (never advancing an id cursor) guarantees
* every product is actually visited exactly once, however many are
* deleted out from under the query as it goes.
*/
private function wipe(): void
{
ImportMapping::whereIn('source_type', ['product', 'variant', 'image'])->delete();
// Only the line items — not the parent Cart rows. This command is
// meant for early-stage/setup use where no real customer carts
// matter yet, but a customer's Cart record also anchors their
// session/coupon/address state; deleting it outright is more than
// "the catalog is gone" calls for. Leaving every variant a cart
// line could reference about to be force-deleted below would
// otherwise reproduce the exact storefront crash this step exists
// to prevent: CartLine::purchasable() resolves to null,
// PricingManager::for() throws a TypeError on every page load that
// renders the cart drawer.
CartLine::where('purchasable_type', 'product_variant')->delete();
while (true) {
// withTrashed(): Product/ProductVariant both use SoftDeletes
// — a plain query would stop seeing a product the moment
// forceDelete() below actually removes it, which is fine, but
// WITHOUT withTrashed() here this loop would never even
// fetch a row that a previous, buggy run of this command
// (or any other code) had already soft-deleted without
// force-deleting it. Ghost rows like that are exactly what
// this command exists to remove.
$products = Product::withTrashed()
->with(['variants' => fn ($query) => $query->withTrashed(), 'associations', 'inverseAssociations'])
->limit(100)
->get();
if ($products->isEmpty()) {
break;
}
foreach ($products as $product) {
$product->associations()->delete();
$product->inverseAssociations()->delete();
$product->productOptions()->detach();
foreach ($product->variants as $variant) {
$variant->prices()->delete();
// NOT delete() — Product/ProductVariant both use
// SoftDeletes, and a plain delete() only sets
// deleted_at, leaving the row (and, for Product, its
// media) sitting in the table. This command's whole
// purpose is an irreversible wipe; a soft-deleted
// ghost row is the opposite of that. Caught in
// practice — a prior run's plain delete() left 185
// ghost Product rows with zero real variants, which
// then crashed the admin's own global search
// (Lunar\Admin\Filament\Resources\ProductResource::
// getGlobalSearchResultDetails() assumes
// $record->variants->first() is never null).
$variant->forceDelete();
}
$product->forceDelete();
}
}
Product::removeAllFromSearch();
}
}
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Modules\Core\Customer\Models\Customer;
/**
* Customer-side sibling of Checkout\Events\RecoveryConsentSet — dispatched
* by CustomerAccountService::setRecoveryConsent() every time the account's
* standing promotional/abandoned-cart-recovery opt-in changes, including
* an explicit opt-OUT, not just an opt-in. $consent is the new value,
* already written to Customer::meta by the time this fires. Distinct from
* RecoveryConsentSet, which fires for the current CART's own opt-in
* (CheckoutService::setRecoveryConsent()) — the two write the same meta
* shape onto different models and can fire independently of each other.
*/
class CustomerRecoveryConsentSet
{
public function __construct(
public readonly Customer $customer,
public readonly bool $consent,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Customer\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Customer\Services\CustomerEmailChangeService when
* the requested new email already belongs to a different user — checked
* both up front (request()) and again at confirm() time, since someone
* else could sign up with that address in the window between the two.
*/
class EmailAlreadyTakenException extends RuntimeException
{
public function __construct()
{
parent::__construct('That email address is already in use.');
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Customer\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Customer\Services\CustomerEmailChangeService::
* confirm() for a wrong, expired, or already-burned (too many wrong
* guesses) code — deliberately one exception for all three, the same way
* Auth\Services\UserOtpService::validate() collapses them into a single
* null return, so a caller can't distinguish "wrong code" from "no
* pending change at all" and use that to probe for one.
*/
class InvalidEmailChangeCodeException extends RuntimeException
{
public function __construct()
{
parent::__construct('That code is invalid or has expired.');
}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Customer\Listeners;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Customer\Services\GuestOrderClaimer;
/**
* Registered from Providers\CustomerServiceProvider — UserAuthenticated
* only fires after a valid login code, which is what makes matching
* placed guest orders by email safe (see GuestOrderClaimer's own
* docblock).
*/
class ClaimGuestOrdersOnLogin
{
public function __construct(
private readonly GuestOrderClaimer $claimer,
) {}
public function handle(UserAuthenticated $event): void
{
$this->claimer->claim($event->user);
}
}
@@ -8,6 +8,7 @@ use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted; use Modules\Core\Customer\Events\CustomerAddressDeleted;
use Modules\Core\Customer\Events\CustomerAddressUpdated; use Modules\Core\Customer\Events\CustomerAddressUpdated;
use Modules\Core\Customer\Events\CustomerProfileUpdated; use Modules\Core\Customer\Events\CustomerProfileUpdated;
use Modules\Core\Customer\Events\CustomerRecoveryConsentSet;
use Modules\Core\Logging\ActivityLogService; use Modules\Core\Logging\ActivityLogService;
/** /**
@@ -62,4 +63,21 @@ class LogCustomerAccountActivity implements ShouldQueue
$event->causer, $event->causer,
); );
} }
/**
* CustomerRecoveryConsentSet carries only the new value, not a
* before/after snapshot the way CustomerProfileUpdated does — but
* CustomerAccountService::setRecoveryConsent() only ever dispatches it
* once the value has actually changed, so "old" is trivially the
* opposite of $event->consent.
*/
public function handleRecoveryConsentSet(CustomerRecoveryConsentSet $event): void
{
$this->activityLog->updated(
$event->customer,
['recovery_consent' => ! $event->consent],
['recovery_consent' => $event->consent],
$event->causer,
);
}
} }
@@ -66,6 +66,9 @@ class CustomerDataProvider implements PersonalDataProvider
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'terms_accepted_at' => $user->terms_accepted_at,
'terms_version' => $user->terms_version,
'privacy_policy_version' => $user->privacy_policy_version,
'customers' => $user->customers->map(fn (Customer $customer) => [ 'customers' => $user->customers->map(fn (Customer $customer) => [
'id' => $customer->id, 'id' => $customer->id,
'company_name' => $customer->company_name, 'company_name' => $customer->company_name,
@@ -13,6 +13,7 @@ use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted; use Modules\Core\Customer\Events\CustomerAddressDeleted;
use Modules\Core\Customer\Events\CustomerAddressUpdated; use Modules\Core\Customer\Events\CustomerAddressUpdated;
use Modules\Core\Customer\Events\CustomerProfileUpdated; use Modules\Core\Customer\Events\CustomerProfileUpdated;
use Modules\Core\Customer\Events\CustomerRecoveryConsentSet;
use Modules\Core\Customer\Exceptions\AddressNotFoundException; use Modules\Core\Customer\Exceptions\AddressNotFoundException;
use Modules\Core\Customer\Exceptions\OrderNotFoundException; use Modules\Core\Customer\Exceptions\OrderNotFoundException;
use Modules\Core\Customer\Models\Customer; use Modules\Core\Customer\Models\Customer;
@@ -72,7 +73,7 @@ class CustomerAccountService
]; ];
private const WRITABLE_PROFILE_FIELDS = [ private const WRITABLE_PROFILE_FIELDS = [
'title', 'first_name', 'last_name', 'company_name', 'vat_no', 'title', 'first_name', 'last_name', 'company_name', 'tax_identifier',
]; ];
public function customer(Authenticatable $user): ?Customer public function customer(Authenticatable $user): ?Customer
@@ -235,6 +236,43 @@ class CustomerAccountService
return $customer; return $customer;
} }
/**
* The account's standing "email me a reminder if I don't finish my
* order" opt-in — same meta shape Checkout\Services\CheckoutService::
* setRecoveryConsent() writes on the current CART (recovery_consent,
* recovery_consent_at, recovery_consent_policy_version), written here
* onto the CUSTOMER instead, so it survives across carts/sessions as a
* standing account preference. The two are independent: opting out on
* the customer doesn't retroactively change a cart already opted in,
* and vice versa — a caller that wants both kept in sync (e.g. 3dealer
* applying a customer's standing preference to the current cart too)
* calls both services itself.
*
* A no-op (no write, no event) when $consent already matches what's
* stored — unlike updateProfile()'s address/profile writes, which
* always write and dispatch even when nothing actually changed.
*/
public function setRecoveryConsent(Authenticatable $user, bool $consent): Customer
{
$customer = $this->customerOrFail($user);
if ((bool) data_get($customer->meta, 'recovery_consent') === $consent) {
return $customer;
}
$customer->meta = [
...($customer->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$customer->save();
Event::dispatch(new CustomerRecoveryConsentSet($customer, $consent, $user));
return $customer;
}
/** /**
* @throws LogicException if $user has no paired Customer at all — * @throws LogicException if $user has no paired Customer at all —
* distinct from AddressNotFoundException/OrderNotFoundException * distinct from AddressNotFoundException/OrderNotFoundException
@@ -0,0 +1,163 @@
<?php
namespace Modules\Core\Customer\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserEmailChanged;
use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Auth\Mail\EmailChangeCodeMail;
use Modules\Core\Auth\Mail\EmailChangedNoticeMail;
use Modules\Core\Customer\Exceptions\EmailAlreadyTakenException;
use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
/**
* Changing an account's login email — core's login is passwordless, so
* the email IS the login, and it only ever changes once the shopper has
* proved they can receive mail at the new address (a typo can never lock
* them out of their own account). The pending change (new address, a
* hash of the code, expiry, wrong-guess count) lives on the user's own
* row (see the migration adding pending_email/pending_email_code_hash/
* pending_email_expires_at/pending_email_attempts) — the same convention
* Auth\Services\UserOtpService's otp_code/otp_expires_at/otp_attempts
* already use — rather than the session, since a code arrives by email
* and is often opened on a different device/session than the one that
* requested it; a session-scoped pending change couldn't be confirmed
* from there at all.
*
* Two independent throttles, both configured under core.auth.email_change
* (same shape/reasoning as core.auth.otp): max_attempts caps wrong
* guesses against ONE code; generation_limit/generation_decay_minutes cap
* how often a NEW code can be requested at all.
*/
class CustomerEmailChangeService
{
/**
* @throws OtpThrottledException if this account has requested too
* many codes within core.auth.email_change.generation_decay_minutes
* @throws EmailAlreadyTakenException if $newEmail already belongs to
* a different user
*/
public function request(Authenticatable $user, string $newEmail): void
{
$newEmail = strtolower(trim($newEmail));
if ($user->newQuery()->where('email', $newEmail)->whereKeyNot($user->getKey())->exists()) {
throw new EmailAlreadyTakenException;
}
$limiterKey = $this->generationLimiterKey($user);
$maxGenerations = (int) config('core.auth.email_change.generation_limit', 3);
if (RateLimiter::tooManyAttempts($limiterKey, $maxGenerations)) {
throw new OtpThrottledException(RateLimiter::availableIn($limiterKey));
}
RateLimiter::hit($limiterKey, (int) config('core.auth.email_change.generation_decay_minutes', 10) * 60);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$user->forceFill([
'pending_email' => $newEmail,
'pending_email_code_hash' => Hash::make($code),
'pending_email_expires_at' => now()->addMinutes((int) config('core.auth.email_change.expiry_minutes', 10)),
'pending_email_attempts' => 0,
])->save();
Mail::to($newEmail)->send(new EmailChangeCodeMail($code));
}
/**
* @throws InvalidEmailChangeCodeException for a wrong, expired, or
* already-burned (too many wrong guesses) code, or when there is no
* pending change at all
* @throws EmailAlreadyTakenException if someone else has since signed
* up with the pending address, in the window between request() and
* confirm()
*/
public function confirm(Authenticatable $user, string $code): void
{
$model = $user::class;
// lockForUpdate() + a transaction make the read-check-increment-save
// below atomic across concurrent requests — same reasoning as
// Auth\Services\UserOtpService::validate(), which this mirrors.
$valid = DB::transaction(function () use ($model, $user, $code) {
/** @var Authenticatable $locked */
$locked = $model::whereKey($user->getKey())->lockForUpdate()->first();
if (! $locked->pending_email
|| ! $locked->pending_email_code_hash
|| ! $locked->pending_email_expires_at
|| now()->isAfter($locked->pending_email_expires_at)) {
return false;
}
if (! Hash::check($code, $locked->pending_email_code_hash)) {
$locked->pending_email_attempts++;
if ($locked->pending_email_attempts >= (int) config('core.auth.email_change.max_attempts', 5)) {
$locked->pending_email_code_hash = null;
$locked->pending_email_expires_at = null;
$locked->pending_email_attempts = 0;
}
$locked->save();
return false;
}
return true;
});
if (! $valid) {
throw new InvalidEmailChangeCodeException;
}
$user->refresh();
$newEmail = $user->pending_email;
// Someone may have signed up with this address since request() ran.
if ($user->newQuery()->where('email', $newEmail)->whereKeyNot($user->getKey())->exists()) {
$user->forceFill([
'pending_email' => null,
'pending_email_code_hash' => null,
'pending_email_expires_at' => null,
'pending_email_attempts' => 0,
])->save();
throw new EmailAlreadyTakenException;
}
$oldEmail = $user->email;
$user->forceFill([
'email' => $newEmail,
'email_verified_at' => now(),
'pending_email' => null,
'pending_email_code_hash' => null,
'pending_email_expires_at' => null,
'pending_email_attempts' => 0,
])->save();
RateLimiter::clear($this->generationLimiterKey($user));
// Lets the previous owner notice if someone else changed it from a
// hijacked session.
Mail::to($oldEmail)->send(new EmailChangedNoticeMail($newEmail));
// The code just proved they own the new address too.
app(GuestOrderClaimer::class)->claim($user);
Event::dispatch(new UserEmailChanged($user, $oldEmail));
}
private function generationLimiterKey(Authenticatable $user): string
{
return 'email-change:'.$user->getKey();
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Core\Customer\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Base\LunarUser;
use Lunar\Models\Order;
/**
* Attaches placed guest orders to an account when their billing email
* matches the account's email, case-insensitively. Only ever called right
* after the shopper has proved they own that email — a login code
* (Auth\Events\UserAuthenticated), or the code confirming an email change
* (a consuming app's own email-change flow, e.g. 3dealer's Account\
* EmailController::verify()) — which is what makes matching on email safe.
*
* Only orders with no customer_id AND no user_id are touched: an order
* already attached to any account (guest or otherwise) is left alone.
*/
class GuestOrderClaimer
{
public function claim(Authenticatable&LunarUser $user): int
{
$customer = $user->latestCustomer();
if (! $customer || ! $user->email) {
return 0;
}
return Order::query()
->whereNotNull('placed_at')
->whereNull('customer_id')
->whereNull('user_id')
->whereHas('billingAddress', fn ($query) => $query
->whereRaw('lower(contact_email) = ?', [strtolower($user->email)]))
->update([
'customer_id' => $customer->id,
'user_id' => $user->id,
]);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?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);
}
public function download(string $path, ?string $name = null): StreamedResponse
{
return $this->disk->download($path, $name);
}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\File\Commands;
use Illuminate\Console\Command;
use Modules\Core\File\Services\FileService;
/**
* Generic wrapper around FileService::pruneUnowned() — see that method's
* own docblock for what "unowned" means and why the grace period exists.
* Any caller (3dealer's product custom-field photo uploads today, some
* other future upload feature tomorrow, in this app or another consuming
* app) schedules this once per purpose string it stores files under; this
* command itself has no opinion about what any given purpose means.
*/
class PruneUnownedFilesCommand extends Command
{
protected $signature = 'boboko:file:prune-unowned {purpose} {--hours=24 : Only delete unowned files older than this}';
protected $description = 'Delete unowned files of a given purpose past their grace period';
public function handle(FileService $files): int
{
$purpose = $this->argument('purpose');
$deleted = $files->pruneUnowned($purpose, now()->subHours((int) $this->option('hours')));
$this->info("Deleted {$deleted} unowned file(s) of purpose \"{$purpose}\".");
return self::SUCCESS;
}
}
@@ -0,0 +1,46 @@
<?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, inline (the
* browser renders/previews it directly rather than prompting to save).
*/
public function retrieve(string $path, ?string $name = null): StreamedResponse;
/**
* Same bytes as retrieve(), but as a forced attachment — the browser
* always prompts to save, even for a type it could otherwise preview
* (an image inline in a new tab).
*/
public function download(string $path, ?string $name = null): StreamedResponse;
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\Core\File\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Core\File\Models\File;
use Modules\Core\File\Services\FileService;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Streams a File's bytes straight to the browser — inline by default (a
* browser-previewable type like an image opens/displays directly), or as
* a forced download with ?download=1 (e.g. an explicit "Download" button
* distinct from a thumbnail/preview link pointing at the same file). Only
* reachable via a short-lived signed URL — same auth model as
* Modules\Core\Shipping\Http\Controllers\DownloadShipmentLabelController
* (a valid signature IS the auth check, no separate staff/customer
* session check here) — so any caller that can mint a signed URL to this
* route (the storefront's own custom-field upload flow, or the admin
* order-line display) can hand a viewer a working link without this
* controller knowing anything about who they are or why they're allowed
* to see this particular file.
*
* Looks the File up manually from a plain {file} id rather than relying
* on implicit route-model-binding — registered via loadRoutesFrom() with
* no middleware group (see Providers\FileServiceProvider::boot()), so
* SubstituteBindings never runs and a type-hinted File parameter would
* silently resolve to an empty, non-existent model instead of 404ing.
*/
class DownloadFileController extends Controller
{
public function __invoke(Request $request, int $file, FileService $files): StreamedResponse
{
if (! $request->hasValidSignature()) {
abort(401);
}
$file = File::findOrFail($file);
abort_unless($files->exists($file), 404);
return $request->boolean('download') ? $files->download($file) : $files->retrieve($file);
}
}
@@ -0,0 +1,76 @@
<?php
namespace Modules\Core\File\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Validator;
use Modules\Core\File\Services\FileService;
/**
* The generic "accept an upload, validate it, store it via FileService,
* return its id" flow — what varies per use case (which extensions/sizes
* are acceptable) is deliberately NOT configurable here, and NEVER trusts
* anything the request itself claims about its own limits: a client could
* simply lie about them. purpose()/validationRules() are protected hooks
* a concrete subclass overrides instead — an ordinary PHP method a caller
* writes once per upload policy, not request input, so the actual limit
* enforced is always whatever server-side code says it is. Different
* products can even need different limits (a 3D-print reference photo
* vs. a video upload, say) — that's still a subclass's own store()
* override deciding which rule set applies to a given request, not
* something this base class or a shared config file could express.
*/
abstract class UploadFileController extends Controller
{
/**
* The File row's `purpose` tag (see Models\File) — also the storage
* directory it lands under (FileService::store()'s single $purpose
* param doubles as both).
*/
abstract protected function purpose(): string;
/**
* Laravel validation rules for the incoming request, keyed exactly as
* $request->all() would be. Must include a 'file' rule accepting an
* uploaded file — this class always reads the file from that key.
*
* @return array<string, array<int, mixed>>
*/
abstract protected function validationRules(Request $request): array;
public function store(Request $request, FileService $files): JsonResponse
{
$validator = Validator::make(
$request->all(),
$this->validationRules($request),
$this->validationMessages($request),
$this->validationAttributes($request),
);
if ($validator->fails()) {
return response()->json(['error' => $validator->errors()->first('file')], 422);
}
$file = $files->store($request->file('file'), $this->purpose());
return response()->json(['file_id' => $file->id]);
}
/**
* @return array<string, string>
*/
protected function validationMessages(Request $request): array
{
return [];
}
/**
* @return array<string, string>
*/
protected function validationAttributes(Request $request): array
{
return [];
}
}
@@ -0,0 +1,44 @@
<?php
namespace Modules\Core\File\Listeners;
use Modules\Core\Cart\Events\CartLineAdded;
use Modules\Core\File\Models\File;
use Modules\Core\File\Services\FileService;
/**
* A custom-field photo is uploaded (and gets its own File row, unowned)
* the moment a shopper picks it on the product page — before add-to-cart
* even runs (see 3dealer's CustomFieldUploadController). The storefront's
* add-to-cart request only carries that File's `id` in its custom_fields
* answer (see CartController::customFieldsMeta()); this is what actually
* gives the File an owner, once the real CartLine it belongs to exists.
*
* Listens for Cart\Events\CartLineAdded rather than reaching back into
* the cart after CartService::addLine() returns — that event already
* carries the exact CartLine Lunar resolved/created, with no need to
* re-match it by meta (ambiguous whenever two lines share a purchasable +
* similar meta).
*/
class AttachCustomFieldFileToCartLine
{
public function __construct(
private readonly FileService $files,
) {}
public function handle(CartLineAdded $event): void
{
$fileIds = collect($event->line->meta['custom_fields'] ?? [])
->pluck('file_id')
->filter()
->all();
if ($fileIds === []) {
return;
}
File::query()
->whereIn('id', $fileIds)
->each(fn (File $file) => $this->files->attachOwner($file, $event->line));
}
}
@@ -0,0 +1,62 @@
<?php
namespace Modules\Core\File\Listeners;
use Lunar\Models\OrderLine;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\File\Models\File;
use Modules\Core\File\Services\FileService;
/**
* Lunar\Pipelines\Order\Creation\CreateOrderLines copies a CartLine's
* meta (custom_fields included) onto its new OrderLine verbatim — so an
* order's custom-field file answer keeps the exact same `file_id` its
* originating cart line's meta already had (see 3dealer's CartController::
* customFieldsMeta(), which stores only that id — File is the single
* source of truth for disk/path/name/mime, never duplicated into meta).
* That id is enough to find the File row directly, with no need to match
* an OrderLine back to "the" CartLine it came from.
*
* Re-points ownership (not a copy — the same File row) from whatever
* CartLine owned it to this OrderLine, so a customer's placed order keeps
* its file even after the cart it came from is later cleared (see
* Modules\Core\Cart\Services\CartService, or a checkout-complete cart
* reset) — FileService::pruneUnowned() only ever removes UNOWNED files,
* but a File left pointing at a since-deleted CartLine would be just as
* orphaned in practice; this listener is what keeps that from ever
* happening for a real, placed order.
*
* Listens for Checkout\Events\OrderPlaced, not an OrderLine model event —
* that's the one place in this codebase an order is reliably known to be
* placed exactly once (see that event's own docblock), and it hands over
* the whole Order with every line already loaded.
*/
class TransferCustomFieldFileOwnership
{
public function __construct(
private readonly FileService $files,
) {}
public function handle(OrderPlaced $event): void
{
foreach ($event->order->lines as $line) {
$this->transferLine($line);
}
}
private function transferLine(OrderLine $line): void
{
$fileIds = collect($line->meta['custom_fields'] ?? [])
->pluck('file_id')
->filter()
->all();
if ($fileIds === []) {
return;
}
File::query()
->whereIn('id', $fileIds)
->each(fn (File $file) => $this->files->attachOwner($file, $line));
}
}
+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();
}
}
+135
View File
@@ -0,0 +1,135 @@
<?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.
*
* `purpose` also doubles as the storage directory a file lands under
* (store() passes it straight through as the adapter's own $directory) —
* one string to name both, rather than every caller supplying two
* near-identical values for what's really the same distinction ("which
* kind of upload is this").
*/
class FileService
{
public function __construct(
private readonly Container $container,
) {}
public function store(UploadedFile $file, string $purpose, string $disk = 'local'): File
{
$path = $this->adapter($disk)->store($file, $purpose);
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);
}
/**
* Same file as retrieve(), forced as a download (Content-Disposition:
* attachment) rather than served inline — for a button distinct from
* a preview link/thumbnail pointing at the same File.
*/
public function download(File $file): StreamedResponse
{
return $this->adapter($file->disk)->download($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]);
}
}
+7
View File
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Core\File\Http\Controllers\DownloadFileController;
Route::get('files/{file}/download', DownloadFileController::class)
->name('files.download');
@@ -85,6 +85,180 @@ class StorefrontLabels
'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'], 'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'],
'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'], 'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'],
'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'], 'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'],
// Passwordless login (Auth\Services\UserOtpService)
'auth.login_intro' => [
'en' => 'Enter your email and we\'ll send you a code to sign in — no password needed.',
'el' => 'Γράψε το email σου και θα σου στείλουμε έναν κωδικό σύνδεσης — δεν χρειάζεται κωδικός πρόσβασης.',
],
'auth.email' => ['en' => 'Email', 'el' => 'Email'],
'auth.terms_notice' => [
'en' => 'By continuing, you accept the <a href=":terms">Terms of Use</a> and have read the <a href=":privacy">Privacy Policy</a>.',
'el' => 'Συνεχίζοντας, αποδέχεσαι τους <a href=":terms">Όρους Χρήσης</a> και έχεις διαβάσει την <a href=":privacy">Πολιτική Απορρήτου</a>.',
],
'auth.send_code' => ['en' => 'Send code', 'el' => 'Αποστολή κωδικού'],
'auth.enter_code' => ['en' => 'Enter the code', 'el' => 'Εισάγετε τον κωδικό'],
'auth.code_sent_to' => ['en' => 'We sent a code to', 'el' => 'Στείλαμε έναν κωδικό στο'],
'auth.code' => ['en' => 'Code', 'el' => 'Κωδικός'],
'auth.resend_code' => ['en' => 'Resend code', 'el' => 'Επαναποστολή κωδικού'],
'auth.code_resent' => ['en' => 'A new code was sent.', 'el' => 'Στάλθηκε νέος κωδικός.'],
'auth.change_email' => ['en' => 'Use a different email', 'el' => 'Χρήση διαφορετικού email'],
'auth.invalid_code' => ['en' => 'That code is invalid or has expired.', 'el' => 'Ο κωδικός δεν είναι έγκυρος ή έχει λήξει.'],
'auth.too_many_codes' => [
'en' => 'Too many attempts. Please wait a few minutes and try again.',
'el' => 'Πολλές προσπάθειες. Περίμενε λίγα λεπτά και ξαναδοκίμασε.',
],
// Account profile page (account/show.blade.php)
'account.nav_profile' => ['en' => 'Profile', 'el' => 'Προφίλ'],
'account.nav_orders' => ['en' => 'Orders', 'el' => 'Παραγγελίες'],
'account.nav_wishlist' => ['en' => 'Wishlist', 'el' => 'Λίστα επιθυμιών'],
'account.email_heading' => ['en' => 'Login email', 'el' => 'Email σύνδεσης'],
'account.email_change' => ['en' => 'Change email', 'el' => 'Αλλαγή email'],
'account.details_heading' => ['en' => 'Your details', 'el' => 'Τα στοιχεία σου'],
'account.first_name' => ['en' => 'First name', 'el' => 'Όνομα'],
'account.last_name' => ['en' => 'Last name', 'el' => 'Επώνυμο'],
'account.invoice' => ['en' => 'I need an invoice', 'el' => 'Χρειάζομαι τιμολόγιο'],
'account.company_name' => ['en' => 'Company name', 'el' => 'Επωνυμία εταιρείας'],
'account.tax_identifier' => ['en' => 'Tax ID (VAT)', 'el' => 'ΑΦΜ'],
'account.address_heading' => ['en' => 'Address', 'el' => 'Διεύθυνση'],
'account.line_one' => ['en' => 'Address', 'el' => 'Διεύθυνση'],
'account.city' => ['en' => 'City', 'el' => 'Πόλη'],
'account.postcode' => ['en' => 'Postcode', 'el' => 'Ταχυδρομικός κώδικας'],
'account.state' => ['en' => 'Region', 'el' => 'Περιοχή'],
'account.state_placeholder' => ['en' => 'Select a region', 'el' => 'Επίλεξε περιοχή'],
'account.phone' => ['en' => 'Phone', 'el' => 'Τηλέφωνο'],
'account.emails_heading' => ['en' => 'Emails', 'el' => 'Ειδοποιήσεις email'],
'account.recovery_consent' => [
'en' => 'Email me a reminder if I don\'t finish my order',
'el' => 'Στείλε μου υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
],
'account.save' => ['en' => 'Save changes', 'el' => 'Αποθήκευση'],
'account.saved' => ['en' => 'Your details were saved.', 'el' => 'Τα στοιχεία σου αποθηκεύτηκαν.'],
'account.delete_heading' => ['en' => 'Delete account', 'el' => 'Διαγραφή λογαριασμού'],
'account.delete_text' => [
'en' => 'This permanently deletes your account and personal data. This cannot be undone.',
'el' => 'Αυτό διαγράφει οριστικά τον λογαριασμό και τα προσωπικά σου δεδομένα. Δεν μπορεί να αναιρεθεί.',
],
'account.delete' => ['en' => 'Delete my account', 'el' => 'Διαγραφή λογαριασμού'],
'account.delete_confirm_heading' => ['en' => 'Are you sure?', 'el' => 'Είσαι σίγουρος/η;'],
'account.delete_confirm_text' => [
'en' => 'This cannot be undone. Your account and personal data will be permanently deleted.',
'el' => 'Αυτό δεν μπορεί να αναιρεθεί. Ο λογαριασμός και τα προσωπικά σου δεδομένα θα διαγραφούν οριστικά.',
],
'account.delete_confirm' => ['en' => 'Yes, delete my account', 'el' => 'Ναι, διαγραφή λογαριασμού'],
'account.delete_cancel' => ['en' => 'Cancel', 'el' => 'Ακύρωση'],
'account.deletion_requested' => [
'en' => 'Your account deletion has been requested.',
'el' => 'Ζητήθηκε η διαγραφή του λογαριασμού σου.',
],
// Account email-change flow (account/email.blade.php, account/email-code.blade.php)
'account.email_change_heading' => ['en' => 'Change your email', 'el' => 'Αλλαγή email'],
'account.email_current' => ['en' => 'Your current email is', 'el' => 'Το τρέχον email σου είναι'],
'account.email_new' => ['en' => 'New email', 'el' => 'Νέο email'],
'account.email_new_hint' => [
'en' => 'We\'ll send a code to this address to confirm it\'s yours.',
'el' => 'Θα στείλουμε έναν κωδικό σε αυτή τη διεύθυνση για να επιβεβαιώσουμε ότι είναι δική σου.',
],
'account.email_confirm' => ['en' => 'Confirm', 'el' => 'Επιβεβαίωση'],
'account.email_same' => [
'en' => 'That\'s already your current email.',
'el' => 'Αυτό είναι ήδη το τρέχον email σου.',
],
'account.email_taken' => [
'en' => 'That email address is already in use.',
'el' => 'Αυτή η διεύθυνση email χρησιμοποιείται ήδη.',
],
'account.email_changed' => ['en' => 'Your email was changed.', 'el' => 'Το email σου άλλαξε.'],
// Order history (account/orders/index.blade.php, account/orders/show.blade.php)
'orders.empty' => ['en' => 'You have no orders yet.', 'el' => 'Δεν έχεις παραγγελίες ακόμα.'],
'orders.shop_now' => ['en' => 'Shop now', 'el' => 'Αγόρασε τώρα'],
'orders.date' => ['en' => 'Date', 'el' => 'Ημερομηνία'],
'orders.number' => ['en' => 'Order', 'el' => 'Παραγγελία'],
'orders.status' => ['en' => 'Status', 'el' => 'Κατάσταση'],
'orders.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
'orders.view' => ['en' => 'View', 'el' => 'Προβολή'],
'orders.view_order' => ['en' => 'View order :number', 'el' => 'Προβολή παραγγελίας :number'],
'orders.order_title' => ['en' => 'Order :number', 'el' => 'Παραγγελία :number'],
'orders.back' => ['en' => 'Back to orders', 'el' => 'Πίσω στις παραγγελίες'],
'orders.payment' => ['en' => 'Payment method', 'el' => 'Τρόπος πληρωμής'],
'orders.shipping_method' => ['en' => 'Shipping method', 'el' => 'Τρόπος αποστολής'],
'orders.tracking' => ['en' => 'Tracking', 'el' => 'Παρακολούθηση αποστολής'],
'orders.items' => ['en' => 'Items', 'el' => 'Προϊόντα'],
'orders.subtotal' => ['en' => 'Subtotal', 'el' => 'Μερικό σύνολο'],
'orders.discount' => ['en' => 'Discount', 'el' => 'Έκπτωση'],
'orders.shipping' => ['en' => 'Shipping', 'el' => 'Μεταφορικά'],
'orders.tax' => ['en' => 'Tax', 'el' => 'ΦΠΑ'],
'orders.shipping_to' => ['en' => 'Shipping to', 'el' => 'Αποστολή σε'],
'orders.billing' => ['en' => 'Billing details', 'el' => 'Στοιχεία τιμολόγησης'],
// Contact form (contact.blade.php, ContactController, emails.contact-confirmation)
'contact.sent' => [
'en' => 'Your message was sent — we\'ll get back to you soon.',
'el' => 'Το μήνυμά σου στάλθηκε — θα σου απαντήσουμε σύντομα.',
],
'contact.send_failed' => [
'en' => 'Something went wrong sending your message. Please try again.',
'el' => 'Κάτι πήγε στραβά κατά την αποστολή. Παρακαλούμε δοκίμασε ξανά.',
],
'contact.too_many' => [
'en' => 'Too many messages sent. Please wait a while before trying again.',
'el' => 'Στάλθηκαν πολλά μηνύματα. Περίμενε λίγο πριν ξαναδοκιμάσεις.',
],
'contact.confirmation_subject' => ['en' => 'We received your message', 'el' => 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_preheader' => [
'en' => 'Thanks for reaching out — here\'s a copy of your message.',
'el' => 'Ευχαριστούμε για την επικοινωνία — εδώ είναι ένα αντίγραφο του μηνύματός σου.',
],
'contact.confirmation_heading' => ['en' => 'We received your message', 'el' => 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_body' => [
'en' => 'Thanks for getting in touch. We\'ll reply as soon as we can.',
'el' => 'Ευχαριστούμε που επικοινώνησες μαζί μας. Θα απαντήσουμε το συντομότερο δυνατό.',
],
'contact.confirmation_footer' => [
'en' => 'This is a copy of the message you sent us.',
'el' => 'Αυτό είναι ένα αντίγραφο του μηνύματος που μας έστειλες.',
],
// Product page — custom fields, add-to-cart failure (product/show.blade.php,
// components/product-custom-fields.blade.php)
'product.personalize' => ['en' => 'Personalize', 'el' => 'Εξατομίκευση'],
'product.custom_field_photo_hint' => [
'en' => 'Max file size: :size MB.',
'el' => 'Μέγιστο μέγεθος αρχείου: :size MB.',
],
'product.custom_field_uploading' => ['en' => 'Uploading…', 'el' => 'Μεταφόρτωση…'],
'product.custom_field_upload_failed' => [
'en' => 'Upload failed. Please try again.',
'el' => 'Η μεταφόρτωση απέτυχε. Παρακαλούμε δοκίμασε ξανά.',
],
'product.add_to_cart_failed' => [
'en' => '{0} Sorry, that\'s out of stock|{1} Only :count left in stock|[2,*] Only :count left in stock',
'el' => '{0} Λυπούμαστε, εξαντλήθηκε|{1} Απομένει μόνο :count κομμάτι|[2,*] Απομένουν μόνο :count κομμάτια',
],
// Reviews (components/review-form.blade.php, review-card.blade.php, product/show.blade.php)
'review.rating_required' => ['en' => 'Please select a rating.', 'el' => 'Παρακαλούμε επίλεξε βαθμολογία.'],
'review.reply' => ['en' => 'Reply', 'el' => 'Απάντηση'],
'review.thank_you' => [
'en' => 'Thanks for your review!',
'el' => 'Ευχαριστούμε για την αξιολόγησή σου!',
],
// Wishlist (components/wishlist-button.blade.php, wishlist/guest.blade.php, wishlist/list.blade.php)
'wishlist.add' => ['en' => 'Add to wishlist', 'el' => 'Προσθήκη στη λίστα επιθυμιών'],
'wishlist.remove' => ['en' => 'Remove from wishlist', 'el' => 'Αφαίρεση από τη λίστα επιθυμιών'],
'wishlist.added' => ['en' => 'Added to wishlist', 'el' => 'Προστέθηκε στη λίστα επιθυμιών'],
'wishlist.removed' => ['en' => 'Removed from wishlist', 'el' => 'Αφαιρέθηκε από τη λίστα επιθυμιών'],
'wishlist.empty' => ['en' => 'Your wishlist is empty.', 'el' => 'Η λίστα επιθυμιών σου είναι άδεια.'],
'wishlist.guest_hint' => [
'en' => 'Log in to keep your wishlist across devices.',
'el' => 'Συνδέσου για να κρατήσεις τη λίστα επιθυμιών σου σε όλες τις συσκευές.',
],
'wishlist.remove_named' => ['en' => 'Remove :name from wishlist', 'el' => 'Αφαίρεση :name από τη λίστα επιθυμιών'],
'wishlist.remove_short' => ['en' => 'Remove', 'el' => 'Αφαίρεση'],
]; ];
} }
} }
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Modules\Core\MigrateImport\Contracts;
use Modules\Core\MigrateImport\DTOs\ImportSpec;
interface Importer
{
public function import(ImportSpec $spec): void;
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\MigrateImport\DTOs;
class ImportSpec
{
/**
* @param ?string $locale The language the export file's own text
* (product titles, descriptions, option names, ...) is actually
* written in — asked of the operator at import time (see
* Command\MigrateImportCommand), since an export has no reliable
* way to declare its own language and it does not necessarily match
* this store's Lunar\Models\Language::getDefault(). Null for a
* source/type this doesn't apply to (e.g. an API-based import with
* no free-text file to attribute a single language to).
*/
public function __construct(
public readonly string $source,
public readonly string $type,
public readonly ?string $filePath = null,
public readonly ?array $credentials = null,
public readonly ?string $locale = null,
) {
}
}
-13
View File
@@ -1,13 +0,0 @@
<?php
namespace Modules\Core\MigrateImport;
use Lunar\Models\Language;
class DefaultLocale
{
public static function code(): string
{
return Language::getDefault()->code;
}
}
-14
View File
@@ -1,14 +0,0 @@
<?php
namespace Modules\Core\MigrateImport;
class ImportSpec
{
public function __construct(
public readonly string $source,
public readonly string $type,
public readonly ?string $filePath = null,
public readonly ?array $credentials = null,
) {
}
}
-8
View File
@@ -1,8 +0,0 @@
<?php
namespace Modules\Core\MigrateImport;
interface Importer
{
public function import(ImportSpec $spec): void;
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\Core\MigrateImport\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Modules\Core\MigrateImport\DTOs\ImportSpec;
use Modules\Core\MigrateImport\Services\ImporterFactory;
class RunMigrateImportJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public readonly ImportSpec $spec,
) {
}
/**
* Just hands off to the right Importer and returns — for Shopify,
* that importer dispatches a job batch instead of importing inline
* (see Shopify\ShopifyExportImporter::import()) and this job's own
* work is done the moment that batch is queued, well before the
* batch's jobs actually run. The SKU backfill that used to happen
* right here, after import() returned, now happens in that batch's
* own then() callback instead — running it here would fire before a
* single product had actually been imported.
*/
public function handle(): void
{
Log::info('Import started', ['source' => $this->spec->source, 'type' => $this->spec->type, 'file' => $this->spec->filePath]);
$importer = ImporterFactory::make($this->spec);
$importer->import($this->spec);
Log::info('Import job complete', ['source' => $this->spec->source]);
}
}
@@ -3,7 +3,6 @@
namespace Modules\Core\MigrateImport\JudgeMe\Resolvers; namespace Modules\Core\MigrateImport\JudgeMe\Resolvers;
use Lunar\Models\Product; use Lunar\Models\Product;
use Lunar\Models\Url;
class ProductResolver class ProductResolver
{ {
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\MigrateImport\JudgeMe; namespace Modules\Core\MigrateImport\JudgeMe\Services;
use RuntimeException; use RuntimeException;
@@ -1,12 +1,12 @@
<?php <?php
namespace Modules\Core\MigrateImport\JudgeMe; namespace Modules\Core\MigrateImport\JudgeMe\Services;
use Throwable; use Throwable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Modules\Core\MigrateImport\Importer; use Modules\Core\MigrateImport\Contracts\Importer;
use Modules\Core\MigrateImport\ImportSpec; use Modules\Core\MigrateImport\DTOs\ImportSpec;
use Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver; use Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver;
use Modules\Core\MigrateImport\Models\ImportMapping; use Modules\Core\MigrateImport\Models\ImportMapping;
use Modules\Core\Review\Models\ProductReview; use Modules\Core\Review\Models\ProductReview;
@@ -22,11 +22,24 @@ class JudgeMeExportImporter implements Importer
public function import(ImportSpec $spec): void public function import(ImportSpec $spec): void
{ {
foreach ($this->csvReader->read($spec->filePath) as $row) { $rows = $this->csvReader->read($spec->filePath);
$total = count($rows);
$imported = 0;
Log::info('JudgeMe import: starting', ['total' => $total]);
foreach ($rows as $row) {
$this->importReview($row); $this->importReview($row);
$imported++;
if ($imported % 100 === 0) {
Log::info('JudgeMe import: progress', ['imported' => $imported, 'total' => $total]);
} }
} }
Log::info('JudgeMe import: finished', ['imported' => $imported, 'total' => $total]);
}
private function importReview(array $row): void private function importReview(array $row): void
{ {
$handle = trim((string) ($row['product_handle'] ?? '')); $handle = trim((string) ($row['product_handle'] ?? ''));
-28
View File
@@ -1,28 +0,0 @@
<?php
namespace Modules\Core\MigrateImport;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RunMigrateImportJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public readonly ImportSpec $spec,
) {
}
public function handle(): void
{
$importer = ImporterFactory::make($this->spec);
$importer->import($this->spec);
}
}
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\MigrateImport\Services;
use Lunar\Models\Language;
/**
* The language every resolver in an import run writes product-facing text
* under (name, description, option names, ...) — NOT necessarily this
* store's own Lunar\Models\Language::getDefault(). An export file has no
* reliable way to declare its own language, and a store's default admin/
* storefront language is an independent fact from whatever language a
* given export happens to be written in — conflating the two (this
* class's own former name, DefaultLocale, said as much) used to silently
* save every imported product's text under the wrong language, invisible
* unless that language was also the one selected while viewing/editing
* the product afterward.
*
* Set once per import run from the operator's own answer (see
* Command\MigrateImportCommand::askImportLocale(), threaded through
* ImportSpec::$locale) at the top of Importer::import() — every resolver
* downstream (ProductAttributeResolver, ProductOptionResolver,
* CollectionResolver, ImportAttributeResolver) calls code() exactly as
* before, unaware anything changed. Falls back to Language::getDefault()
* only when nothing was ever set (e.g. an import path with no locale
* concept of its own — JudgeMe's reviews-only export never calls set()).
*/
class ImportLocale
{
private static ?string $code = null;
public static function set(string $code): void
{
self::$code = $code;
}
public static function code(): string
{
return self::$code ?? Language::getDefault()->code;
}
/**
* A static property outlives a single request only inside a
* long-running worker process, where a queued job for one import
* must not leak its locale into the next — called at the end of
* every Importer::import() run, success or failure.
*/
public static function reset(): void
{
self::$code = null;
}
}
@@ -1,10 +1,12 @@
<?php <?php
namespace Modules\Core\MigrateImport; namespace Modules\Core\MigrateImport\Services;
use InvalidArgumentException; use InvalidArgumentException;
use Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter; use Modules\Core\MigrateImport\DTOs\ImportSpec;
use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter; use Modules\Core\MigrateImport\Contracts\Importer;
use Modules\Core\MigrateImport\JudgeMe\Services\JudgeMeExportImporter;
use Modules\Core\MigrateImport\Shopify\Services\ShopifyExportImporter;
class ImporterFactory class ImporterFactory
{ {
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\MigrateImport\Shopify; namespace Modules\Core\MigrateImport\Shopify\DTOs;
class ProductGroup class ProductGroup
{ {
@@ -0,0 +1,65 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Jobs;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Lunar\Models\CollectionGroup;
use Lunar\Models\Currency;
use Modules\Core\MigrateImport\Services\ImportLocale;
use Modules\Core\MigrateImport\Shopify\DTOs\ProductGroup;
use Modules\Core\MigrateImport\Shopify\Services\ShopifyExportImporter;
/**
* One product per job — ShopifyExportImporter::import() used to loop over
* every ProductGroup inline, inside RunMigrateImportJob's own single
* process. A large export (hundreds of products, each with variants,
* media downloads and conversions) grew that one process's memory past
* queue:work's --memory limit (see docker-compose.yml), which kills the
* worker mid-job; the container then restarts and the WHOLE import
* re-runs from row one, never actually finishing. Splitting into one
* job per product means memory resets between jobs (a fresh worker
* process picks up each one), and a restart only repeats whichever
* single product was in flight — ImportMapping's own per-handle
* resolve()/record() already makes re-importing the same product cheap
* and idempotent.
*/
class ImportShopifyProductJob implements ShouldQueue
{
use Batchable;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public function __construct(
private readonly ProductGroup $group,
private readonly string $imagesPath,
private readonly CollectionGroup $collectionGroup,
private readonly Currency $currency,
private readonly ?string $locale,
) {
}
public function handle(ShopifyExportImporter $importer): void
{
if ($this->locale !== null) {
ImportLocale::set($this->locale);
}
try {
$importer->importProduct($this->group, $this->imagesPath, $this->collectionGroup, $this->currency);
} finally {
// A queue worker process outlives a single job — this must
// not leak into whichever product the same worker picks up
// next.
ImportLocale::reset();
}
}
}
@@ -7,7 +7,7 @@ use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Collection; use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup; use Lunar\Models\CollectionGroup;
use Lunar\Models\Product; use Lunar\Models\Product;
use Modules\Core\MigrateImport\DefaultLocale; use Modules\Core\MigrateImport\Services\ImportLocale;
class CollectionResolver class CollectionResolver
{ {
@@ -45,7 +45,7 @@ class CollectionResolver
'collection_group_id' => $group->id, 'collection_group_id' => $group->id,
'attribute_data' => [ 'attribute_data' => [
'name' => new TranslatedText(collect([ 'name' => new TranslatedText(collect([
DefaultLocale::code() => new Text($name), ImportLocale::code() => new Text($name),
])), ])),
], ],
]); ]);
@@ -8,7 +8,7 @@ use Lunar\Models\Attribute;
use Lunar\Models\AttributeGroup; use Lunar\Models\AttributeGroup;
use Lunar\Models\Product; use Lunar\Models\Product;
use Lunar\Models\ProductType; use Lunar\Models\ProductType;
use Modules\Core\MigrateImport\DefaultLocale; use Modules\Core\MigrateImport\Services\ImportLocale;
class ImportAttributeResolver class ImportAttributeResolver
{ {
@@ -40,7 +40,7 @@ class ImportAttributeResolver
[ [
'attribute_group_id' => $group->id, 'attribute_group_id' => $group->id,
'position' => $nextPosition++, 'position' => $nextPosition++,
'name' => [DefaultLocale::code() => $definition['label']], 'name' => [ImportLocale::code() => $definition['label']],
'section' => 'main', 'section' => 'main',
'type' => $definition['type'], 'type' => $definition['type'],
'required' => false, 'required' => false,
@@ -49,7 +49,7 @@ class ImportAttributeResolver
? ['richtext' => false] ? ['richtext' => false]
: [], : [],
'system' => false, 'system' => false,
'description' => [DefaultLocale::code() => ''], 'description' => [ImportLocale::code() => ''],
], ],
); );
@@ -62,7 +62,7 @@ class ImportAttributeResolver
return AttributeGroup::firstOrCreate( return AttributeGroup::firstOrCreate(
['attributable_type' => Product::morphName(), 'handle' => 'import'], ['attributable_type' => Product::morphName(), 'handle' => 'import'],
[ [
'name' => [DefaultLocale::code() => 'Additional Details'], 'name' => [ImportLocale::code() => 'Additional Details'],
'position' => 100, 'position' => 100,
], ],
); );
@@ -6,7 +6,7 @@ use Lunar\FieldTypes\Number;
use Lunar\FieldTypes\Text; use Lunar\FieldTypes\Text;
use Lunar\FieldTypes\TranslatedText; use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\ProductType; use Lunar\Models\ProductType;
use Modules\Core\MigrateImport\DefaultLocale; use Modules\Core\MigrateImport\Services\ImportLocale;
class ProductAttributeResolver class ProductAttributeResolver
{ {
@@ -43,7 +43,7 @@ class ProductAttributeResolver
} }
return new TranslatedText(collect([ return new TranslatedText(collect([
DefaultLocale::code() => new Text($value), ImportLocale::code() => new Text($value),
])); ]));
} }
} }
@@ -5,7 +5,7 @@ namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Lunar\Models\ProductOption; use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue; use Lunar\Models\ProductOptionValue;
use Modules\Core\MigrateImport\DefaultLocale; use Modules\Core\MigrateImport\Services\ImportLocale;
class ProductOptionResolver class ProductOptionResolver
{ {
@@ -24,8 +24,8 @@ class ProductOptionResolver
return ProductOption::query()->firstOrCreate( return ProductOption::query()->firstOrCreate(
['handle' => $handle], ['handle' => $handle],
[ [
'name' => [DefaultLocale::code() => $name], 'name' => [ImportLocale::code() => $name],
'label' => [DefaultLocale::code() => $name], 'label' => [ImportLocale::code() => $name],
'shared' => true, 'shared' => true,
], ],
); );
@@ -46,7 +46,7 @@ class ProductOptionResolver
return $existing ?? ProductOptionValue::create([ return $existing ?? ProductOptionValue::create([
'product_option_id' => $option->id, 'product_option_id' => $option->id,
'name' => [DefaultLocale::code() => $value], 'name' => [ImportLocale::code() => $value],
]); ]);
} }
} }
@@ -1,8 +1,9 @@
<?php <?php
namespace Modules\Core\MigrateImport\Shopify; namespace Modules\Core\MigrateImport\Shopify\Services;
use RuntimeException; use RuntimeException;
use Modules\Core\MigrateImport\Shopify\DTOs\ProductGroup;
class ShopifyCsvReader class ShopifyCsvReader
{ {
@@ -1,9 +1,10 @@
<?php <?php
namespace Modules\Core\MigrateImport\Shopify; namespace Modules\Core\MigrateImport\Shopify\Services;
use Lunar\Models\TaxClass; use Lunar\Models\TaxClass;
use Lunar\Models\ProductOption; use Lunar\Models\ProductOption;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Lunar\Models\Collection; use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup; use Lunar\Models\CollectionGroup;
@@ -12,9 +13,13 @@ use Lunar\Models\Language;
use Lunar\Models\Product; use Lunar\Models\Product;
use Lunar\Models\ProductVariant; use Lunar\Models\ProductVariant;
use Lunar\Models\Url; use Lunar\Models\Url;
use Modules\Core\MigrateImport\ImportSpec; use Modules\Core\Catalog\Services\SkuBackfillService;
use Modules\Core\MigrateImport\Importer; use Modules\Core\MigrateImport\Services\ImportLocale;
use Modules\Core\MigrateImport\DTOs\ImportSpec;
use Modules\Core\MigrateImport\Shopify\DTOs\ProductGroup;
use Modules\Core\MigrateImport\Contracts\Importer;
use Modules\Core\MigrateImport\Models\ImportMapping; use Modules\Core\MigrateImport\Models\ImportMapping;
use Modules\Core\MigrateImport\Shopify\Jobs\ImportShopifyProductJob;
use Modules\Core\MigrateImport\Shopify\Resolvers\AssetResolver; use Modules\Core\MigrateImport\Shopify\Resolvers\AssetResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\BrandResolver; use Modules\Core\MigrateImport\Shopify\Resolvers\BrandResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\CollectionResolver; use Modules\Core\MigrateImport\Shopify\Resolvers\CollectionResolver;
@@ -46,9 +51,21 @@ class ShopifyExportImporter implements Importer
) { ) {
} }
/**
* Dispatches one ImportShopifyProductJob per ProductGroup instead of
* importing them inline — see that job's own docblock for why (a
* single process holding every group in memory for the whole run
* kept exceeding queue:work's --memory limit on a large export,
* which kills the worker mid-run and restarts the entire import from
* scratch). Bus::batch()'s then() is what SkuBackfillService used to
* run right after this loop — now deferred until every product job
* in the batch has actually finished, since dispatching a batch
* itself returns immediately.
*/
public function import(ImportSpec $spec): void public function import(ImportSpec $spec): void
{ {
$groups = $this->csvReader->read($spec->filePath); $groups = $this->csvReader->read($spec->filePath);
$total = count($groups);
$imagesPath = dirname($spec->filePath).'/files'; $imagesPath = dirname($spec->filePath).'/files';
$collectionGroup = CollectionGroup::firstOrCreate( $collectionGroup = CollectionGroup::firstOrCreate(
['handle' => 'shopify'], ['handle' => 'shopify'],
@@ -56,12 +73,30 @@ class ShopifyExportImporter implements Importer
); );
$currency = Currency::getDefault(); $currency = Currency::getDefault();
foreach ($groups as $group) { Log::info('Shopify import: dispatching product jobs', ['total' => $total]);
$this->importProduct($group, $imagesPath, $collectionGroup, $currency);
} $jobs = collect($groups)->map(fn (ProductGroup $group) => new ImportShopifyProductJob(
$group,
$imagesPath,
$collectionGroup,
$currency,
$spec->locale,
))->all();
Bus::batch($jobs)
->name("Shopify import: {$spec->filePath}")
->then(function () use ($total) {
Log::info('Shopify import: all product jobs finished, backfilling missing SKUs', ['total' => $total]);
app(SkuBackfillService::class)->backfill();
Log::info('Shopify import: finished', ['total' => $total]);
})
->catch(function ($batch, $e) {
Log::error('Shopify import: batch failed', ['error' => $e->getMessage()]);
})
->dispatch();
} }
private function importProduct( public function importProduct(
ProductGroup $group, ProductGroup $group,
string $imagesPath, string $imagesPath,
CollectionGroup $collectionGroup, CollectionGroup $collectionGroup,
@@ -100,7 +135,12 @@ class ShopifyExportImporter implements Importer
[ [
'element_type' => $product->getMorphClass(), 'element_type' => $product->getMorphClass(),
'element_id' => $product->id, 'element_id' => $product->id,
'language_id' => Language::getDefault()->id, // ImportLocale, not Language::getDefault() — same
// reasoning as ProductAttributeResolver et al.: this
// product's handle/slug came from the export, written in
// whatever language the operator said the file is in,
// not necessarily this store's own default language.
'language_id' => Language::where('code', ImportLocale::code())->value('id'),
], ],
[ [
'slug' => $group->handle, 'slug' => $group->handle,
@@ -163,6 +203,12 @@ class ShopifyExportImporter implements Importer
$variant->sku = trim((string) ($row['Variant SKU'] ?? '')) ?: null; $variant->sku = trim((string) ($row['Variant SKU'] ?? '')) ?: null;
$variant->stock = (int) ($row['Variant Inventory Qty'] ?? 0); $variant->stock = (int) ($row['Variant Inventory Qty'] ?? 0);
$variant->shippable = filter_var($row['Variant Requires Shipping'] ?? 'true', FILTER_VALIDATE_BOOLEAN); $variant->shippable = filter_var($row['Variant Requires Shipping'] ?? 'true', FILTER_VALIDATE_BOOLEAN);
// Lunar's own column default is 'always' (purchasable regardless of
// stock) — wrong for an imported catalogue, whose Variant Inventory
// Qty is real, meaningful stock data. 'in_stock' makes purchasability
// actually respect it (see Modules\Core\Catalog\Services\
// StockService's own docblock on the three purchasable values).
$variant->purchasable = 'in_stock';
$variant->save(); $variant->save();
ImportMapping::record(self::SOURCE, 'variant', $externalId, $variant); ImportMapping::record(self::SOURCE, 'variant', $externalId, $variant);
@@ -239,7 +285,23 @@ class ShopifyExportImporter implements Importer
$existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId); $existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId);
if ($existing instanceof Media) { // ImportMapping is a durable record of "we already imported this,"
// but the Media row it points at can go stale — e.g. Command\
// WipeCatalogCommand deletes every Product (media included, via
// Spatie's own model-delete cleanup) without knowing this mapping
// exists, since the mapping ISN'T scoped to a single Product to
// clean up alongside it. Re-running an import afterward used to
// trust the cached Media object unconditionally — it still existed
// as a PHP object even though its underlying row (and file) were
// long gone, so every re-imported product silently got zero
// media, no error, no warning. Falls through to a fresh import
// whenever the mapping doesn't resolve to a real, still-attached
// Media row.
if ($existing instanceof Media
&& Media::whereKey($existing->getKey())->exists()
&& $existing->model_type === $product->getMorphClass()
&& (int) $existing->model_id === $product->id
) {
return $existing; return $existing;
} }
@@ -3,22 +3,60 @@
namespace Modules\Core\Order\Filament\Extensions; namespace Modules\Core\Order\Filament\Extensions;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Support\Colors\Color;
use Filament\Support\Exceptions\Halt; use Filament\Support\Exceptions\Halt;
use Filament\Tables\Columns\Layout\Panel;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\HtmlString;
use Lunar\Admin\Support\Extending\BaseExtension; use Lunar\Admin\Support\Extending\BaseExtension;
use Lunar\Models\OrderLine;
use Modules\Core\File\Models\File;
/** /**
* Same fix as OrderActionsExtension, applied to the order lines * extendTable() has two unrelated jobs: the "bulk_refund" toolbar-action
* table's "bulk_refund" toolbar action (Lunar\Admin\...\OrderItemsTable:: * fix (see fixFailureNotification()'s own docblock — a genuine Filament
* getBulkRefundAction()) — see that class's docblock for the underlying * bug), and adding a "Custom Fields" entry to each order line's own
* Filament bug (failureNotification()+failure()+halt() never actually * collapsible details dropdown (Lunar\Admin\...\OrderItemsTable::
* sends the notification, because halt()'s Halt exception is caught before * getOrderLinesTableColumns()'s Panel — the same one already showing
* Filament reaches the code that would send it). * stock level, notes, and the price_breakdowns table) — the shopper's
* answers to Product::$custom_fields (a reference photo, personalization
* text, ...), stored on OrderLine.meta by 3dealer's CartController::
* customFieldsMeta() and, until now, never shown anywhere in the admin.
*
* Finds that Panel via $table->getCollapsibleColumnsLayout() — NOT
* $table->getColumns(), which two earlier attempts at this both reached
* for. HasColumns::pushColumns() flattens every Panel/Split into leaf
* columns at table-build time and stores THAT flat list as
* $this->columns (what getColumns() returns); the original nested
* Panel/Stack objects actually used for rendering are kept separately —
* in $this->columnsLayout for a non-collapsible layout component, or
* $this->collapsibleColumnsLayout for one that IS collapsible (this
* order-lines Panel is, via ->collapsible()). So `$column instanceof
* Panel` over getColumns() can never match anything — Panel/Split
* instances simply never appear in that array at all — and a fix built
* on that check silently mutated nothing. A first attempt building a
* brand new Panel and re-calling $table->columns() on top of the
* existing setup fixed nothing either and instead rendered as a stray
* empty extra column outside the dropdown (caught by actually opening
* the order page). Mutates the found Panel's Stack in place via
* Stack::schema(), the one part of both earlier attempts that actually
* worked once the right object was found.
*
* Its own TextColumn rather than reusing the Panel's existing KeyValue:
* KeyValue's own Blade view HTML-escapes every value ({{ $value }}),
* which can't render a clickable link for a file answer.
*/ */
class OrderItemsTableExtension extends BaseExtension class OrderItemsTableExtension extends BaseExtension
{ {
public function extendTable(Table $table): Table public function extendTable(Table $table): Table
{ {
if ($table->getCollapsibleColumnsLayout() instanceof Panel) {
$this->addCustomFieldsColumn($table->getCollapsibleColumnsLayout());
}
return $table->toolbarActions( return $table->toolbarActions(
array_map( array_map(
fn ($action) => $action instanceof BulkAction && $action->getName() === 'bulk_refund' fn ($action) => $action instanceof BulkAction && $action->getName() === 'bulk_refund'
@@ -29,6 +67,88 @@ class OrderItemsTableExtension extends BaseExtension
); );
} }
private function addCustomFieldsColumn(Panel $panel): void
{
$stack = $panel->getComponents()[0] ?? null;
if ($stack === null) {
return;
}
$stack->schema([
...$stack->getComponents(),
TextColumn::make('custom_fields')
->label('Custom Fields')
->visible(fn (OrderLine $record) => filled($record->meta['custom_fields'] ?? null))
->getStateUsing(fn (OrderLine $record) => $this->renderCustomFields($record))
->html(),
]);
}
/**
* Same table markup/classes as this Panel's own existing KeyValue
* component (Lunar\Admin's price_breakdowns, right above this in the
* dropdown — see lunarpanel::tables.components.key-value) for visual
* consistency, rebuilt here rather than reused: KeyValue's Blade view
* HTML-escapes every value ({{ $value }}), which can't render a
* thumbnail/download link for a file answer.
*/
private function renderCustomFields(OrderLine $record): HtmlString
{
$rows = collect($record->meta['custom_fields'] ?? [])
->map(fn (array $field) => sprintf(
'<tr class="divide-x divide-gray-950/10 dark:divide-white/10"><td class="p-2 font-medium whitespace-nowrap">%s</td><td class="p-2">%s</td></tr>',
e($field['label']),
$field['type'] === 'file' ? $this->fileCell($field) : e($field['value'] ?? ''),
))
->implode('');
return new HtmlString(
'<div class="w-full mt-2 overflow-hidden overflow-x-auto ring-1 ring-inset ring-gray-950/10 dark:ring-white/10 rounded bg-white/70 dark:bg-white/5">'
.'<table class="min-w-full text-xs divide-y divide-gray-950/10 dark:divide-white/10"><tbody class="divide-y divide-gray-950/10 dark:divide-white/10">'
.$rows
.'</tbody></table></div>',
);
}
/**
* A thumbnail (previewable image types only — an inline-signed URL to
* the same File; see FileService::retrieve()) alongside an icon-only
* download link forcing Content-Disposition: attachment (FileService::
* download()) — two separate signed URLs, not one reused with a query
* string appended after signing, since a signature covers the exact
* query parameters present when it was minted.
*/
private function fileCell(array $field): string
{
$file = File::find($field['file_id'] ?? null);
if ($file === null) {
return __('lunarpanel::global.na');
}
$previewUrl = URL::temporarySignedRoute('files.download', now()->addHours(2), ['file' => $file->id]);
$downloadUrl = URL::temporarySignedRoute('files.download', now()->addHours(2), ['file' => $file->id, 'download' => 1]);
$previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
$thumbnail = in_array($file->mime, $previewable, true)
? sprintf(
'<a href="%s" target="_blank" rel="noopener"><img src="%s" alt="" style="width:2.5rem;height:2.5rem;object-fit:cover;border-radius:0.375rem;vertical-align:middle"></a>',
$previewUrl,
$previewUrl,
)
: '';
return sprintf(
'<div style="display:flex;align-items:center;gap:0.5rem">%s<span>%s</span><a href="%s" title="Download" style="color:rgb(%s);display:inline-flex">%s</a></div>',
$thumbnail,
e($file->original_name),
$downloadUrl,
Color::Blue[600],
Blade::render('<x-filament::icon icon="heroicon-o-arrow-down-tray" style="width:1rem;height:1rem"/>'),
);
}
private function fixFailureNotification(BulkAction $action): BulkAction private function fixFailureNotification(BulkAction $action): BulkAction
{ {
$originalAction = $action->getActionFunction(); $originalAction = $action->getActionFunction();
+5
View File
@@ -2,13 +2,18 @@
namespace Modules\Core\Providers; namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Modules\Core\Auth\Events\UserCreated;
use Modules\Core\Auth\Listeners\RecordLegalAcceptanceForNewUser;
use Modules\Core\Command\CreateAdminCommand; use Modules\Core\Command\CreateAdminCommand;
class AuthServiceProvider extends ServiceProvider class AuthServiceProvider extends ServiceProvider
{ {
public function boot(): void public function boot(): void
{ {
Event::listen(UserCreated::class, RecordLegalAcceptanceForNewUser::class);
if ($this->app->runningInConsole()) { if ($this->app->runningInConsole()) {
$this->app->booted(fn () => $this->commands([CreateAdminCommand::class])); $this->app->booted(fn () => $this->commands([CreateAdminCommand::class]));
} }
+55 -4
View File
@@ -5,12 +5,15 @@ namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Lunar\Models\Product; use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Product as ProductContract;
use Lunar\Models\Product as LunarProduct;
use Lunar\Models\ProductOption; use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue; use Lunar\Models\ProductOptionValue;
use Modules\Core\Catalog\Events\ProductDeleted; use Modules\Core\Catalog\Events\ProductDeleted;
use Modules\Core\Catalog\Events\ProductSaved; use Modules\Core\Catalog\Events\ProductSaved;
use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct; use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct;
use Modules\Core\Catalog\Models\Product;
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver; use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
use Modules\Core\Catalog\OptionTypes\ColorOptionType; use Modules\Core\Catalog\OptionTypes\ColorOptionType;
use Modules\Core\Catalog\Services\ProductOptionTypeManager; use Modules\Core\Catalog\Services\ProductOptionTypeManager;
@@ -40,13 +43,55 @@ class CatalogServiceProvider extends ServiceProvider
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value)); ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value)); ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
Product::saved(fn (Product $product) => Event::dispatch(new ProductSaved($product))); // Registered on BOTH classes — Eloquent model events are keyed by
Product::deleted(fn (Product $product) => Event::dispatch(new ProductDeleted($product->id))); // the literal class ::saved()/::deleted() was called on
// (registerModelEvent() uses static::class at registration time),
// not by inheritance, so a listener registered only on one class
// never fires for an instance of the other. Code resolving Product
// through the contract (Filament's own ProductResource, anything
// using app(Contracts\Product::class)) gets the subclass once
// ModelManifest::replace() below takes effect; code that still
// hardcodes `Lunar\Models\Product` directly (e.g. MigrateImport\
// Shopify\ShopifyExportImporter — importing has no reason to need
// the subclass's own custom_fields cast) keeps creating base-class
// instances. Both must dispatch ProductSaved/ProductDeleted, since
// ReindexProductsRecommendingProduct listens to those regardless
// of which path created/updated the product.
$dispatchSaved = fn (LunarProduct $product) => Event::dispatch(new ProductSaved($product));
$dispatchDeleted = fn (LunarProduct $product) => Event::dispatch(new ProductDeleted($product->id));
LunarProduct::saved($dispatchSaved);
LunarProduct::deleted($dispatchDeleted);
Product::saved($dispatchSaved);
Product::deleted($dispatchDeleted);
Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']); Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']);
Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']); Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']);
$this->app->booted(function () { $this->app->booted(function () {
// Deferred to booted() to run after every provider (Lunar's
// own included) has finished its own boot() — matches
// 3dealer's own AppServiceProvider, which registers Customer
// the same way for the same reason.
//
// The first argument MUST be the CONTRACT
// (Lunar\Models\Contracts\Product), not the concrete
// Lunar\Models\Product — HasModelExtending::modelClass()
// (which every Lunar model's __callStatic()/newModelQuery()
// consults to decide "is there a registered replacement for
// me") looks itself up by
// ModelManifest::guessContractClass(static::class), which
// resolves to the CONTRACT interface, then does
// ModelManifest::get($thatContract) — so the manifest must be
// keyed by the contract, or the lookup simply misses and
// silently falls back to the base class. Caught in practice —
// passing the concrete LunarProduct::class here (mirroring
// Modules\Core\Customer\Providers\CustomerServiceProvider's
// own replace() call, which has this exact same bug) left
// Product::modelClass() resolving to Lunar\Models\Product no
// matter what, until this was corrected.
ModelManifest::replace(ProductContract::class, Product::class);
// A full nightly reindex, on top of the per-event reindexing // A full nightly reindex, on top of the per-event reindexing
// above — catches everything event-driven reindexing // above — catches everything event-driven reindexing
// deliberately doesn't cover: a newly-created product not yet // deliberately doesn't cover: a newly-created product not yet
@@ -58,8 +103,14 @@ class CatalogServiceProvider extends ServiceProvider
// documents, so a deploy that changed ProductIndexer's field // documents, so a deploy that changed ProductIndexer's field
// list self-heals here even if `lunar:meilisearch:setup` // list self-heals here even if `lunar:meilisearch:setup`
// wasn't run manually after that deploy. // wasn't run manually after that deploy.
// References the subclass, not 'Lunar\Models\Product' — Scout's
// own reindex loop (Searchable::makeAllSearchable()) queries
// via whatever class name is passed here, so this determines
// which class's casts (custom_fields included) are actually
// applied to $model in ProductIndexer::toSearchableArray()
// during this nightly full reindex.
$this->app->make(Schedule::class) $this->app->make(Schedule::class)
->command('lunar:search:index', ['Lunar\\Models\\Product', '--refresh']) ->command('lunar:search:index', [Product::class, '--refresh'])
->dailyAt('03:00'); ->dailyAt('03:00');
}); });
} }
@@ -0,0 +1,61 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as ViewInstance;
use Modules\Core\Cart\Services\CartService;
/**
* The cart + checkout module — its view/component namespace, its routes,
* and the composer that feeds the always-present cart drawer. Strings
* (__('checkout.cart.*')) are NOT wired up here — same as storefront.*
* elsewhere — they resolve through Lunar's DB-backed translation UI
* (spatie/laravel-translation-loader), seeded by Checkout\Database\Seeders\
* CheckoutTranslationsSeeder rather than shipped as lang/ files.
*
* A consuming app wires this module in with:
* 1. `php artisan vendor:publish --tag=core-checkout-assets` — copies
* resources/js/checkout/** and resources/css/checkout.css into the
* host's own resources/ tree. Vite only ever bundles from a host's
* own resources/ directory, so these are published (an explicit,
* host-owned, re-publishable copy) rather than imported cross-package.
* 2. `import { registerCheckout } from './checkout'` in the host's own
* JS entry point, and a @vite entry for the published checkout.css.
* 3. `@include('checkout::drawer')` in the host's own layout.
* See config/checkout.php for the handful of per-site settings (login
* route, single-country mode, ...) a host is expected to publish and
* override.
*/
class CheckoutModuleServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadViewsFrom(__DIR__.'/../../resources/views/checkout', 'checkout');
Blade::anonymousComponentNamespace('checkout::components', 'checkout');
Route::middleware('web')->group(__DIR__.'/../Checkout/routes/checkout.php');
$this->publishes([
__DIR__.'/../../resources/js/checkout' => resource_path('js/checkout'),
__DIR__.'/../../resources/css/checkout.css' => resource_path('css/checkout.css'),
], 'core-checkout-assets');
// The drawer is rendered on every page (from the layout) and its body
// partial is re-rendered on every cart mutation — both need the current
// cart without a controller in the loop.
View::composer(
['checkout::drawer', 'checkout::partials.cart-body'],
function (ViewInstance $view) {
$service = app(CartService::class);
$cart = $service->current();
$view->with('cart', $cart);
$view->with('lines', $cart ? $service->activeLines($cart) : collect());
},
);
}
}

Some files were not shown because too many files have changed in this diff Show More