32 Commits

Author SHA1 Message Date
arvanitakis 9f30a7324e Feature: Shipment Updates to handle COD 2026-07-19 18:27:24 +03:00
arvanitakis 435a4dd290 Feat: Adding Shippment Tracking 2026-07-19 17:41:09 +03:00
arvanitakis d34e450526 Fix: Move carrier live-pricing choice onto ShippingMethod.charge_by
Reverts the earlier per-rate pricing_mode column in favor of extending Lunar's existing charge_by field (cart_total/weight) with a third "live" option, gated by a SupportsLivePricing capability check on the driver. Adds a shared ResolvesFixedPricing trait so any carrier driver can fall back to Lunar's normal price-break resolution, matching the vendor ShipBy driver's own charge_by handling instead of introducing a separate mechanism. Also fixes an incorrect Get() path in the admin form that silently hid the new "live" option.
2026-07-19 02:24:10 +03:00
arvanitakis 4acabe4185 Feature: Wire up carrier-agnostic shipping admin UI
Registers ShippingServiceProvider (carrier config, driver/fulfillment bindings, Rates page override) and wires the shipping admin surface into CorePlugin: dynamic carrier dropdown on Shipping Method create/edit, a "Create Shipment" order action resolved generically by carrier, and a Pickup Manifests page for carriers that support manifest batching.
2026-07-19 00:52:01 +03:00
arvanitakis 37c2e1194c Feature: Add Box Now locker delivery integration
Box Now rate driver (fixed pricing only — no live pricing API) and fulfillment service (delivery request creation, label printing, cancellation). Unlike ACS, Box Now books courier pickup automatically on delivery request creation, so no manifest/pickup-list step is implemented. Storefront locker selection is not yet built; createShipment() expects the chosen locker's locationId to be supplied by the caller.
2026-07-19 00:51:23 +03:00
arvanitakis 89255687a1 Feature: Add ACS courier integration
ACS rate driver (live price quotes via ACS_Price_Calculation, cached postcode-to-station lookups) and fulfillment service (voucher creation, label printing, end-of-day pickup manifest). Adds a per-rate pricing_mode column so admins can choose live API pricing vs. a fixed price on ACS-driven shipping rates, surfaced via a custom Rates page.
2026-07-19 00:50:51 +03:00
arvanitakis 3599329b57 Feature: Add ManifestResult DTO for carrier manifest batching
Completes the carrier-agnostic shipping abstraction (CarrierFulfillmentInterface, SupportsManifestBatching, Shipment model) with the result type SupportsManifestBatching::issueManifest() returns.
2026-07-19 00:50:15 +03:00
arvanitakis 9618d19cfa Feature: Creating Migration For Adding Pricing Mode to Shipping Rates 2026-07-19 00:42:13 +03:00
arvanitakis 08135c4c8f Feature: Creating Shipping Carrier Contracts 2026-07-16 17:14:41 +03:00
arvanitakis e18b2fa44c Feature: Creating Shipment migration and model 2026-07-16 17:14:23 +03:00
arvanitakis 4c00369005 Bump version to 0.3.0 2026-07-12 05:38:14 +03:00
arvanitakis cf6aa343f1 Feat: Creating Product Indexer to Strip HTML tags from Products 2026-07-12 05:34:43 +03:00
arvanitakis ee9893bd64 Feature: Adding Meilisearch to core 2026-07-12 05:28:23 +03:00
arvanitakis 0524380ab0 Bump version to 0.2.0 2026-07-10 15:21:42 +03:00
arvanitakis ed496716eb Feature: Adding Product Reviews Importer and views 2026-07-10 15:19:48 +03:00
arvanitakis 1d25e0d746 Hotfix: Correctly parsing shopify's url handle 2026-07-10 13:42:10 +03:00
arvanitakis 96ad89b6b8 Bump composer version 2026-07-10 12:51:29 +03:00
arvanitakis c07b36e1f9 Hotfix: Adding countries to lunar install 2026-07-10 12:38:48 +03:00
arvanitakis 2767a4bb69 Hotfix: Overriding Lunar Install command for non-interactive inputs 2026-07-10 12:31:59 +03:00
arvanitakis 1d2a90059d Hotfix: Correcting core version to 0.1.0 2026-07-09 00:56:04 +03:00
arvanitakis 364368ba3d Merge pull request 'Feature: Add Shopify product importer and table-rate shipping; bump to 0.2.0' (#1) from 1811058982446433859-creating-product-importer into master
Reviewed-on: #1
2026-07-08 21:40:15 +00:00
arvanitakis 51c43c11b5 Bump version to 0.2.0 2026-07-09 00:39:30 +03:00
arvanitakis 90ac32094c Feature: Adding Docs, CONTRIBUTE.md 2026-07-09 00:37:32 +03:00
arvanitakis df03866285 Feature: Creating product import resolvers, wiring import job 2026-07-09 00:37:14 +03:00
arvanitakis 825ce885c0 Feature: Adding Migration To Track import mappings 2026-07-08 23:41:23 +03:00
arvanitakis cd9632a6bf Feature: Creating Skeleton for import command, interfaces, shopify importer 2026-07-06 03:42:24 +03:00
arvanitakis 677b240a8d Feature: Adding shipping options to Core 2026-07-06 03:10:34 +03:00
arvanitakis ab213f69ec Feat: Adding Migration for otp to lunar_customers 2026-07-03 17:16:30 +03:00
arvanitakis ebe905acfa Bump composer.json version to 0.0.2
Composer's VCS driver cross-checks the tag against the version field
in composer.json and skips tags that disagree, so this must match
the v0.0.2 tag for it to resolve.
2026-07-03 15:09:28 +03:00
arvanitakis d35dff3731 Fix: remove hardcoded Lucent nav item from CorePlugin
CorePlugin registered a "Lucent" navigation item pointing at /lucent
unconditionally, coupling the core package to a specific CMS that
consuming apps may not install.
2026-07-03 15:08:05 +03:00
arvanitakis ce08287641 Feat: Moving things, updating OTP, cleaning stuff up 2026-07-03 02:15:31 +03:00
arvanitakis 15bff15cad Feat: Adding Stoic, Updating Core Service Provider to declare that the lunar page is called Boboko 2026-07-02 23:48:55 +03:00
97 changed files with 5316 additions and 132 deletions
+63
View File
@@ -0,0 +1,63 @@
# Changelog
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/).
## [0.3.0] - 2026-07-12
### Added
- **Meilisearch product search**: pulled in `lunarphp/search` (Lunar's driver-agnostic search abstraction — `database`/`meilisearch`/`typesense` engines, selectable via Scout's own `SCOUT_DRIVER` config) and `lunarphp/meilisearch`, wiring Meilisearch in as the search engine for products.
- `Search\ProductIndexer` overrides Lunar's own indexer to strip HTML tags from string fields (e.g. `name_en`, `description_en`) before they reach the search index — Lunar's default indexer sends raw attribute HTML straight through, which pollutes relevance ranking and highlighting with markup.
- Meilisearch itself is treated as app-level infrastructure, not a `boboko-core` concern: the actual Meilisearch container, host port, and master key live in each consuming app's own `docker-compose.yml`/`.env` (e.g. `3dealer`), the same way Postgres and Valkey do — `boboko-core` only declares the PHP package dependency and the indexing code.
## [0.2.0] - 2026-07-10
### Added
- **Product reviews** (`Modules\Core\Review`): a new `ProductReview` model + `product_reviews` table (plain, unprefixed — same convention as `import_mappings`), linked to Lunar's `Product` via a `Product::reviews()` macro (registered in `CorePlugin`, since `Lunar\Models\Product` is a vendor model and can't be edited directly).
- **JudgeMe CSV review importer** (`MigrateImport\JudgeMe\JudgeMeExportImporter`), wired into the existing `boboko:migrate:import --source=judgeme --type=export` command: reads a Judge.me review export, resolves each row's `product_handle` to a Lunar product via `Lunar\Models\Url`, and creates/updates `ProductReview` rows idempotently via `import_mappings` (`source=judgeme`, `source_type=review`, keyed on Judge.me's `metaobject_handle`). Rows with no matching product are skipped with a logged warning rather than failing the whole import.
- Review images (`picture_urls` in the CSV) are downloaded and stored as real media via Spatie MediaLibrary (`ProductReview::IMAGES_COLLECTION`), not just linked by URL — consistent with how product images are handled.
- **Admin UI**: a new "Reviews" sub-navigation page on the product edit screen (`Review\Pages\ManageProductReviews`, wired via `Review\Extensions\ProductResourceExtension`), listing rating/title/reviewer with View, Reply, and Delete actions. The Reply action lets staff write/edit a reply directly from the table, setting `replied_at`. The View modal shows full review detail (body, reviewer email, location, source, dates, reply, downloaded images).
### Fixed
- `Shopify\ShopifyExportImporter` never wrote a Lunar `Url` (slug) row for imported products, despite `docs/shopify-import.md` specifying it should — meaning no code outside the importer itself could resolve "which Lunar product has handle X" (only the importer's own private `import_mappings` bookkeeping could). It now creates/updates a default `Url` row (`slug` = Shopify handle) per product on every import, which the new JudgeMe review importer depends on for product resolution.
## [0.1.0] - 2026-07-09
### Added
- **Shipping**: registered Lunar's `lunarphp/table-rate-shipping` plugin (`ShippingPlugin`) directly on `CorePlugin`, so table-rate shipping is available to every consumer app without per-app wiring.
- **Product migration/import framework** (`Modules\Core\MigrateImport`): a source-agnostic pipeline for importing a vendor's product catalog into Lunar.
- `boboko:migrate:import` Artisan command — interactively prompts for source, type (export/API), and credentials or file path, then dispatches the import as a queued job (`RunMigrateImportJob`) on the default queue. The file-path prompt resolves relative to `storage/app/private/imports/`, so answering e.g. `shopify` picks up the first CSV found in `imports/shopify/` automatically.
- `ImportSpec`, `Importer` interface, and `ImporterFactory` (source+type → importer class) as the extension points for future sources (WooCommerce, etc.) and mechanisms (API vs. file export).
- `import_mappings` table + `ImportMapping` model: a polymorphic (source, source_type, external_id) → model mapping used by every resolver to make imports idempotent and safely re-runnable.
- `DefaultLocale` helper wrapping Lunar's `Language::getDefault()->code`, used anywhere a translatable field needs a locale key, instead of assuming `app()->getLocale()` matches Lunar's configured default.
- **Shopify CSV export importer** (`Shopify\ShopifyExportImporter`), the first working source/type combination, verified end-to-end against a real 183-product/693-variant/332-image Shopify export (row counts in the CSV match 1:1 with imported Products/Variants/Media):
- `ShopifyCsvReader` + `ProductGroup` group Shopify's flat, repeated-handle CSV rows into one row-group per product (product row, variant rows, image rows).
- Ten resolvers under `Shopify\Resolvers`, each responsible for idempotently resolving-or-creating one Lunar entity: `TaxClassResolver`, `ProductTypeResolver` (auto-attaches system attributes to new types), `BrandResolver`, `TagResolver`, `CollectionResolver` (multi-level, multi-collection support via `>`-delimited breadcrumbs), `ProductOptionResolver` (dedupes options/values by slugified name so case variants like "Size"/"size" resolve to one row), `AssetResolver` (Spatie MediaLibrary via `Product::addMedia()`, matches local export images by UUID first, filename fallback), `PriceResolver` (minor-unit conversion per currency), `ImportAttributeResolver` and `ProductAttributeResolver` (custom `cost_per_item`/`seo_title`/`seo_description` attributes, field-type-aware `attribute_data` writing).
- `docs/shopify-import.md` — full CSV-to-Lunar field mapping reference and import design notes.
- `docs/lunar.md` — new "Gotchas" section documenting non-obvious Lunar behavior hit while building the importer (table-prefix/nested-set race, required `ProductOption.handle`, per-group `Attribute.position`, etc.).
- `CONTRIBUTE.md` — local dev setup (path-repo + `bin/dc-core.sh`), and the manual DB-verification workflow used to build this feature.
### Fixed
- `ProductOptionResolver` created duplicate `ProductOption`/`ProductOptionValue` rows when the same option or value appeared with different casing across products (e.g. Shopify export rows using both "Size" and "size"), and could create a duplicate value within a single product's own variant rows due to relying on a stale lazy-loaded relation. Both now resolve by normalized (slugified) identity queried fresh from the database.
- `boboko:migrate:import` could dispatch an import job with a blank file path (silent no-op failure) if the file-path prompt was answered empty; it now re-prompts until a valid, existing file is given.
## [0.0.1] - 2026-07-03
First release.
### Added
- OTP-based authentication built around `User` instead of `Customer` (`UserOtpService`, `UserOtpMail`), replacing the earlier customer-scoped OTP flow.
- `UserCreated` event with a `CreateCustomerForUser` listener to provision a Lunar customer automatically when a user is created.
- `UserRelationManager` for managing users from the customer resource in the panel.
- Stoic image UI component (`resources/views/ui/stoic-image.blade.php`) and its YAML-driven config/service (see `Stoic::class`).
- `config/core.php` for module-level configuration.
- `AuthServiceProvider` and `CustomerServiceProvider` now register alongside `CoreServiceProvider`.
- Migrations: add OTP to `users`, drop OTP from Lunar `customers`, drop `password` from `users`, make `name` nullable on `users` and Lunar `customers`.
- `docs/modules.md` documenting module structure.
### Removed
- `CustomerOtpMail` and `CustomerOtpService`, superseded by the user-based OTP flow.
### Dependencies
- Added explicit `symfony/yaml` requirement (used directly by `Stoic::loadConfig()`).
+67
View File
@@ -0,0 +1,67 @@
# Contributing to boboko-core
This is a Composer library, not a runnable app — you can't `php artisan serve` it directly. To develop and verify changes, you need a consumer app wired to a local checkout via a Composer path repository, plus a real database, since a large part of this package (Lunar models, migrations, Filament panel resources) can only be meaningfully verified against a live Lunar install.
## Local dev setup
This works against any consumer app that follows the same convention — `boboko-test`, `boboko-starter`, `boboko-3dealer`, etc. — checked out next to this repo:
```
RadicalElements/
├── boboko-core/ (this repo)
└── boboko-test/ (or boboko-starter, boboko-3dealer, ... — consumer app, Docker-based)
```
Each of these consumer apps ships a `bin/dc-core.sh` helper that wraps the Docker Compose overlay needed to bind-mount a local `boboko-core` checkout into the app container:
```bash
./bin/dc-core.sh exec app <command>
```
This is shorthand for `docker compose -f docker-compose.dev.yml -f docker-compose.core-dev.yml exec app <command>`. Use `./bin/dc-core.sh` for everything below instead of typing the full compose invocation.
1. **Path repository.** In the consumer app's `composer.json`, the `repositories` array needs a path entry pointing at `../boboko-core`. If it only exists in a disabled block (e.g. `_repositories`), move it into the live array.
2. **Relaxed version constraint.** The consumer app's `composer.json` should require `"boboko/core": "0.*"` (not a tight `^0.0.1` caret) — otherwise Composer rejects newer `0.0.x` versions resolved from the path repo.
3. **Bind mount.** The consumer app's `docker-compose.core-dev.yml` overlays `../boboko-core` into the container at `/var/www/boboko-core`, matching where the path repo resolves it relative to `/var/www/html`.
4. **Re-resolve after every change.** Composer's path repo does not hot-reload — after editing anything in `boboko-core` (including adding new files, which need autoload discovery), the container needs to re-run `composer update boboko/core`. In `boboko-test`, the entrypoint does this automatically on every dev boot (see `docker/entrypoint.sh`), so `./bin/dc-core.sh up` alone picks up local core changes. If a consumer app's entrypoint doesn't do this yet, run it manually:
```bash
./bin/dc-core.sh exec app composer update boboko/core --with-all-dependencies
```
Skipping this step is the most common cause of "my change isn't showing up."
## Verifying changes against a real database
There is no automated test suite for this package — too much of Lunar's behavior (table prefixing, nested sets, translatable attributes, Filament panel filters) only breaks in combination, against real Postgres, in a way that's impractical to fake in isolation. Instead, verify changes directly against a consumer app's live database. The practical workflow used throughout this package's `MigrateImport` feature:
**One-off checks**, via `php artisan tinker --execute="..."` in the consumer app's container:
```bash
./bin/dc-core.sh exec app php artisan tinker --execute="
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver;
\$type = (new ProductTypeResolver())->resolve('Test Type');
echo \$type->name . PHP_EOL;
"
```
**Multi-step scripts** (creating related records, checking round-trips), via a raw PHP script bootstrapped like an Artisan command — this avoids tinker's persistent-session quirks and more closely matches how code actually runs in a queued job:
```bash
./bin/dc-core.sh exec app php -r "
require 'vendor/autoload.php';
\$app = require 'bootstrap/app.php';
\$kernel = \$app->make(Illuminate\Contracts\Console\Kernel::class);
\$kernel->bootstrap();
// ... your verification code ...
"
```
**Always clean up fixtures** created this way — either via `DB::statement('DELETE FROM ...')` in the same script (respecting foreign key order — Lunar has cascading relations like `lunar_customer_group_product` that block naive deletes), or leave them if they're harmless and the DB is a disposable dev/test instance.
Non-obvious Lunar bugs/traps hit while building against it are documented in [docs/lunar.md](docs/lunar.md#gotchas), not here — check there before debugging something that looks like a Lunar quirk.
## Code organization
Feature work lives under `src/<Feature>/`, with models in a `Models/` subdirectory (see `src/Auth/Models`, `src/Customer/Models`, `src/MigrateImport/Models`). Source-specific importer logic is grouped by source name (e.g. `src/MigrateImport/Shopify/`), with resolver classes (one responsibility each — resolve-or-create a single Lunar entity) grouped further under `Resolvers/`.
+2
View File
@@ -122,3 +122,5 @@ php artisan migrate
- [`docs/otp-auth.md`](docs/otp-auth.md) — OTP authentication flow
- [`docs/activity-log.md`](docs/activity-log.md) — Activity logging
- [`docs/lunar.md`](docs/lunar.md) — Lunar framework reference
- [`docs/notifications.md`](docs/notifications.md) — Notification registry
- [`docs/modules.md`](docs/modules.md) — Module architecture, Customer/User pairing, provider registration pitfalls
+10 -3
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "1.0.0",
"version": "0.3.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -12,7 +12,11 @@
"php": "^8.5",
"lunarphp/lunar": "1.3.0",
"laravel/framework": "^12.0",
"laravel/tinker": "^3.0"
"laravel/tinker": "^3.0",
"symfony/yaml": "^7.0",
"lunarphp/table-rate-shipping": "^1.3",
"lunarphp/search": "*",
"lunarphp/meilisearch": "*"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -27,7 +31,10 @@
"extra": {
"laravel": {
"providers": [
"Modules\\Core\\Providers\\CoreServiceProvider"
"Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider"
]
}
},
+19
View File
@@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Auto-create Customer for User
|--------------------------------------------------------------------------
|
| When enabled, creating a User automatically creates and attaches a bare
| Customer record (storefront passwordless-signup convention). Shops that
| drive the relationship the other way around (creating a Customer first,
| then a User for portal login) should set this to false.
|
*/
'auto_create_customer_for_user' => true,
];
+210
View File
@@ -0,0 +1,210 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Search Engine
|--------------------------------------------------------------------------
|
| This option controls the default search connection that gets used while
| using Laravel Scout. This connection is used when syncing all models
| to the search service. You should adjust this based on your needs.
|
| Supported: "algolia", "meilisearch", "typesense",
| "database", "collection", "null"
|
*/
'driver' => env('SCOUT_DRIVER', 'collection'),
/*
|--------------------------------------------------------------------------
| Index Prefix
|--------------------------------------------------------------------------
|
| Here you may specify a prefix that will be applied to all search index
| names used by Scout. This prefix may be useful if you have multiple
| "tenants" or applications sharing the same search infrastructure.
|
*/
'prefix' => env('SCOUT_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Queue Data Syncing
|--------------------------------------------------------------------------
|
| This option allows you to control if the operations that sync your data
| with your search engines are queued. When this is set to "true" then
| all automatic data syncing will get queued for better performance.
|
*/
'queue' => env('SCOUT_QUEUE', false),
/*
|--------------------------------------------------------------------------
| Database Transactions
|--------------------------------------------------------------------------
|
| This configuration option determines if your data will only be synced
| with your search indexes after every open database transaction has
| been committed, thus preventing any discarded data from syncing.
|
*/
'after_commit' => false,
/*
|--------------------------------------------------------------------------
| Chunk Sizes
|--------------------------------------------------------------------------
|
| These options allow you to control the maximum chunk size when you are
| mass importing data into the search engine. This allows you to fine
| tune each of these chunk sizes based on the power of the servers.
|
*/
'chunk' => [
'searchable' => 500,
'unsearchable' => 500,
],
/*
|--------------------------------------------------------------------------
| Soft Deletes
|--------------------------------------------------------------------------
|
| This option allows to control whether to keep soft deleted records in
| the search indexes. Maintaining soft deleted records can be useful
| if your application still needs to search for the records later.
|
*/
'soft_delete' => false,
/*
|--------------------------------------------------------------------------
| Identify User
|--------------------------------------------------------------------------
|
| This option allows you to control whether to notify the search engine
| of the user performing the search. This is sometimes useful if the
| engine supports any analytics based on this application's users.
|
| Supported engines: "algolia"
|
*/
'identify' => env('SCOUT_IDENTIFY', false),
/*
|--------------------------------------------------------------------------
| Algolia Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Algolia settings. Algolia is a cloud hosted
| search engine which works great with Scout out of the box. Just plug
| in your application ID and admin API key to get started searching.
|
*/
'algolia' => [
'id' => env('ALGOLIA_APP_ID', ''),
'secret' => env('ALGOLIA_SECRET', ''),
'index-settings' => [
// 'users' => [
// 'searchableAttributes' => ['id', 'name', 'email'],
// 'attributesForFaceting'=> ['filterOnly(email)'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Meilisearch Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Meilisearch settings. Meilisearch is an open
| source search engine with minimal configuration. Below, you can state
| the host and key information for your own Meilisearch installation.
|
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
*/
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
// 'users' => [
// 'filterableAttributes'=> ['id', 'name', 'email'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Typesense Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Typesense settings. Typesense is an open
| source search engine using minimal configuration. Below, you will
| state the host, key, and schema configuration for the instance.
|
*/
'typesense' => [
'client-settings' => [
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
'nodes' => [
[
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
],
'nearest_node' => [
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
],
// 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
'model-settings' => [
// User::class => [
// 'collection-schema' => [
// 'fields' => [
// [
// 'name' => 'id',
// 'type' => 'string',
// ],
// [
// 'name' => 'name',
// 'type' => 'string',
// ],
// [
// 'name' => 'created_at',
// 'type' => 'int64',
// ],
// ],
// 'default_sorting_field' => 'created_at',
// ],
// 'search-parameters' => [
// 'query_by' => 'name'
// ],
// ],
],
'import_action' => env('TYPESENSE_IMPORT_ACTION', 'upsert'),
],
];
+50
View File
@@ -0,0 +1,50 @@
<?php
/*
|--------------------------------------------------------------------------
| ACS Courier credentials
|--------------------------------------------------------------------------
|
| ACS requires two credential mechanisms simultaneously: an AcsApiKey
| HTTP header (gates the REST gateway itself) and four account fields
| (Company_ID/Company_Password/User_ID/User_Password) sent in every
| request body. Both are supplied by ACS when your account is set up.
|
| Set these via environment variables — never commit real values.
|
| ACS_BASE_URL Root REST endpoint (unversioned, single URL for
| every ACSAlias call).
| ACS_API_KEY The AcsApiKey header value.
| ACS_COMPANY_ID Company_ID body field.
| ACS_COMPANY_PASSWORD Company_Password body field.
| ACS_USER_ID User_ID body field.
| ACS_USER_PASSWORD User_Password body field.
| ACS_BILLING_CODE Your ACS credit/billing code, used for price
| calculation and voucher creation.
| ACS_SENDER_* Static sender details reused on every voucher.
|
*/
return [
'base_url' => env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'),
'api_key' => env('ACS_API_KEY'),
'company_id' => env('ACS_COMPANY_ID'),
'company_password' => env('ACS_COMPANY_PASSWORD'),
'user_id' => env('ACS_USER_ID'),
'user_password' => env('ACS_USER_PASSWORD'),
'billing_code' => env('ACS_BILLING_CODE'),
'sender' => [
'name' => env('ACS_SENDER_NAME'),
'address' => env('ACS_SENDER_ADDRESS'),
'zip_code' => env('ACS_SENDER_ZIP'),
'phone' => env('ACS_SENDER_PHONE'),
],
'timeout' => env('ACS_HTTP_TIMEOUT', 10),
];
+47
View File
@@ -0,0 +1,47 @@
<?php
/*
|--------------------------------------------------------------------------
| Box Now credentials
|--------------------------------------------------------------------------
|
| Box Now uses OAuth2 client-credentials: exchange BOXNOW_CLIENT_ID /
| BOXNOW_CLIENT_SECRET for a Bearer access token (POST /auth-sessions,
| ~1hr expiry), then attach it as an Authorization header on every call.
| Unlike ACS, there is no separate per-request credential body — the
| token alone authorizes all calls once obtained.
|
| Set these via environment variables — never commit real values.
|
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
| lookups (Box Now recommends this over the main
| base URL for those two calls specifically).
| BOXNOW_CLIENT_ID OAuth2 client id.
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
| the pickup origin on every delivery request.
| BOXNOW_SENDER_* Static sender contact details reused on every
| delivery request.
|
*/
return [
'base_url' => env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'),
'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'),
'client_id' => env('BOXNOW_CLIENT_ID'),
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
'sender' => [
'name' => env('BOXNOW_SENDER_NAME'),
'email' => env('BOXNOW_SENDER_EMAIL'),
'phone' => env('BOXNOW_SENDER_PHONE'),
],
'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10),
];
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_customers', function (Blueprint $table) {
$table->string('otp_code', 6)->nullable()->after('meta');
$table->timestamp('otp_expires_at')->nullable()->after('otp_code');
});
}
public function down(): void
{
Schema::table('lunar_customers', function (Blueprint $table) {
$table->dropColumn(['otp_code', 'otp_expires_at']);
});
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('otp_code', 6)->nullable()->after('password');
$table->timestamp('otp_expires_at')->nullable()->after('otp_code');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['otp_code', 'otp_expires_at']);
});
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_customers', function (Blueprint $table) {
$table->dropColumn(['otp_code', 'otp_expires_at']);
});
}
public function down(): void
{
Schema::table('lunar_customers', function (Blueprint $table) {
$table->string('otp_code', 6)->nullable()->after('meta');
$table->timestamp('otp_expires_at')->nullable()->after('otp_code');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('password');
});
Schema::dropIfExists('password_reset_tokens');
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('password')->after('email_verified_at');
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('name')->nullable()->change();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('name')->nullable(false)->change();
});
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_customers', function (Blueprint $table) {
$table->string('first_name')->nullable()->change();
$table->string('last_name')->nullable()->change();
});
}
public function down(): void
{
Schema::table('lunar_customers', function (Blueprint $table) {
$table->string('first_name')->nullable(false)->change();
$table->string('last_name')->nullable(false)->change();
});
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('import_mappings', function (Blueprint $table) {
$table->id();
$table->string('source');
$table->string('source_type');
$table->string('external_id');
$table->morphs('model');
$table->timestamps();
$table->unique(['source', 'source_type', 'external_id']);
});
}
public function down(): void
{
Schema::dropIfExists('import_mappings');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('product_reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained(config('lunar.database.table_prefix').'products');
$table->string('title')->nullable();
$table->text('body')->nullable();
$table->unsignedTinyInteger('rating');
$table->timestamp('reviewed_at')->nullable();
$table->string('reviewer_name')->nullable();
$table->string('reviewer_email')->nullable();
$table->text('reply')->nullable();
$table->timestamp('replied_at')->nullable();
$table->string('location')->nullable();
$table->string('source')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('product_reviews');
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shipments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained(config('lunar.database.table_prefix').'orders');
$table->string('carrier');
$table->string('tracking_reference')->unique();
$table->string('parent_reference')->nullable();
$table->timestamp('label_printed_at')->nullable();
$table->string('manifest_reference')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('shipments');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shipment_info', function (Blueprint $table) {
$table->id();
$table->foreignId('shipment_id')->constrained('shipments')->cascadeOnDelete();
$table->string('status');
$table->string('carrier_status')->nullable();
$table->text('message')->nullable();
$table->string('location')->nullable();
$table->timestamp('occurred_at');
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('shipment_info');
}
};
+15
View File
@@ -1191,3 +1191,18 @@ php artisan vendor:publish --tag=lunar.migrations # publish migrations
php artisan scout:import "Lunar\Models\Product" # index products
php artisan scout:flush "Lunar\Models\Order" # flush order index
```
---
## Gotchas
Real bugs/traps hit while building against Lunar in this package — not obvious from reading Lunar's source in isolation.
- **`Lunar\Base\BaseModel` prefixes table names at construction time.** Mass-assigning a `parent_id` on a model using `kalnoy/nestedset`'s `NodeTrait` (e.g. `Collection`) triggers a mutator that queries the database *before* the table prefix is applied, throwing "relation does not exist". Use `appendToNode()`/`saveAsRoot()` instead of setting `parent_id` directly. See `MigrateImport\Shopify\Resolvers\CollectionResolver` for the pattern.
- **Don't trust a schema read from memory — re-check the actual migration/model file.** `products.brand` was assumed to be a plain string column based on an earlier read; it's actually `brand_id`, a real FK to a `Brand` model. Verify column names against the live `Schema::getColumnListing()` or the actual migration file, not recollection.
- **Lunar's default `Language` may not be `en`.** Don't hardcode `app()->getLocale()` for `TranslatedText`/translatable fields — use `Lunar\Models\Language::getDefault()->code` (wrapped here as `MigrateImport\DefaultLocale::code()`). A mismatch means data saves under the wrong locale key and silently doesn't render in the panel.
- **`attribute_data` is not a free-form array.** Values must be `Lunar\Base\FieldType` instances (`Text`, `TranslatedText`, `Number`, etc.), and the handle must be mapped to the product's `ProductType` via `mappedAttributes()`/`productAttributes()` — otherwise the value is silently dropped or won't render in the panel.
- **New `ProductType`s start with zero mapped attributes.** Creating one via `ProductType::create()` alone means `name`/`description` won't work until you `attach()` the existing system attributes to it.
- **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness.
- **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead.
- **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken.
+423
View File
@@ -0,0 +1,423 @@
# Module Architecture
This project uses standalone Composer packages for shared functionality, pulled into each e-shop app as regular dependencies. `boboko/core` (this repo) is the foundational module — every e-shop requires it. Other modules (e.g. `boboko/appointments`) require `boboko/core` in turn and layer additional, optional functionality on top.
Unlike an in-repo `modules/` directory, each module here lives in **its own repository** from day one. An app pulls it in via Composer, either from a `path` repository (local development, symlinked) or a `vcs`/Packagist reference (plain dev and production).
---
## Directory Structure
A module repository (e.g. `boboko-core`) is a self-contained Composer package:
```
boboko-core/ (this repo)
composer.json
config/
core.php
database/migrations/
resources/
views/
logos/
js/
docs/
src/
Auth/
Events/
Extensions/
Filament/Pages/
Mail/
Models/
Services/
Customer/
Extensions/
Listeners/
Models/
RelationManagers/
Command/
Providers/
CoreServiceProvider.php ← entry point, registers itself + config/migrations/assets
AuthServiceProvider.php ← staff/admin auth wiring not covered elsewhere
CustomerServiceProvider.php ← ModelManifest + Customer↔User event wiring
Stoic/
```
The e-shop app (`boboko-test`) consumes it as `vendor/boboko/core`:
```
boboko-test/
app/
Models/
Customer.php ← app-level model, extends Modules\Core\Customer\Models\Customer
User.php ← app-level model, dispatches Modules\Core\Auth\Events\UserCreated
Staff.php ← app-level model, extends Modules\Core\Auth\Models\Staff
Lunar/
Extensions/ ← app's own Filament resource extensions (source of truth, wired in PanelServiceProvider)
Pages/ ← app's own Create/Edit page overrides (event dispatch timing, prefill, etc.)
Providers/
AppServiceProvider.php
PanelServiceProvider.php ← all LunarPanel wiring for this app
EventServiceProvider.php ← all Event::listen registrations for this app
config/
core.php ← published from boboko-core, per-app overrides (e.g. auto_create_customer_for_user)
modules/
Appointments/ ← a second module, symlinked/required the same way as core
```
Within a module, organise by concern rather than by type (`Auth/`, `Customer/`, not `Models/`, `Services/` at the top level).
---
## Pulling In a Module
**In the app's root `composer.json`:**
```json
"require": {
"boboko/core": "^1.0"
},
"repositories": [
{
"type": "vcs",
"url": "https://code.radical-elements.com/boboko/core.git"
}
]
```
This is the **default** — a fresh `composer install` never depends on a local checkout existing.
**When developing the module itself alongside the app**, swap the `repositories` entry for a `path` repo pointing at a sibling checkout:
```json
"repositories": [
{
"type": "path",
"url": "../boboko-core",
"options": { "symlink": true }
}
]
```
This is a **manual, deliberate swap** of `composer.json` — not automated, since Composer has no clean built-in mechanism to merge an additional repository on top of the main list per-environment (`composer.local.json` is not auto-merged in Composer 2.x; `COMPOSER_REPOSITORIES` does not merge either — both were tested and ruled out). Swap it back to `vcs` before committing, so CI/prod builds never require `../boboko-core` to exist.
### Docker Compose: the local-core mount
`docker-compose.dev.yml` does **not** mount `../boboko-core` by default — normal app development never needs the sibling checkout present. A separate override file adds just that mount:
```
docker-compose.core-dev.yml ← adds ../boboko-core:/var/www/boboko-core to app/queue/scheduler
```
```bash
# normal app dev
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# developing core locally (also requires the composer.json path-repo swap above)
docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.core-dev.yml up
# or: bin/dc-core.sh up
```
Docker Compose merges `volumes:` lists additively across `-f` files, so the override only needs to declare the one extra mount per service.
---
## Creating a New Module
**1. Create the repository and `composer.json`:**
```json
{
"name": "boboko/your-module",
"description": "What it does",
"type": "library",
"version": "1.0.0",
"require": {
"boboko/core": "^1.0"
},
"autoload": {
"psr-4": {
"Modules\\YourModule\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Modules\\YourModule\\Providers\\YourModuleServiceProvider"
]
}
}
}
```
**2. Create the service provider:**
```php
namespace Modules\YourModule\Providers;
use Illuminate\Support\ServiceProvider;
class YourModuleServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void
{
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'your-module');
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
}
}
```
**Register every provider the module needs in `extra.laravel.providers`.** A module can define more than one provider (e.g. `CoreServiceProvider`, `AuthServiceProvider`, `CustomerServiceProvider`) — Composer's package auto-discovery only boots providers actually listed here. A provider class that exists but isn't listed silently never runs; this has bitten this project before (see "Provider Registration Pitfalls" below).
**3. Require it in the app's `composer.json`**, same as any other module (see "Pulling In a Module" above).
Then run `composer update boboko/your-module`.
---
## Provider Registration Pitfalls
Composer's Laravel package auto-discovery only boots providers explicitly listed in `extra.laravel.providers`. It is easy to write a second/third provider in a module (e.g. splitting concerns into `AuthServiceProvider`, `CustomerServiceProvider`) and forget to add it to that list — the class compiles fine, autoloads fine, and simply never boots. Symptoms are subtle: `ModelManifest::replace()` never runs, event listeners never attach, and nothing errors — code just silently does nothing.
**Before assuming a provider's `boot()` logic is live, check `vendor/composer/installed.json`'s `extra.laravel.providers` for the package**, not just `composer.json` (a local `path`-repo package's cached `extra` metadata does not refresh on file edits alone — `composer dump-autoload` is not enough; a full `composer update <package>` is required to re-read a changed `composer.json`, and lock-file version conflicts can block that update).
### Duplicate registration between module and app
When a module ships a provider that overlaps with app-level Filament wiring (e.g. both independently call `LunarPanel::extensions([...])` for the same resource, or both register the same model-created listener), only one should survive. Prefer whichever is **proven to run** in production (usually the app-level `PanelServiceProvider`/`CorePlugin` wiring, since that's what's actually referenced from the panel registration) and strip the duplicate from the module provider — don't leave both, since duplicate `created`/`updated` listeners fire twice (e.g. sending two invite emails).
---
## Extending the Staff Model via Traits
Modules never define their own `Staff` model. Instead, each module ships a trait with its behaviour, and `app/Models/Staff.php` opts in to whatever the app needs.
**1. Define a trait in your module:**
```php
namespace Modules\YourModule\Concerns;
trait HasYourFeature
{
public function yourRelationship(): HasMany
{
return $this->hasMany(YourModel::class);
}
}
```
**2. Use it in `app/Models/Staff.php`:**
```php
namespace App\Models;
use Modules\Core\Auth\Models\Staff as CoreStaff;
use Zap\Models\Concerns\HasSchedules;
use Modules\YourModule\Concerns\HasYourFeature;
class Staff extends CoreStaff
{
use HasSchedules;
use HasYourFeature;
}
```
The app decides which traits to compose — modules have no knowledge of each other.
---
## Model Inheritance Chain
```
Lunar\Admin\Models\Staff ← Lunar base (auth, roles, Filament)
└── Modules\Core\Auth\Models\Staff ← OTP fields
└── App\Models\Staff ← app model, composes all traits
Lunar\Models\Customer ← Lunar base (buyer, addresses, orders)
└── Modules\Core\Customer\Models\Customer ← full_name accessor, title suppressed
└── App\Models\Customer ← app model, emails()/phones(), Elorus fields
```
Modules never extend each other's `Staff`/`Customer`. If a module needs staff or customer behaviour, it ships a trait, or (for core-generic behaviour) it lives directly in the module's own model, which the app then extends.
**`Customer` is not Authenticatable.** Auth is handled entirely by `App\Models\User` (see "Customer/User Pairing" below) — this changed during this project; earlier revisions made `Customer` implement `Authenticatable` directly, which has since been reverted.
---
## Customer/User Pairing
Two independent Laravel models exist per person: `App\Models\User` (the only `Authenticatable` entity, `web` guard) and `App\Models\Customer` (Lunar's buyer record — addresses, orders, no login). They are linked via Lunar's own `customer_user` pivot table (`$customer->users()` / `$user->customers()` / `$user->latestCustomer()`), not a direct foreign key — this is Lunar's native relationship shape, not something this project invented.
**Which direction creates which depends on the app:**
- **Generic e-shop flow (core's default, `config('core.auto_create_customer_for_user')`, default `true`):** a storefront visitor supplies only an email (Shopify-style passwordless). `Modules\Core\Auth\Services\UserOtpService::generateAndSend()` finds-or-creates the `User`. A genuinely new `User` fires `Modules\Core\Auth\Events\UserCreated`, handled by `Modules\Core\Customer\Listeners\CreateCustomerForUser`, which creates a bare `Customer` (no name yet — nothing was collected at signup) and attaches it via the pivot.
- **This app's flow (`boboko-test`, flag set to `false` in its published `config/core.php`):** staff create a `Customer` directly (often pre-filled from an appointment booking) with real business data (name, email, VAT, etc.). `App\Events\CustomerCreated` (dispatched manually — see "Event Timing" below) is handled by `App\Listeners\CreateUserForCustomerListener`, which finds-or-creates the paired `User` by the customer's primary email.
**A shop opts out of core's default cascade by publishing and editing `config/core.php`:**
```bash
php artisan vendor:publish --tag=core-config
```
```php
// config/core.php
'auto_create_customer_for_user' => false,
```
Both listeners guard against the other direction re-triggering: they call `User::withoutEvents(...)` around `firstOrCreate`/save, so pairing a `Customer` never spuriously fires `UserCreated` (and vice versa) even if both directions are somehow active at once.
---
## Event Timing: Filament Repeaters and Model Events
**Do not dispatch domain events from a model's `$dispatchesEvents` (`created`/`updated`) when the event's listeners depend on related data saved via a Filament relationship `Repeater`** (e.g. `Repeater::make('emails')->relationship('emails')`). Filament's `CreateRecord`/`EditRecord` pages save the base record first (firing Eloquent's `created`/`updated` immediately), then save repeater-managed relations in a separate step afterward. A listener that reads `$customer->primaryEmail()` inside a `CustomerCreated`/`CustomerUpdated` handler triggered this way will silently see no email — no error, just a no-op.
**Fix:** dispatch the event manually from the Filament page's `afterCreate()`/`afterSave()` hook instead, once relations are guaranteed persisted:
```php
// app/Lunar/Pages/CreateCustomer.php
class CreateCustomer extends BaseCreateRecord
{
protected function afterCreate(): void
{
/** @var \App\Models\Customer $customer */
$customer = $this->record;
event(new CustomerCreated($customer));
}
}
```
```php
// app/Lunar/Pages/EditCustomer.php
class EditCustomer extends BaseEditCustomer // extends Lunar's own EditCustomer, not BaseEditRecord directly
{
public function afterSave(): void
{
parent::afterSave(); // preserves Lunar's own afterSave() behaviour (e.g. sync_with_search)
event(new CustomerUpdated($this->record));
}
}
```
Remove the corresponding entry from the model's `$dispatchesEvents` array so the event only fires once, from the page.
Both pages must be wired into the resource's `extendPages()`:
```php
public function extendPages(array $pages): array
{
$pages['create'] = CreateCustomer::route('/create');
$pages['edit'] = EditCustomer::route('/{record}/edit');
return $pages;
}
```
---
## Splitting Service Providers
When a service provider grows beyond a handful of responsibilities, split it into focused sub-providers and register **each one individually** in `extra.laravel.providers` (see "Provider Registration Pitfalls" above — registering only the entry-point provider silently drops the others).
**Each sub-provider owns one concern:**
```php
// AuthServiceProvider — staff auth wiring not already covered by the app's own CorePlugin/PanelServiceProvider
class AuthServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->app->booted(fn () => $this->commands([CreateAdminCommand::class]));
}
}
}
// CustomerServiceProvider — customer model replacement + Customer/User event wiring
class CustomerServiceProvider extends ServiceProvider
{
public function boot(): void
{
ModelManifest::replace(LunarCustomer::class, Customer::class);
Event::listen(UserCreated::class, CreateCustomerForUser::class);
}
}
```
**Rule of thumb:** if a provider has more than one `use` group (e.g. Lunar imports alongside mail imports alongside event imports), it is doing too much. If two providers (module and app) would both register the same `LunarPanel::extensions()` call or the same model-event listener, one of them is redundant — see "Provider Registration Pitfalls."
---
## Filament Panel Integration
All `LunarPanel::*` calls that must run before `LunarPanel::register()` (which snapshots `getRelations()` and locks in Livewire component registrations) live in the app's `PanelServiceProvider::register()`:
```php
// PanelServiceProvider::register()
LunarPanel::extensions([...]); // must come before register()
LunarPanel::panel(fn ($panel) => ...);
LunarPanel::disableTwoFactorAuth();
LunarPanel::register(); // snapshots everything above
```
Module-level extensions belong in the module's own sub-provider **only if the app isn't already the one wiring that resource's extension** — check `PanelServiceProvider` first. The rule: if removing the module would break the configuration, it belongs in the module; if the app already owns that wiring (common for `CustomerResource`/`StaffResource` in this project), the module's own attempt to also wire it is dead weight or a duplicate-registration bug.
---
## Overriding Lunar Relation Managers
Lunar resources ship with their own relation managers (e.g. `CustomerResource` has `AddressRelationManager`, `UserRelationManager`). To replace or remove one:
**1. Create your relation manager extending the one you want to replace** (only if you need to keep it, with changes):
```php
// app/Lunar/RelationManagers/AddressRelationManager.php
class AddressRelationManager extends BaseAddressRelationManager
{
public function getDefaultTable(Table $table): Table { ... }
}
```
**2. In the resource extension's `getRelations()`, swap or filter it out:**
```php
class CustomerResourceExtension extends BaseExtension
{
public function getRelations(array $relations): array
{
$relations = array_map(
fn ($relation) => $relation == BaseAddressRelationManager::class
? AddressRelationManager::class
: $relation,
array_filter(
$relations,
fn ($relation) => $relation != BaseUserRelationManager::class, // drop entirely
)
);
return $relations;
}
}
```
**Always compare against the Lunar base class** (`Lunar\Admin\...\AddressRelationManager`), not an intermediate class from a module. If a module has already swapped it and your extension compares against the module's class, the swap silently fails when boot order changes.
**3. Register the extension in `PanelServiceProvider::register()` before `LunarPanel::register()`** — extensions registered later (e.g. in `boot()`) are too late; Livewire component names are already locked in.
---
## Layering Module and App Configuration
`LunarPanel::extensions()` **merges** — calling it twice for the same resource registers both extensions. They run in registration order, each receiving the result of the previous one. **Only do this deliberately** — if both a module's provider and the app's `PanelServiceProvider` register an extension for the same resource without intending to layer, you get duplicate behaviour (see "Provider Registration Pitfalls").
```php
// CustomerServiceProvider::boot() — Core's fields
LunarPanel::extensions([CustomerResource::class => CoreCustomerResourceExtension::class]);
// PanelServiceProvider::register() — App's fields, applied on top
LunarPanel::extensions([CustomerResource::class => AppCustomerResourceExtension::class]);
```
Both `extendForm()` methods run: Core's first, App's second.
+150
View File
@@ -0,0 +1,150 @@
# Shopify → Lunar product import
Findings from comparing a real Shopify product export CSV against Lunar's schema (`vendor/lunarphp/core`), plus the resulting implementation plan for `MigrateImport\Shopify\ShopifyExportImporter`.
## Idempotency problem
Nothing in Lunar tracks "this record came from external system X, ID Y." Re-running an import with no external-ID tracking would duplicate every product on each run.
**Solution: a dedicated `import_mappings` table**, not columns on Lunar's core tables.
```php
Schema::create($this->prefix.'import_mappings', function (Blueprint $table) {
$table->id();
$table->string('source'); // 'shopify'
$table->string('source_type'); // 'product', 'variant', 'image', 'collection', 'tag'
$table->string('external_id'); // Shopify handle, or handle+position for variants/images
$table->morphs('model'); // model_type + model_id -> Product, ProductVariant, Asset, Collection, Tag
$table->timestamps();
$table->unique(['source', 'source_type', 'external_id']);
});
```
Every resolver looks up `import_mappings` by `(source, source_type, external_id)` first — update if found, create + insert mapping row if not.
## CSV shape
Shopify's export is flat: one row per (product × variant × extra image), with `Handle` repeated across all rows belonging to the same product. Only the first row per handle carries `Title`/`Body (HTML)`/`Vendor`/etc — later rows are variant-only or pure image rows (no `Option1 Value` at all).
The importer must first group consecutive CSV rows by `Handle` into `{ handle, productFields, variants: [...], images: [...] }` before touching Lunar.
## Field mapping: CSV → Lunar
| CSV column | Lunar equivalent | Notes |
|---|---|---|
| `Title`, `Body (HTML)` | `products.attribute_data` (JSON, translatable) | goes into attribute_data, not a flat column |
| `Vendor` | `products.brand` (plain string column) | direct passthrough |
| `Product Category` | `Collection` (nested set) inside a `CollectionGroup` | Google-taxonomy breadcrumb (e.g. `Toys & Games > Toys > Bath Toys`) — parse into a nested Collection chain, product attached to the deepest node. See "Collections" below. |
| `Type` | `product_type_id` (required FK) | must resolve/create a `ProductType`; needs a default fallback (e.g. "General") |
| `Tags` | `Tag` model (separate, not built into Product) | split CSV string, find/create, attach |
| `Published` / `Status` | `products.status` (plain string, indexed) | direct mapping |
| `Option1/2/3 Name+Value` | `product_options` + `product_option_values` + variant pivot | find/create option (by name), find/create value (by option+value), attach to variant |
| `Variant SKU` | `product_variants.sku` | direct |
| `Variant Grams` | `product_variants` dimensions (`$table->dimensions()`) | needs unit conversion check — macro likely adds `weight_value`/`weight_unit`, not raw grams |
| `Variant Inventory Qty` | `product_variants.stock` | direct |
| `Variant Price` / `Compare At Price` | `prices` table (polymorphic `priceable`, per-currency, per-customer-group) | **not a variant column** — create a `Price` row per variant per currency |
| `Cost per item` | *(nothing)* | Lunar has no cost-price field — stash in `attribute_data` as a custom attribute, in scope for v1 |
| `Variant Barcode` | closest is `gtin`/`ean` on variant | ambiguous, needs a mapping rule |
| `Variant Requires Shipping` | `product_variants.shippable` (boolean) | direct |
| `Variant Taxable` | `tax_class_id` (required FK, not boolean) | must resolve to an actual `TaxClass` row (e.g. "Standard" vs "None") |
| `Image Src` (+ image-only rows) | `Asset` model + Spatie MediaLibrary, ordered via `media_product_variant` pivot (`position`) | see "Images" below — file is local, not a URL to download |
| `SEO Title` / `SEO Description` | *(nothing structured)* | stash in `attribute_data` as custom attributes, in scope for v1 |
| Metafield columns (see below) | *(nothing)* | **all empty across every row in the sample export — skip entirely** |
| `Handle` | `Url` model (`urls` table, polymorphic, per-language slug) | create a `Url` row per product (slug = handle), not a plain column |
## Collections — Lunar supports multiple collections per product
Lunar's `collection_product` table is a plain many-to-many pivot (`collection_id`, `product_id`, `position`) with no uniqueness constraint tying a product to a single collection. A `Product` can belong to any number of `Collection`s simultaneously, and `Collection`s themselves are a nested-set tree (`NodeTrait`, `_lft`/`_rgt`/`parent_id`) scoped inside a `CollectionGroup` (`collection_group_id`).
This matters for the importer in two ways:
- **The breadcrumb import doesn't have to be exclusive.** Attaching a product to `Toys & Games > Toys > Bath Toys > Rubber Duckies` doesn't prevent it from also being attached to a separately-curated collection (e.g. a manual "Best Sellers" collection) — no conflict, since it's a plain pivot.
- **Resolve the breadcrumb into whichever `CollectionGroup` fits the target site's structure** — the known Shopify category tree can be parsed straight into it; no need for a separate import-only group unless you specifically want to isolate/purge imported collections independently later.
- Only the **deepest node** of the breadcrumb needs the product attached directly — Lunar's nested-set ancestry (`getBreadcrumbAttribute()`) already exposes the full parent chain via `$collection->ancestors`, so there's no need to attach the product to every intermediate level.
Resolver logic: split `Product Category` on `>`, trim each segment, walk down creating/reusing `Collection` nodes as a chain (`parent_id` chained) within the fixed import `CollectionGroup`, then attach the product only to the final (leaf) `Collection` via `collection_product`.
## Images — the export includes a local files folder
The Shopify export isn't just the CSV — it also produces a folder of the actual image files, named by UUID (not by the original filename or `Image Src` URL). The `Image Src` column in the CSV is the *original* Shopify CDN URL, which is **not** how the file is named on disk in the export folder.
This changes the AssetResolver from an HTTP-download step to a local-file-lookup step:
- The importer needs a way to map each CSV `Image Src` row to its corresponding UUID-named file in the export folder — check whether the export also provides a manifest/mapping file, or whether the mapping has to be inferred from row order per `Handle` (i.e. Nth image row for a handle = Nth file in that handle's image set, if the export preserves ordering).
- Once resolved, the local file is what gets added to the `Asset` + Spatie MediaLibrary (`addMedia($localPath)`), not a downloaded copy of the CDN URL — no HTTP client / retry / timeout handling needed for images, but a **file-existence check** is needed (missing/renamed file in the export folder should not silently fail the whole product import).
- `import_mappings` for images should still key on something stable from the CSV (e.g. `Image Src` URL, or `handle + position`) rather than the UUID filename, since the UUID is regenerated on every fresh export and isn't a stable external ID across re-imports.
## Metafield columns — confirmed empty, skipped
These are Shopify's auto-generated category-specific metafields (populated once a product has a `Product Category` assigned). In the sample export they were empty for effectively every row (a few had `color-pattern`, `material`, or `target-gender` populated). **Decision: skip all of these for v1**, since none carry data worth preserving in this dataset.
```
Product rating count (product.metafields.reviews.rating_count)
Age group (product.metafields.shopify.age-group)
Barware material (product.metafields.shopify.barware-material)
Board game mechanics (product.metafields.shopify.board-game-mechanics)
Color (product.metafields.shopify.color-pattern)
Construction set theme (product.metafields.shopify.construction-set-theme)
Decoration material (product.metafields.shopify.decoration-material)
Dexterity skills (product.metafields.shopify.dexterity-skills)
Dice shape (product.metafields.shopify.dice-shape)
Difficulty level (product.metafields.shopify.difficulty-level)
Doll/Playset features (product.metafields.shopify.doll-playset-features)
Finish (product.metafields.shopify.finish)
Gameplay skills (product.metafields.shopify.gameplay-skills)
Hardware material (product.metafields.shopify.hardware-material)
Material (product.metafields.shopify.material)
Pet trainer type (product.metafields.shopify.pet-trainer-type)
Plant class (product.metafields.shopify.plant-class)
Plant name (product.metafields.shopify.plant-name)
Puzzle theme (product.metafields.shopify.puzzle-theme)
Puzzle type (product.metafields.shopify.puzzle-type)
Recommended age group (product.metafields.shopify.recommended-age-group)
Shape (product.metafields.shopify.shape)
Stationery binding type (product.metafields.shopify.stationery-binding-type)
Suitable space (product.metafields.shopify.suitable-space)
Target gender (product.metafields.shopify.target-gender)
Theme (product.metafields.shopify.theme)
Toy/Game material (product.metafields.shopify.toy-game-material)
Usage type (product.metafields.shopify.usage-type)
Vase shape (product.metafields.shopify.vase-shape)
```
## Resolvers, in dependency order
Each resolver is idempotent: find-by-natural-key-or-create, checking `import_mappings` where applicable.
1. **TaxClassResolver** — maps `Variant Taxable` (bool) to a fixed `TaxClass` ("Standard" vs "None"), not a per-row lookup.
2. **ProductTypeResolver** — maps CSV `Type` to `ProductType`; falls back to a default (e.g. "General") since `product_type_id` is required.
3. **BrandResolver**`Vendor``products.brand`; likely a passthrough, not a real resolver, since it's a plain string column.
4. **TagResolver** — splits `Tags`, finds/creates `Tag` rows, attaches to product.
5. **CollectionResolver** — parses `Product Category` breadcrumb into a nested `Collection` chain within the target `CollectionGroup` (find/create per level), attaches product only to the deepest (leaf) Collection. Does not touch or conflict with any other collections the product may already belong to — a product can be in multiple collections at once.
6. **ProductOptionResolver** — for each `Option1/2/3 Name`, find/create `ProductOption` + `ProductOptionValue`, reused across products.
7. **AssetResolver** — resolves each CSV image row to its local UUID-named file in the export's images folder (see "Images" above), creates `Asset` + Spatie media from that local file, attaches to product and/or specific variants with `position` ordering.
8. **PriceResolver** — creates `Price` row(s) per variant per currency from `Variant Price` / `Variant Compare At Price`.
## Orchestration order per product group
```
1. Resolve/create ProductType, TaxClass, Brand
2. Create or update Product (import_mappings: source_type='product', external_id=handle)
- write Title/Body, Cost per item, SEO title/description into attribute_data
3. Create/attach Url (slug = handle)
4. Resolve/attach Tags
5. Resolve/attach Collection chain (parsed from Product Category breadcrumb, within the target CollectionGroup; attach product to leaf Collection only)
6. For each Option1/2/3 Name -> resolve ProductOption
7. For each variant row:
- resolve ProductOptionValues for that variant's option combination
- create or update ProductVariant (import_mappings: source_type='variant', external_id=handle+variant-index)
- attach ProductOptionValues to variant
- create/update Price row(s)
8. For each image row:
- resolve the CSV row to its local UUID-named file in the export's images folder; skip with a logged warning if the file is missing
- resolve/create Asset from the local file (import_mappings: source_type='image', external_id=image URL or handle+position — not the UUID filename, which is regenerated per export)
- attach to Product, and to specific ProductVariant if the image is variant-scoped
```
## Explicitly out of scope for v1
- Metafield columns (confirmed empty — see above).
+221
View File
@@ -0,0 +1,221 @@
/**
* Stoic CMS — in-place editor script
*
* Vendor this file into your website's asset pipeline.
*
* Configuration (set before this script runs):
* window.STOIC_HOST = "https://your-cms-domain.com"; // default: same origin
*
* Usage on your HTML elements:
* data-stoic="schema/slug" open editor for this entry
* data-stoic="schema/slug#field" open editor and focus a specific field
* data-stoic="singleton#field" open editor for a singleton schema
*
* SSO: if STOIC_SSO_SECRET is configured on the server, the admin link
* will generate a signed token so the user lands directly in the CMS.
* Set window.STOIC_SSO_TOKEN before loading this script:
* window.STOIC_SSO_TOKEN = "<?= $ssoToken ?>";
*/
(function () {
"use strict";
const HOST = (window.STOIC_HOST || "").replace(/\/$/, "");
// ── State ────────────────────────────────────────────────────────────────
let editMode = false;
let activePane = null;
let messageListener = null;
// ── Toolbar ──────────────────────────────────────────────────────────────
const bar = document.createElement("div");
bar.className = "stoic-bar";
const btn = document.createElement("button");
btn.className = "stoic-edit-toggle";
btn.setAttribute("aria-label", "Toggle edit mode");
btn.innerHTML =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> Edit';
btn.addEventListener("click", function () {
editMode ? exitEditMode() : enterEditMode();
});
const adminLink = document.createElement("a");
adminLink.className = "stoic-admin-link";
adminLink.target = "_blank";
adminLink.rel = "noopener noreferrer";
adminLink.setAttribute("aria-label", "Open CMS admin");
adminLink.innerHTML =
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>';
// If a pre-generated SSO token is provided, use the SSO endpoint directly.
// Otherwise link to the standard login page.
if (window.STOIC_SSO_TOKEN) {
adminLink.href = HOST + "/cms/sso?" + window.STOIC_SSO_TOKEN;
} else {
adminLink.href = HOST;
}
bar.appendChild(btn);
bar.appendChild(adminLink);
// ── Edit mode ────────────────────────────────────────────────────────────
function enterEditMode() {
editMode = true;
btn.classList.add("active");
document.querySelectorAll("[data-stoic]").forEach(function (el) {
el.classList.add("stoic-target");
el.addEventListener("click", onTargetClick, true);
});
}
function exitEditMode() {
editMode = false;
btn.classList.remove("active");
document.querySelectorAll("[data-stoic]").forEach(function (el) {
el.classList.remove("stoic-target", "stoic-target--active");
el.removeEventListener("click", onTargetClick, true);
});
closePane();
}
function onTargetClick(e) {
e.preventDefault();
e.stopPropagation();
document
.querySelectorAll(".stoic-target--active")
.forEach(function (el) {
el.classList.remove("stoic-target--active");
});
this.classList.add("stoic-target--active");
const attr = this.getAttribute("data-stoic");
const hashIdx = attr.indexOf("#");
const path = hashIdx === -1 ? attr : attr.slice(0, hashIdx);
const field = hashIdx === -1 ? "" : attr.slice(hashIdx + 1);
openPane(path, field);
}
// ── Side pane ────────────────────────────────────────────────────────────
function openPane(path, field) {
closePane();
const overlay = document.createElement("div");
overlay.className = "stoic-overlay";
overlay.addEventListener("click", closePane);
const pane = document.createElement("div");
pane.className = "stoic-pane";
const closeBtn = document.createElement("button");
closeBtn.className = "stoic-pane-close";
closeBtn.setAttribute("aria-label", "Close");
closeBtn.innerHTML =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
closeBtn.addEventListener("click", closePane);
const iframe = document.createElement("iframe");
iframe.className = "stoic-pane-iframe";
let src = HOST + "/cms/embed/" + path;
if (field) src += "?focus=" + encodeURIComponent(field);
iframe.src = src;
pane.appendChild(closeBtn);
pane.appendChild(iframe);
document.body.appendChild(overlay);
document.body.appendChild(pane);
activePane = { pane: pane, overlay: overlay };
messageListener = function (e) {
if (e.data && e.data.type === "stoic:saved") {
onSaved();
}
};
window.addEventListener("message", messageListener);
requestAnimationFrame(function () {
pane.classList.add("open");
overlay.classList.add("open");
});
}
function closePane() {
if (!activePane) return;
var pane = activePane.pane;
var overlay = activePane.overlay;
pane.classList.remove("open");
overlay.classList.remove("open");
setTimeout(function () {
pane.remove();
overlay.remove();
}, 220);
if (messageListener) {
window.removeEventListener("message", messageListener);
messageListener = null;
}
document
.querySelectorAll(".stoic-target--active")
.forEach(function (el) {
el.classList.remove("stoic-target--active");
});
activePane = null;
}
function onSaved() {
var activeEl = document.querySelector(".stoic-target--active");
if (activeEl) {
activeEl.dispatchEvent(
new CustomEvent("stoic:saved", {
bubbles: true,
composed: true,
}),
);
}
closePane();
}
// ── Styles ───────────────────────────────────────────────────────────────
var style = document.createElement("style");
style.textContent = [
".stoic-bar{position:fixed;bottom:1.25rem;right:1.25rem;z-index:2147483646;display:inline-flex;align-items:center;gap:0.375rem;background:#1a1a2e;border-radius:9999px;padding:0.25rem 0.25rem 0.25rem 0.5rem;box-shadow:0 2px 12px rgba(0,0,0,.35);}",
".stoic-edit-toggle{display:inline-flex;align-items:center;gap:0.4rem;padding:0.35rem 0.75rem;border:none;border-radius:9999px;background:transparent;color:#fff;font-size:0.8rem;font-weight:600;font-family:system-ui,sans-serif;cursor:pointer;transition:background .15s;}",
".stoic-edit-toggle:hover{background:rgba(255,255,255,.1);}",
".stoic-edit-toggle.active{background:#4f46e5;}",
".stoic-admin-link{display:inline-flex;align-items:center;justify-content:center;width:1.75rem;height:1.75rem;border-radius:50%;color:rgba(255,255,255,.6);text-decoration:none;transition:color .15s,background .15s;}",
".stoic-admin-link:hover{color:#fff;background:rgba(255,255,255,.1);}",
".stoic-target{outline:2px dashed rgba(79,70,229,.5)!important;outline-offset:2px!important;cursor:pointer!important;transition:outline-color .15s;}",
".stoic-target:hover{outline-color:rgba(79,70,229,.9)!important;}",
".stoic-target--active{outline:2px solid #4f46e5!important;outline-offset:2px!important;}",
".stoic-overlay{position:fixed;inset:0;z-index:2147483644;background:rgba(0,0,0,.25);opacity:0;transition:opacity .2s;}",
".stoic-overlay.open{opacity:1;}",
".stoic-pane{position:fixed;top:0;right:0;bottom:0;z-index:2147483645;width:min(480px,100vw);background:#fff;box-shadow:-4px 0 24px rgba(0,0,0,.18);transform:translateX(100%);transition:transform .22s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column;}",
".stoic-pane.open{transform:translateX(0);}",
".stoic-pane-close{position:absolute;top:.75rem;right:.75rem;z-index:1;width:1.75rem;height:1.75rem;border:none;border-radius:50%;background:rgba(0,0,0,.07);color:#555;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;}",
".stoic-pane-close:hover{background:rgba(0,0,0,.14);}",
".stoic-pane-iframe{flex:1;width:100%;border:none;}",
].join("");
// ── Init ─────────────────────────────────────────────────────────────────
document.head.appendChild(style);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
document.body.appendChild(bar);
});
} else {
document.body.appendChild(bar);
}
})();
@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
+33
View File
@@ -0,0 +1,33 @@
@props([
'src', // Stoic filename string e.g. "hero.png"
'preset' => 'medium', // which preset sets the src + default sizes hint
'alt' => '',
'loading' => 'lazy',
'fetchpriority' => 'auto',
'sizes' => null, // override when you know the render context
'width' => null,
'height' => null,
])
@php
$srcset = \Modules\Core\Stoic\Stoic::srcset($src);
$presetW = collect(\Modules\Core\Stoic\Stoic::presets())->firstWhere('code', $preset)['width'] ?? 800;
$sizesAttr = $sizes ?? "(min-width: 1024px) {$presetW}px, 100vw";
[$width, $height] = $width !== null && $height !== null
? [$width, $height]
: \Modules\Core\Stoic\Stoic::dimensions($src, $preset);
@endphp
<img
src="{{ \Modules\Core\Stoic\Stoic::image($src, $preset) }}"
@if($srcset)
srcset="{{ $srcset }}"
sizes="{{ $sizesAttr }}"
@endif
@if($width !== null) width="{{ $width }}" @endif
@if($height !== null) height="{{ $height }}" @endif
alt="{{ $alt }}"
loading="{{ $loading }}"
fetchpriority="{{ $fetchpriority }}"
{{ $attributes }}
/>
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Auth\Events;
use Illuminate\Contracts\Auth\Authenticatable;
class UserCreated
{
public function __construct(
public readonly Authenticatable $user,
) {}
}
@@ -6,7 +6,7 @@ use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
class CustomerOtpMail extends Mailable
class UserOtpMail extends Mailable
{
public function __construct(
public readonly string $name,
@@ -20,6 +20,6 @@ class CustomerOtpMail extends Mailable
public function content(): Content
{
return new Content(view: 'core::auth.mail.customer-otp');
return new Content(view: 'core::auth.mail.user-otp');
}
}
-61
View File
@@ -1,61 +0,0 @@
<?php
namespace Modules\Core\Auth\Services;
use Illuminate\Support\Facades\Mail;
use Lunar\Models\Contracts\Customer;
use Lunar\Facades\ModelManifest;
use Modules\Core\Auth\Mail\CustomerOtpMail;
class CustomerOtpService
{
private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6;
public function generateAndSend(string $email): bool
{
$customer = $this->findByEmail($email);
if (! $customer) {
return false;
}
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
$customer->otp_code = $code;
$customer->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$customer->save();
Mail::to($email)->send(new CustomerOtpMail($customer->full_name, $code));
return true;
}
public function validate(string $email, string $code): ?Customer
{
$customer = $this->findByEmail($email);
if (! $customer) {
return null;
}
if (! $customer->otp_expires_at || $customer->otp_code != $code || now()->isAfter($customer->otp_expires_at)) {
return null;
}
$customer->otp_code = null;
$customer->otp_expires_at = null;
$customer->save();
return $customer;
}
private function findByEmail(string $email): ?Customer
{
$model = ModelManifest::get(Customer::class);
return $model::query()
->whereHas('emails', fn ($query) => $query->where('email', $email))
->first();
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace Modules\Core\Auth\Services;
use Illuminate\Support\Facades\Mail;
use Modules\Core\Auth\Mail\UserOtpMail;
class UserOtpService
{
private const EXPIRY_MINUTES = 10;
private const CODE_LENGTH = 6;
public function generateAndSend(string $email): bool
{
$model = config('auth.providers.users.model');
$user = $model::firstOrCreate(['email' => $email]);
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
$user->otp_code = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->save();
Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code));
return true;
}
public function validate(string $email, string $code)
{
$model = config('auth.providers.users.model');
$user = $model::where('email', $email)->first();
if (! $user) {
return null;
}
if (! $user->otp_expires_at || $user->otp_code != $code || now()->isAfter($user->otp_expires_at)) {
return null;
}
$user->otp_code = null;
$user->otp_expires_at = null;
$user->save();
return $user;
}
}
+249
View File
@@ -0,0 +1,249 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Lunar\Facades\DB;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Attribute;
use Lunar\Models\AttributeGroup;
use Lunar\Models\Channel;
use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup;
use Lunar\Models\Country;
use Lunar\Models\Currency;
use Lunar\Models\CustomerGroup;
use Lunar\Models\Language;
use Lunar\Models\Product;
use Lunar\Models\ProductType;
use Lunar\Models\TaxClass;
use Lunar\Models\TaxZone;
/**
* Overrides Lunar's own lunar:install to skip the interactive prompts (migrate
* confirmation, admin creation, GitHub star), so it's safe to run unattended from
* the entrypoint on every boot. Migrations already run as a separate, earlier
* entrypoint step; the admin user is created manually.
*/
class InstallLunarCommand extends Command
{
protected $signature = 'lunar:install';
protected $description = 'Seed the default Lunar store data (countries, channel, currency, tax zone, attributes, product type)';
public function handle(): void
{
$this->components->info('Seeding default Lunar store data...');
if (! Country::count()) {
$this->components->info('Importing countries');
$this->call('lunar:import:address-data');
}
DB::transaction(function () {
if (! Channel::whereDefault(true)->exists()) {
$this->components->info('Setting up default channel');
Channel::create([
'name' => 'Webstore',
'handle' => 'webstore',
'default' => true,
'url' => 'http://localhost',
]);
}
if (! Language::count()) {
$this->components->info('Adding default language');
Language::create([
'code' => 'en',
'name' => 'English',
'default' => true,
]);
}
if (! Currency::whereDefault(true)->exists()) {
$this->components->info('Adding a default currency (USD)');
Currency::create([
'code' => 'USD',
'name' => 'US Dollar',
'exchange_rate' => 1,
'decimal_places' => 2,
'default' => true,
'enabled' => true,
]);
}
if (! CustomerGroup::whereDefault(true)->exists()) {
$this->components->info('Adding a default customer group.');
CustomerGroup::create([
'name' => 'Retail',
'handle' => 'retail',
'default' => true,
]);
}
if (! CollectionGroup::count()) {
$this->components->info('Adding an initial collection group');
CollectionGroup::create([
'name' => 'Main',
'handle' => 'main',
]);
}
if (! TaxClass::count()) {
$this->components->info('Adding a default tax class.');
TaxClass::create([
'name' => 'Default Tax Class',
'default' => true,
]);
}
if (! TaxZone::count()) {
$this->components->info('Adding a default tax zone.');
$taxZone = TaxZone::create([
'name' => 'Default Tax Zone',
'zone_type' => 'country',
'price_display' => 'tax_exclusive',
'default' => true,
'active' => true,
]);
$taxZone->countries()->createMany(
Country::get()->map(fn ($country) => [
'country_id' => $country->id,
])
);
}
if (! Attribute::count()) {
$this->components->info('Setting up initial attributes');
$group = AttributeGroup::create([
'attributable_type' => Product::morphName(),
'name' => collect([
'en' => 'Details',
]),
'handle' => 'details',
'position' => 1,
]);
$collectionGroup = AttributeGroup::create([
'attributable_type' => Collection::morphName(),
'name' => collect([
'en' => 'Details',
]),
'handle' => 'collection_details',
'position' => 1,
]);
Attribute::create([
'attribute_type' => 'product',
'attribute_group_id' => $group->id,
'position' => 1,
'name' => [
'en' => 'Name',
],
'handle' => 'name',
'section' => 'main',
'type' => TranslatedText::class,
'required' => true,
'default_value' => null,
'configuration' => [
'richtext' => false,
],
'system' => true,
'description' => [
'en' => '',
],
]);
Attribute::create([
'attribute_type' => 'collection',
'attribute_group_id' => $collectionGroup->id,
'position' => 1,
'name' => [
'en' => 'Name',
],
'handle' => 'name',
'section' => 'main',
'type' => TranslatedText::class,
'required' => true,
'default_value' => null,
'configuration' => [
'richtext' => false,
],
'system' => true,
'description' => [
'en' => '',
],
]);
Attribute::create([
'attribute_type' => 'product',
'attribute_group_id' => $group->id,
'position' => 2,
'name' => [
'en' => 'Description',
],
'handle' => 'description',
'section' => 'main',
'type' => TranslatedText::class,
'required' => false,
'default_value' => null,
'configuration' => [
'richtext' => true,
],
'system' => false,
'description' => [
'en' => '',
],
]);
Attribute::create([
'attribute_type' => 'collection',
'attribute_group_id' => $collectionGroup->id,
'position' => 2,
'name' => [
'en' => 'Description',
],
'handle' => 'description',
'section' => 'main',
'type' => TranslatedText::class,
'required' => false,
'default_value' => null,
'configuration' => [
'richtext' => true,
],
'system' => false,
'description' => [
'en' => '',
],
]);
}
if (! ProductType::count()) {
$this->components->info('Adding a product type.');
$type = ProductType::create([
'name' => 'Stock',
]);
$type->mappedAttributes()->attach(
Attribute::whereAttributeType(
Product::morphName()
)->get()->pluck('id')
);
}
});
$this->components->info('Publishing Filament assets');
$this->call('filament:assets');
$this->components->info('Lunar default data seeded.');
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\RunMigrateImportJob;
class MigrateImportCommand extends Command
{
protected $signature = 'boboko:migrate:import
{--source= : The source platform to import from}
{--type= : The import mechanism for the chosen source}
{--file= : Path to the export file (when type is export)}';
protected $description = 'Migrate data (products, customers, etc.) from an external source';
// Supported sources. Only "shopify" and "judgeme" are wired up for now.
private const SOURCES = [
'shopify',
'judgeme',
// "woocommerce",
];
// Supported import types per source. Only "export" is wired up for now.
private const TYPES = [
'export',
// "api",
];
public function handle(): void
{
$source = $this->option('source')
?? $this->choice('Select a source:', self::SOURCES, 0);
$type = $this->option('type')
?? $this->choice('Select an import type:', self::TYPES, 0);
if ($type === 'api') {
$credentials = [
'api_key' => $this->secret('API key:'),
'api_secret' => $this->secret('API secret:'),
];
$filePath = null;
} else {
$importsPath = storage_path('app/private/imports');
$filePath = $this->option('file')
?? $this->resolveImportPath($importsPath, $this->ask("Path to the export file, relative to {$importsPath}:"));
while (blank($filePath) || ! is_file($filePath)) {
if (filled($filePath)) {
$this->error("File not found: {$filePath}");
}
$filePath = $this->resolveImportPath(
$importsPath,
$this->ask("Path to the export file, relative to {$importsPath}:"),
);
}
$credentials = null;
}
$spec = new ImportSpec(
source: $source,
type: $type,
filePath: $filePath,
credentials: $credentials,
);
RunMigrateImportJob::dispatch($spec);
$this->info('Import queued.');
}
// Answers are relative to storage/app/private/imports (e.g. "shopify" or
// "shopify/products_export.csv"); absolute paths are used as-is. A
// directory answer picks the first CSV file found inside it.
private function resolveImportPath(string $importsPath, ?string $answer): ?string
{
if (blank($answer)) {
return $answer;
}
$path = str_starts_with($answer, '/') ? $answer : "{$importsPath}/{$answer}";
if (is_dir($path)) {
$csv = collect(glob("{$path}/*.csv"))->first();
return $csv ?? $path;
}
return $path;
}
}
+26 -10
View File
@@ -3,15 +3,27 @@
namespace Modules\Core;
use Filament\Contracts\Plugin;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\OrderResource;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff;
use Lunar\Admin\Support\Facades\LunarPanel;
use Lunar\Models\Product;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource\Pages\ListShippingMethod;
use Lunar\Shipping\ShippingPlugin;
use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Filament\Pages\Login;
use Modules\Core\Auth\Mail\InviteMail;
use Modules\Core\Review\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Shipping\Extensions\OrderViewExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Pages\ManagePickupManifests;
class CorePlugin implements Plugin
{
@@ -24,21 +36,25 @@ class CorePlugin implements Plugin
{
$panel->path('boboko')
->brandName('Boboko')
->brandLogo(asset('logos/core/boboko-logo.svg'))
->darkModeBrandLogo(asset('logos/core/boboko-logo-white.svg'))
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
->login(Login::class)
->navigationItems([
NavigationItem::make("Lucent")
->url("/lucent")
->icon("heroicon-o-sun")
->group("Content")
->sort(1),
]);
->plugin(ShippingPlugin::make())
->pages([ManagePickupManifests::class]);
LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class,
ProductResource::class => ProductResourceExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::class,
OrderResource\Pages\ManageOrder::class => OrderViewExtension::class,
]);
Product::macro('reviews', function (): HasMany {
/** @var Product $this */
return $this->hasMany(ProductReview::class);
});
LunarStaff::addActivitylogExcept([
'otp_code',
'otp_expires_at',
@@ -3,17 +3,21 @@
namespace Modules\Core\Customer\Extensions;
use Lunar\Admin\Filament\Resources\CustomerResource\RelationManagers\AddressRelationManager as BaseAddressRelationManager;
use Lunar\Admin\Filament\Resources\CustomerResource\RelationManagers\UserRelationManager as BaseUserRelationManager;
use Lunar\Admin\Support\Extending\BaseExtension;
use Modules\Core\Customer\RelationManagers\AddressRelationManager;
use Modules\Core\Customer\RelationManagers\UserRelationManager;
class CustomerResourceExtension extends BaseExtension
{
public function getRelations(array $relations): array
{
return array_map(
fn ($relation) => $relation == BaseAddressRelationManager::class
? AddressRelationManager::class
: $relation,
fn ($relation) => match ($relation) {
BaseAddressRelationManager::class => AddressRelationManager::class,
BaseUserRelationManager::class => UserRelationManager::class,
default => $relation,
},
$relations
);
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Customer\Listeners;
use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Customer as CustomerContract;
use Modules\Core\Auth\Events\UserCreated;
class CreateCustomerForUser
{
public function handle(UserCreated $event): void
{
if (! config('core.auto_create_customer_for_user')) {
return;
}
$model = ModelManifest::get(CustomerContract::class);
$customer = $model::create([]);
$customer->users()->attach($event->user);
}
}
+1 -12
View File
@@ -2,19 +2,8 @@
namespace Modules\Core\Customer\Models;
use Illuminate\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Foundation\Auth\Access\Authorizable;
class Customer extends \Lunar\Models\Customer implements AuthenticatableContract
class Customer extends \Lunar\Models\Customer
{
use Authenticatable;
use Authorizable;
protected $casts = [
'otp_expires_at' => 'datetime',
];
public function getTitleAttribute(): ?string
{
return null;
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Customer\RelationManagers;
use Filament\Forms\Components\TextInput;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Lunar\Admin\Events\CustomerUserEdited;
use Lunar\Admin\Filament\Resources\CustomerResource\RelationManagers\UserRelationManager as BaseUserRelationManager;
class UserRelationManager extends BaseUserRelationManager
{
public function getDefaultTable(Table $table): Table
{
return $table->columns([
Tables\Columns\TextColumn::make('name')
->label(__('lunarpanel::user.table.name.label')),
Tables\Columns\TextColumn::make('email')
->label(__('lunarpanel::user.table.email.label')),
])->actions([
Tables\Actions\EditAction::make('edit')
->after(
fn (Model $record) => CustomerUserEdited::dispatch($record)
)
->form([
TextInput::make('email')
->label(__('lunarpanel::user.form.email.label'))
->required()
->email(),
]),
]);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\MigrateImport;
use Lunar\Models\Language;
class DefaultLocale
{
public static function code(): string
{
return Language::getDefault()->code;
}
}
+14
View File
@@ -0,0 +1,14 @@
<?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
@@ -0,0 +1,8 @@
<?php
namespace Modules\Core\MigrateImport;
interface Importer
{
public function import(ImportSpec $spec): void;
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\MigrateImport;
use Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter;
use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter;
class ImporterFactory
{
public static function make(ImportSpec $spec): Importer
{
return match ([$spec->source, $spec->type]) {
['shopify', 'export'] => new ShopifyExportImporter,
// ["shopify", "api"] => new ShopifyApiImporter(),
['judgeme', 'export'] => new JudgeMeExportImporter,
// ["woocommerce", "export"] => new WooCommerceExportImporter(),
// ["woocommerce", "api"] => new WooCommerceApiImporter(),
default => throw new \InvalidArgumentException(
"No importer available for source \"{$spec->source}\" with type \"{$spec->type}\".",
),
};
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\MigrateImport\JudgeMe;
class JudgeMeCsvReader
{
/**
* @return array<int, array<string, string>>
*/
public function read(string $csvPath): array
{
$handle = fopen($csvPath, 'r');
if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}");
}
$headers = fgetcsv($handle);
$rows = [];
while (($row = fgetcsv($handle)) !== false) {
$rows[] = array_combine($headers, $row);
}
fclose($handle);
return $rows;
}
}
@@ -0,0 +1,105 @@
<?php
namespace Modules\Core\MigrateImport\JudgeMe;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Modules\Core\MigrateImport\Importer;
use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver;
use Modules\Core\MigrateImport\Models\ImportMapping;
use Modules\Core\Review\Models\ProductReview;
class JudgeMeExportImporter implements Importer
{
private const SOURCE = 'judgeme';
public function __construct(
private readonly JudgeMeCsvReader $csvReader = new JudgeMeCsvReader,
private readonly ProductResolver $productResolver = new ProductResolver,
) {}
public function import(ImportSpec $spec): void
{
foreach ($this->csvReader->read($spec->filePath) as $row) {
$this->importReview($row);
}
}
private function importReview(array $row): void
{
$handle = trim((string) ($row['product_handle'] ?? ''));
$externalId = $row['metaobject_handle'] ?? '';
$product = $handle !== '' ? $this->productResolver->resolve($handle) : null;
if (! $product) {
Log::warning('JudgeMe import: no product found for handle, skipping review', [
'product_handle' => $handle,
'metaobject_handle' => $externalId,
]);
return;
}
$existing = ImportMapping::resolve(self::SOURCE, 'review', $externalId);
$review = $existing instanceof ProductReview ? $existing : new ProductReview;
$review->product_id = $product->id;
$review->title = trim((string) ($row['title'] ?? '')) ?: null;
$review->body = $row['body'] ?? null;
$review->rating = (int) ($row['rating'] ?? 0);
$review->reviewed_at = $this->parseDate($row['review_date'] ?? null);
$review->reviewer_name = $row['reviewer_name'] ?? null;
$review->reviewer_email = trim((string) ($row['reviewer_email'] ?? '')) ?: null;
$review->reply = trim((string) ($row['reply'] ?? '')) ?: null;
$review->replied_at = $this->parseDate($row['reply_date'] ?? null);
$review->location = trim((string) ($row['location'] ?? '')) ?: null;
$review->source = trim((string) ($row['source'] ?? '')) ?: null;
$review->save();
ImportMapping::record(self::SOURCE, 'review', $externalId, $review);
$pictureUrls = $this->parsePictureUrls($row['picture_urls'] ?? null);
if ($pictureUrls && $review->getMedia(ProductReview::IMAGES_COLLECTION)->isEmpty()) {
$this->importImages($review, $pictureUrls);
}
}
/**
* @param array<int, string> $urls
*/
private function importImages(ProductReview $review, array $urls): void
{
foreach ($urls as $url) {
try {
$review->addMediaFromUrl($url)->toMediaCollection(ProductReview::IMAGES_COLLECTION);
} catch (\Throwable $e) {
Log::warning('JudgeMe import: failed to download review image', [
'review_id' => $review->id,
'url' => $url,
'error' => $e->getMessage(),
]);
}
}
}
private function parseDate(?string $value): ?Carbon
{
$value = trim((string) $value);
return $value !== '' ? Carbon::parse($value) : null;
}
private function parsePictureUrls(?string $value): ?array
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
return array_map('trim', explode(',', $value));
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\MigrateImport\JudgeMe\Resolvers;
use Lunar\Models\Product;
use Lunar\Models\Url;
class ProductResolver
{
public function resolve(string $handle): ?Product
{
$url = Url::query()
->where('slug', $handle)
->where('element_type', (new Product)->getMorphClass())
->first();
return $url?->element;
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Core\MigrateImport\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ImportMapping extends Model
{
protected $guarded = [];
public function model(): MorphTo
{
return $this->morphTo();
}
public static function resolve(string $source, string $sourceType, string $externalId): ?Model
{
return static::query()
->where('source', $source)
->where('source_type', $sourceType)
->where('external_id', $externalId)
->first()
?->model;
}
public static function record(string $source, string $sourceType, string $externalId, Model $model): self
{
return static::query()->updateOrCreate(
[
'source' => $source,
'source_type' => $sourceType,
'external_id' => $externalId,
],
[
'model_type' => $model->getMorphClass(),
'model_id' => $model->getKey(),
],
);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?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,18 @@
<?php
namespace Modules\Core\MigrateImport\Shopify;
class ProductGroup
{
/** @var array<int, array<string, string>> */
public array $variantRows = [];
/** @var array<int, array<string, string>> */
public array $imageRows = [];
public function __construct(
public readonly string $handle,
public readonly array $productRow,
) {
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\Product;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class AssetResolver
{
public function resolve(Product $product, string $localFilePath, int $position): ?Media
{
if (! is_file($localFilePath)) {
return null;
}
return $product->addMedia($localFilePath)
->preservingOriginal()
->withCustomProperties(['position' => $position])
->toMediaCollection(config('lunar.media.collection'));
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\Brand;
class BrandResolver
{
public function resolve(?string $vendor): ?Brand
{
$vendor = trim((string) $vendor);
if ($vendor === '') {
return null;
}
return Brand::firstOrCreate(['name' => $vendor]);
}
}
@@ -0,0 +1,65 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\FieldTypes\Text;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup;
use Lunar\Models\Product;
use Modules\Core\MigrateImport\DefaultLocale;
class CollectionResolver
{
public function resolve(Product $product, CollectionGroup $group, ?string $categoryPath): void
{
$segments = collect(explode('>', (string) $categoryPath))
->map(fn (string $segment) => trim($segment))
->filter();
if ($segments->isEmpty()) {
return;
}
$parent = null;
foreach ($segments as $segment) {
$parent = $this->findChild($group, $parent, $segment) ?? $this->create($group, $parent, $segment);
}
$product->collections()->syncWithoutDetaching([$parent->id]);
}
private function findChild(CollectionGroup $group, ?Collection $parent, string $name): ?Collection
{
return Collection::query()
->where('collection_group_id', $group->id)
->where('parent_id', $parent?->id)
->get()
->first(fn (Collection $collection) => $collection->translateAttribute('name') === $name);
}
private function create(CollectionGroup $group, ?Collection $parent, string $name): Collection
{
$collection = new Collection([
'collection_group_id' => $group->id,
'attribute_data' => [
'name' => new TranslatedText(collect([
DefaultLocale::code() => new Text($name),
])),
],
]);
// Mass-assigning parent_id triggers NodeTrait's setParentIdAttribute
// mutator before BaseModel's constructor has prefixed the table,
// causing an "undefined table" query. appendToNode()/saveAsRoot()
// avoid that mutator entirely.
if ($parent) {
$collection->appendToNode($parent)->save();
} else {
$collection->saveAsRoot();
}
return $collection;
}
}
@@ -0,0 +1,70 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\FieldTypes\Number;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Attribute;
use Lunar\Models\AttributeGroup;
use Lunar\Models\Product;
use Lunar\Models\ProductType;
use Modules\Core\MigrateImport\DefaultLocale;
class ImportAttributeResolver
{
// Attributes Lunar doesn't ship by default but this importer needs,
// with the field type each one should use.
public const EXTRA_ATTRIBUTES = [
'cost_per_item' => ['label' => 'Cost per item', 'type' => Number::class],
'seo_title' => ['label' => 'SEO Title', 'type' => TranslatedText::class],
'seo_description' => ['label' => 'SEO Description', 'type' => TranslatedText::class],
];
public function ensureMapped(ProductType $productType): void
{
$mappedHandles = $productType->productAttributes()->pluck('handle')->all();
$missing = array_diff(array_keys(self::EXTRA_ATTRIBUTES), $mappedHandles);
if (empty($missing)) {
return;
}
$group = $this->importAttributeGroup();
$nextPosition = Attribute::where('attribute_group_id', $group->id)->max('position') + 1;
foreach ($missing as $handle) {
$definition = self::EXTRA_ATTRIBUTES[$handle];
$attribute = Attribute::firstOrCreate(
['attribute_type' => Product::morphName(), 'handle' => $handle],
[
'attribute_group_id' => $group->id,
'position' => $nextPosition++,
'name' => [DefaultLocale::code() => $definition['label']],
'section' => 'main',
'type' => $definition['type'],
'required' => false,
'default_value' => null,
'configuration' => $definition['type'] === TranslatedText::class
? ['richtext' => false]
: [],
'system' => false,
'description' => [DefaultLocale::code() => ''],
],
);
$productType->mappedAttributes()->syncWithoutDetaching([$attribute->id]);
}
}
private function importAttributeGroup(): AttributeGroup
{
return AttributeGroup::firstOrCreate(
['attributable_type' => Product::morphName(), 'handle' => 'import'],
[
'name' => [DefaultLocale::code() => 'Additional Details'],
'position' => 100,
],
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\Currency;
use Lunar\Models\Price;
use Lunar\Models\ProductVariant;
class PriceResolver
{
public function resolve(ProductVariant $variant, Currency $currency, float $price, ?float $comparePrice): Price
{
return Price::updateOrCreate(
[
'priceable_type' => $variant->getMorphClass(),
'priceable_id' => $variant->id,
'currency_id' => $currency->id,
'customer_group_id' => null,
],
[
'price' => (int) round($price * (10 ** $currency->decimal_places)),
'compare_price' => $comparePrice !== null
? (int) round($comparePrice * (10 ** $currency->decimal_places))
: null,
'min_quantity' => 1,
],
);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\FieldTypes\Number;
use Lunar\FieldTypes\Text;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\ProductType;
use Modules\Core\MigrateImport\DefaultLocale;
class ProductAttributeResolver
{
/**
* @param array<string, ?string> $values keyed by attribute handle, e.g. ['name' => ..., 'description' => ...]
*/
public function resolve(ProductType $productType, array $values): array
{
$mappedHandles = $productType->productAttributes()->pluck('handle')->all();
$attributeData = [];
foreach ($values as $handle => $value) {
if (! in_array($handle, $mappedHandles, true)) {
continue;
}
if (trim((string) $value) === '') {
continue;
}
$attributeData[$handle] = $this->fieldFor($handle, $value);
}
return $attributeData;
}
private function fieldFor(string $handle, string $value): Number|TranslatedText
{
$type = ImportAttributeResolver::EXTRA_ATTRIBUTES[$handle]['type'] ?? TranslatedText::class;
if ($type === Number::class) {
return new Number((float) $value);
}
return new TranslatedText(collect([
DefaultLocale::code() => new Text($value),
]));
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Illuminate\Support\Str;
use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue;
use Modules\Core\MigrateImport\DefaultLocale;
class ProductOptionResolver
{
public function resolveOption(string $name): ProductOption
{
// Match by slugified name (== handle), not raw name, so Shopify's
// inconsistent casing across products ("Size" vs "size") resolves to
// the same option instead of creating a near-duplicate.
$handle = Str::slug($name) ?: 'option';
return ProductOption::query()->firstOrCreate(
['handle' => $handle],
[
'name' => [DefaultLocale::code() => $name],
'shared' => true,
],
);
}
public function resolveValue(ProductOption $option, string $value): ProductOptionValue
{
$slug = Str::slug($value) ?: 'value';
// Query fresh rather than $option->values: that relation is lazily
// cached on first access, so within one product's variant loop it
// would miss a value created earlier in the same loop, creating a
// duplicate for the same option+value pair.
$existing = ProductOptionValue::query()
->where('product_option_id', $option->id)
->get()
->first(fn (ProductOptionValue $optionValue) => Str::slug($optionValue->translate('name')) === $slug);
return $existing ?? ProductOptionValue::create([
'product_option_id' => $option->id,
'name' => [DefaultLocale::code() => $value],
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\Attribute;
use Lunar\Models\Product;
use Lunar\Models\ProductType;
class ProductTypeResolver
{
private const DEFAULT_NAME = 'General';
public function resolve(?string $type): ProductType
{
$name = trim((string) $type) !== '' ? trim($type) : self::DEFAULT_NAME;
$existing = ProductType::where('name', $name)->first();
if ($existing) {
return $existing;
}
$productType = ProductType::create(['name' => $name]);
$productType->mappedAttributes()->attach(
Attribute::whereAttributeType(Product::morphName())->pluck('id'),
);
return $productType;
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Lunar\Models\Product;
use Lunar\Models\Tag;
class TagResolver
{
public function resolve(Product $product, ?string $tags): void
{
$values = collect(explode(',', (string) $tags))
->map(fn (string $tag) => trim($tag))
->filter();
$this->sync($product, $values);
}
private function sync(Product $product, Collection $tags): void
{
$tagIds = $tags
->map(fn (string $tag) => Str::upper($tag))
->map(fn (string $tag) => Tag::firstOrCreate(['value' => $tag])->id);
$product->tags()->sync($tagIds);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\TaxClass;
class TaxClassResolver
{
public function resolve(bool $taxable): TaxClass
{
return TaxClass::getDefault();
}
}
@@ -0,0 +1,55 @@
<?php
namespace Modules\Core\MigrateImport\Shopify;
class ShopifyCsvReader
{
/**
* @return array<int, ProductGroup>
*/
public function read(string $csvPath): array
{
$handle = fopen($csvPath, 'r');
if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}");
}
$headers = fgetcsv($handle);
/** @var array<string, ProductGroup> $groups */
$groups = [];
$order = [];
while (($row = fgetcsv($handle)) !== false) {
$data = array_combine($headers, $row);
$productHandle = trim($data['Handle'] ?? '');
if ($productHandle === '') {
continue;
}
if (! isset($groups[$productHandle])) {
$groups[$productHandle] = new ProductGroup($productHandle, $data);
$order[] = $productHandle;
}
$group = $groups[$productHandle];
$hasVariantData = trim((string) ($data['Option1 Value'] ?? '')) !== ''
|| trim((string) ($data['Variant SKU'] ?? '')) !== '';
if ($hasVariantData) {
$group->variantRows[] = $data;
}
if (trim((string) ($data['Image Src'] ?? '')) !== '') {
$group->imageRows[] = $data;
}
}
fclose($handle);
return array_map(fn (string $handle) => $groups[$handle], $order);
}
}
@@ -0,0 +1,238 @@
<?php
namespace Modules\Core\MigrateImport\Shopify;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup;
use Lunar\Models\Currency;
use Lunar\Models\Language;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Lunar\Models\Url;
use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\Importer;
use Modules\Core\MigrateImport\Models\ImportMapping;
use Modules\Core\MigrateImport\Shopify\Resolvers\AssetResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\BrandResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\CollectionResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\ImportAttributeResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\PriceResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductAttributeResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\TagResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\TaxClassResolver;
class ShopifyExportImporter implements Importer
{
private const SOURCE = 'shopify';
public function __construct(
private readonly ShopifyCsvReader $csvReader = new ShopifyCsvReader(),
private readonly TaxClassResolver $taxClassResolver = new TaxClassResolver(),
private readonly ProductTypeResolver $productTypeResolver = new ProductTypeResolver(),
private readonly BrandResolver $brandResolver = new BrandResolver(),
private readonly TagResolver $tagResolver = new TagResolver(),
private readonly CollectionResolver $collectionResolver = new CollectionResolver(),
private readonly ProductOptionResolver $productOptionResolver = new ProductOptionResolver(),
private readonly AssetResolver $assetResolver = new AssetResolver(),
private readonly PriceResolver $priceResolver = new PriceResolver(),
private readonly ImportAttributeResolver $importAttributeResolver = new ImportAttributeResolver(),
private readonly ProductAttributeResolver $productAttributeResolver = new ProductAttributeResolver(),
) {
}
public function import(ImportSpec $spec): void
{
$groups = $this->csvReader->read($spec->filePath);
$imagesPath = dirname($spec->filePath).'/files';
$collectionGroup = CollectionGroup::firstOrCreate(
['handle' => 'shopify'],
['name' => 'Shopify'],
);
$currency = Currency::getDefault();
foreach ($groups as $group) {
$this->importProduct($group, $imagesPath, $collectionGroup, $currency);
}
}
private function importProduct(
ProductGroup $group,
string $imagesPath,
CollectionGroup $collectionGroup,
Currency $currency,
): void {
$row = $group->productRow;
$taxClass = $this->taxClassResolver->resolve(
filter_var($row['Variant Taxable'] ?? 'true', FILTER_VALIDATE_BOOLEAN),
);
$productType = $this->productTypeResolver->resolve($row['Type'] ?? null);
$brand = $this->brandResolver->resolve($row['Vendor'] ?? null);
$this->importAttributeResolver->ensureMapped($productType);
$attributeData = $this->productAttributeResolver->resolve($productType->fresh(), [
'name' => $row['Title'] ?? '',
'description' => $row['Body (HTML)'] ?? '',
'cost_per_item' => $row['Cost per item'] ?? '',
'seo_title' => $row['SEO Title'] ?? '',
'seo_description' => $row['SEO Description'] ?? '',
]);
$existing = ImportMapping::resolve(self::SOURCE, 'product', $group->handle);
$product = $existing instanceof Product ? $existing : new Product();
$product->product_type_id = $productType->id;
$product->status = filter_var($row['Published'] ?? 'true', FILTER_VALIDATE_BOOLEAN) ? 'published' : 'draft';
$product->brand_id = $brand?->id;
$product->attribute_data = array_merge($product->attribute_data?->toArray() ?? [], $attributeData);
$product->save();
ImportMapping::record(self::SOURCE, 'product', $group->handle, $product);
Url::updateOrCreate(
[
'element_type' => $product->getMorphClass(),
'element_id' => $product->id,
'language_id' => Language::getDefault()->id,
],
[
'slug' => $group->handle,
'default' => true,
],
);
$this->tagResolver->resolve($product, $row['Tags'] ?? null);
$this->collectionResolver->resolve($product, $collectionGroup, $row['Product Category'] ?? null);
$options = $this->attachOptions($product, $row);
foreach ($group->variantRows as $index => $variantRow) {
$this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options);
}
foreach ($group->imageRows as $index => $imageRow) {
$this->importImage($product, $group->handle, $index, $imageRow, $imagesPath);
}
}
/**
* @return array<int, \Lunar\Models\ProductOption>
*/
private function attachOptions(Product $product, array $row): array
{
$options = [];
foreach ([1, 2, 3] as $position) {
$name = trim((string) ($row["Option{$position} Name"] ?? ''));
if ($name === '' || $name === 'Title') {
continue;
}
$option = $this->productOptionResolver->resolveOption($name);
$product->productOptions()->syncWithoutDetaching([$option->id => ['position' => $position]]);
$options[$position] = $option;
}
return $options;
}
private function importVariant(
Product $product,
string $handle,
int $index,
array $row,
\Lunar\Models\TaxClass $taxClass,
Currency $currency,
array $options,
): void {
$externalId = "{$handle}#{$index}";
$existing = ImportMapping::resolve(self::SOURCE, 'variant', $externalId);
$variant = $existing instanceof ProductVariant ? $existing : new ProductVariant();
$variant->product_id = $product->id;
$variant->tax_class_id = $taxClass->id;
$variant->sku = trim((string) ($row['Variant SKU'] ?? '')) ?: null;
$variant->stock = (int) ($row['Variant Inventory Qty'] ?? 0);
$variant->shippable = filter_var($row['Variant Requires Shipping'] ?? 'true', FILTER_VALIDATE_BOOLEAN);
$variant->save();
ImportMapping::record(self::SOURCE, 'variant', $externalId, $variant);
foreach ($options as $position => $option) {
$value = trim((string) ($row["Option{$position} Value"] ?? ''));
if ($value === '') {
continue;
}
$optionValue = $this->productOptionResolver->resolveValue($option, $value);
$variant->values()->syncWithoutDetaching([$optionValue->id]);
}
$price = (float) ($row['Variant Price'] ?? 0);
$comparePrice = trim((string) ($row['Variant Compare At Price'] ?? '')) !== ''
? (float) $row['Variant Compare At Price']
: null;
$this->priceResolver->resolve($variant, $currency, $price, $comparePrice);
}
private function importImage(
Product $product,
string $handle,
int $index,
array $row,
string $imagesPath,
): void {
$externalId = $row['Image Src'] ?: "{$handle}#image-{$index}";
$position = (int) ($row['Image Position'] ?? $index + 1);
if (ImportMapping::resolve(self::SOURCE, 'image', $externalId)) {
return;
}
$localFile = $this->findLocalFile($imagesPath, $row['Image Src']);
if ($localFile === null) {
Log::warning('Shopify import: image file not found', [
'handle' => $handle,
'image_src' => $row['Image Src'],
]);
return;
}
$media = $this->assetResolver->resolve($product, $localFile, $position);
if ($media) {
ImportMapping::record(self::SOURCE, 'image', $externalId, $product);
}
}
private function findLocalFile(string $imagesPath, string $imageSrc): ?string
{
$imagesPath = rtrim($imagesPath, '/');
$filename = basename(parse_url($imageSrc, PHP_URL_PATH) ?? '');
// Shopify CDN filenames often embed a UUID (e.g. "1_ab5a6923-...-b130c6.jpg").
// Export folders name files by that UUID alone, so try matching on it
// before falling back to an exact filename match.
if (preg_match('/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i', $filename, $matches)) {
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$byUuid = "{$imagesPath}/{$matches[0]}".($extension ? ".{$extension}" : '');
if (is_file($byUuid)) {
return $byUuid;
}
}
$byFilename = "{$imagesPath}/{$filename}";
return is_file($byFilename) ? $byFilename : null;
}
}
-31
View File
@@ -2,44 +2,13 @@
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\ServiceProvider;
use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff;
use Lunar\Admin\Support\Facades\LunarPanel;
use Lunar\Models\Customer as LunarCustomer;
use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Mail\InviteMail;
use Modules\Core\Command\CreateAdminCommand;
class AuthServiceProvider extends ServiceProvider
{
public function boot(): void
{
LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class,
]);
LunarStaff::addActivitylogExcept([
'otp_code',
'otp_expires_at',
'password',
'remember_token',
'email_verified_at',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
LunarCustomer::addActivitylogExcept([
'otp_code',
'otp_expires_at',
]);
LunarStaff::created(function (LunarStaff $staff) {
Mail::to($staff->email)->send(new InviteMail($staff->first_name));
});
if ($this->app->runningInConsole()) {
$this->app->booted(fn () => $this->commands([CreateAdminCommand::class]));
}
+19 -3
View File
@@ -2,27 +2,43 @@
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Command\AnonymizeCommand;
use Modules\Core\Command\ExportCleanupCommand;
use Modules\Core\Command\ExportCommand;
use Modules\Core\Command\ImportCommand;
use Modules\Core\Command\InstallLunarCommand;
use Modules\Core\Command\MigrateImportCommand;
class CoreServiceProvider extends ServiceProvider
{
public function register(): void {}
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/core.php', 'core');
}
public function boot(): void
{
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core');
Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core');
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->publishes([
__DIR__ . '/../../resources/logos' => public_path('logos/core'),
__DIR__ . '/../../config/core.php' => config_path('core.php'),
__DIR__ . '/../../config/scout.php' => config_path('scout.php'),
], 'core-config');
$this->publishes([
__DIR__ . '/../../resources/logos' => public_path('static/logos/core'),
__DIR__ . '/../../resources/js/stoic_embed.js' => public_path('js/stoic_embed.js'),
], 'core-assets');
if ($this->app->runningInConsole()) {
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class]);
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]);
//Overriding lunar:install
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
}
}
}
+5 -7
View File
@@ -2,22 +2,20 @@
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Admin\Support\Facades\LunarPanel;
use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Customer as LunarCustomer;
use Modules\Core\Customer\Extensions\CustomerResourceExtension;
use Modules\Core\Auth\Events\UserCreated;
use Modules\Core\Customer\Listeners\CreateCustomerForUser;
use Modules\Core\Customer\Models\Customer;
class CustomerServiceProvider extends ServiceProvider
{
public function boot(): void
{
LunarPanel::extensions([
CustomerResource::class => CustomerResourceExtension::class,
]);
ModelManifest::replace(LunarCustomer::class, Customer::class);
Event::listen(UserCreated::class, CreateCustomerForUser::class);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Livewire\Mechanisms\ComponentRegistry;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Carriers\Acs\AcsClient;
use Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService;
use Modules\Core\Shipping\Carriers\Acs\AcsRateDriver;
use Modules\Core\Shipping\Carriers\Acs\Jobs\WarmAcsAreaCacheJob;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
use Modules\Core\Shipping\Models\Shipment;
class ShippingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs');
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow');
$this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs')));
$this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow')));
$this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) {
return match ($params['carrier'] ?? null) {
'acs' => $app->make(AcsFulfillmentService::class),
'box-now' => $app->make(BoxNowFulfillmentService::class),
default => null,
};
});
// The vendor Rates page has no extension hook, so we swap it for
// our subclass everywhere. Route::get($path, VendorClass::class)
// instantiates the vendor class directly via the container for the
// initial full-page load (bypassing Livewire's component registry
// entirely), so this container bind is required in addition to the
// Livewire::component() re-registration below — the bind covers
// first load, the Livewire registration covers every AJAX
// round-trip (form submits, table interactions) afterwards.
$this->app->bind(VendorManageShippingRates::class, ManageShippingRates::class);
}
public function boot(): void
{
$this->publishes([
__DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
], 'core-config');
Order::resolveRelationUsing('shipments', function ($order) {
return $order->hasMany(Shipment::class);
});
// Deferred: the Shipping facade resolves a binding registered in
// lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(),
// and provider boot order between packages isn't guaranteed.
$this->app->booted(function () {
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
$this->app->make(ConsoleSchedule::class)
->job(new WarmAcsAreaCacheJob)
->dailyAt('06:00')
->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
$this->app->make(ConsoleSchedule::class)
->job(new PollShipmentTrackingJob)
->everyThirtyMinutes();
$this->overrideRatesPageLivewireComponent();
});
}
/**
* The vendor Rates page has no extension hook, so we swap it for our
* subclass (see Shipping/Filament/Pages/ManageShippingRates). Filament
* already registered the vendor class as a Livewire component under a
* name derived from its class string (see
* Panel::registerLivewireComponents()); Livewire's own registry is a
* simple last-write-wins name => class map, so re-registering the same
* derived name against our subclass here overrides it — keeping the
* route, sub-navigation, and every Livewire round-trip (including form
* submissions) pointed at one consistent component identity.
*/
private function overrideRatesPageLivewireComponent(): void
{
$name = $this->app->make(ComponentRegistry::class)->getName(VendorManageShippingRates::class);
Livewire::component($name, ManageShippingRates::class);
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Review\Extensions;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Review\Pages\ManageProductReviews;
class ProductResourceExtension extends ResourceExtension
{
public function extendPages(array $pages): array
{
$pages['reviews'] = ManageProductReviews::route('/{record}/reviews');
return $pages;
}
public function extendSubNavigation(array $pages): array
{
return [...$pages, ManageProductReviews::class];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Review\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Lunar\Models\Product;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class ProductReview extends Model implements HasMedia
{
use InteractsWithMedia;
public const IMAGES_COLLECTION = 'review-images';
protected $guarded = [];
protected $casts = [
'reviewed_at' => 'datetime',
'replied_at' => 'datetime',
];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function registerMediaCollections(): void
{
$this->addMediaCollection(self::IMAGES_COLLECTION);
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace Modules\Core\Review\Pages;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Support\Pages\BaseManageRelatedRecords;
use Modules\Core\Review\Models\ProductReview;
class ManageProductReviews extends BaseManageRelatedRecords
{
protected static string $resource = ProductResource::class;
protected static string $relationship = 'reviews';
public static function getNavigationIcon(): ?string
{
return 'heroicon-o-star';
}
public function getTitle(): string
{
return 'Reviews';
}
public static function getNavigationLabel(): string
{
return 'Reviews';
}
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('rating')
->label('Rating')
->disabled(),
TextInput::make('title')
->label('Title')
->disabled(),
Textarea::make('body')
->label('Body')
->disabled()
->columnSpanFull(),
Group::make([
TextInput::make('reviewer_name')
->label('Reviewer name')
->disabled(),
TextInput::make('reviewer_email')
->label('Reviewer email')
->disabled(),
])->columns(2),
Group::make([
TextInput::make('location')
->label('Location')
->disabled(),
TextInput::make('source')
->label('Source')
->disabled(),
])->columns(2),
Group::make([
TextInput::make('reviewed_at')
->label('Reviewed at')
->disabled(),
TextInput::make('replied_at')
->label('Replied at')
->disabled(),
])->columns(2),
Textarea::make('reply')
->label('Reply')
->disabled()
->columnSpanFull(),
Placeholder::make('images')
->label('Images')
->content(function (?ProductReview $record) {
if (! $record) {
return null;
}
$media = $record->getMedia(ProductReview::IMAGES_COLLECTION);
if ($media->isEmpty()) {
return 'No images';
}
$tags = $media->map(fn ($item) => sprintf(
'<a href="%1$s" target="_blank"><img src="%1$s" style="height:6rem;width:6rem;object-fit:cover;border-radius:0.5rem;display:inline-block;margin:0.25rem" /></a>',
$item->getUrl(),
))->implode('');
return new HtmlString($tags);
})
->columnSpanFull(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('title')
->columns([
TextColumn::make('rating')
->label('Rating')
->formatStateUsing(fn (int $state) => str_repeat('★', $state).str_repeat('☆', 5 - $state))
->sortable(),
TextColumn::make('title')
->label('Title')
->limit(40)
->searchable(),
TextColumn::make('reviewer_name')
->label('Reviewer')
->searchable(),
])
->defaultSort('reviewed_at', 'desc')
->filters([
//
])
->actions([
ViewAction::make(),
Action::make('reply')
->label(fn (ProductReview $record) => $record->reply ? 'Edit reply' : 'Reply')
->icon('heroicon-o-chat-bubble-left-right')
->form([
Textarea::make('reply')
->label('Reply')
->required(),
])
->fillForm(fn (ProductReview $record) => ['reply' => $record->reply])
->action(function (ProductReview $record, array $data) {
$record->update([
'reply' => $data['reply'],
'replied_at' => Carbon::now(),
]);
}),
DeleteAction::make(),
])
->bulkActions([
DeleteBulkAction::make(),
]);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Search;
use Illuminate\Database\Eloquent\Model;
use Lunar\Search\ProductIndexer as BaseProductIndexer;
/**
* Lunar's own indexer puts raw attribute HTML (e.g. name_en, description_en) into
* the search index, which pollutes relevance ranking and highlighting with markup.
* Strip tags from string fields before they reach Meilisearch.
*/
class ProductIndexer extends BaseProductIndexer
{
public function toSearchableArray(Model $model): array
{
$data = parent::toSearchableArray($model);
foreach ($data as $key => $value) {
if (is_string($value)) {
$data[$key] = trim(strip_tags($value));
}
}
return $data;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
class AcsArea
{
public function __construct(
public readonly string $stationId,
public readonly int $branchId,
) {}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Support\Facades\Http;
class AcsClient
{
public function __construct(private readonly array $config) {}
public function call(string $alias, array $parameters = []): AcsResponse
{
$response = Http::withHeaders([
'AcsApiKey' => $this->config['api_key'],
])
->timeout($this->config['timeout'])
->post($this->config['base_url'], [
'ACSAlias' => $alias,
'ACSInputParameters' => array_merge($this->credentialParams(), $parameters),
]);
return AcsResponse::fromHttpResponse($response);
}
private function credentialParams(): array
{
return [
'Company_ID' => $this->config['company_id'],
'Company_Password' => $this->config['company_password'],
'User_ID' => $this->config['user_id'],
'User_Password' => $this->config['user_password'],
];
}
}
@@ -0,0 +1,202 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking
{
public function __construct(
private readonly AcsClient $client,
private readonly AreaResolver $areaResolver,
) {}
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$address = $order->shippingAddress;
$destination = $this->areaResolver->resolve($address->postcode);
$weight = $request->weight ?? 0.5;
$params = [
'Pickup_Date' => now()->toDateString(),
'Sender' => config('acs.sender.name'),
'Recipient_Name' => trim("{$address->first_name} {$address->last_name}"),
'Recipient_Address' => $address->line_one,
'Recipient_Zipcode' => $address->postcode,
'Recipient_Region' => $address->city,
'Recipient_Phone' => $address->contact_phone,
'Recipient_Country' => 'GR',
'Acs_Station_Branch_Destination' => $destination->branchId,
'Billing_Code' => config('acs.billing_code'),
'Charge_Type' => 2,
'Item_Quantity' => $request->packageCount,
'Weight' => $weight,
];
if ($request->paymentMode === 'cod') {
$params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal;
$params['Cod_Payment_Way'] = 0; // cash
$params['Acs_Delivery_Products'] = 'COD';
}
$response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError();
$voucherNo = (string) $response->valueOutput['Voucher_No'];
$shipment = Shipment::create([
'order_id' => $order->id,
'carrier' => 'acs',
'tracking_reference' => $voucherNo,
'meta' => [
'station_destination' => $destination->stationId,
'weight' => $weight,
'pickup_date' => now()->toDateString(),
],
]);
if ($request->packageCount > 1) {
$this->persistMultipartVouchers($shipment);
}
return $shipment;
}
public function printLabel(Shipment $shipment): string
{
$response = $this->client->call('ACS_Print_Voucher', [
'Voucher_No' => $shipment->tracking_reference,
'Print_Type' => 2,
'Start_Position' => 1,
])->throwIfError();
$shipment->update(['label_printed_at' => now()]);
return $response->valueOutput[$shipment->tracking_reference] ?? '';
}
public function cancelShipment(Shipment $shipment): void
{
if ($shipment->manifest_reference) {
throw new \RuntimeException('Cannot cancel a shipment already included in an issued manifest.');
}
$this->client->call('ACS_Delete_Voucher', [
'Voucher_No' => $shipment->tracking_reference,
])->throwIfError();
$shipment->update(['cancelled_at' => now()]);
}
public function pendingForManifest(): Collection
{
return Shipment::query()
->where('carrier', 'acs')
->whereNull('manifest_reference')
->whereNull('cancelled_at')
->get();
}
public function issueManifest(Collection $shipments): ManifestResult
{
$unprinted = $shipments->whereNull('label_printed_at');
if ($unprinted->isNotEmpty()) {
return ManifestResult::blocked($unprinted, 'unprinted');
}
$response = $this->client->call('ACS_Issue_Pickup_List', [
'Pickup_Date' => now()->toDateString(),
'MyData' => null,
])->throwIfError();
$pickupListNo = (string) $response->valueOutput['PickupList_No'];
$shipments->each(fn (Shipment $shipment) => $shipment->update([
'manifest_reference' => $pickupListNo,
]));
return ManifestResult::success($pickupListNo, $shipments);
}
public function trackShipment(Shipment $shipment): Collection
{
$response = $this->client->call('ACS_TrackingDetails', [
'Voucher_No' => $shipment->tracking_reference,
])->throwIfError();
$rows = $response->tableOutput['Table_Data'] ?? [];
// ACS's per-checkpoint data (checkpoint_action) is free text with no
// status code, so the final checkpoint's status is corroborated
// against the structured summary call rather than guessed from text.
$isDelivered = $this->isDelivered($shipment);
return collect($rows)->values()->map(function (array $row, int $index) use ($rows, $isDelivered) {
$isLast = $index === count($rows) - 1;
return new TrackingCheckpoint(
status: $isLast && $isDelivered
? TrackingStatus::Delivered
: $this->guessStatusFromAction($row['checkpoint_action'] ?? ''),
carrierStatus: $row['checkpoint_action'] ?? null,
message: $row['checkpoint_action'] ?? null,
location: $row['checkpoint_location'] ?? null,
occurredAt: Carbon::parse($row['checkpoint_date_time']),
meta: $row,
);
});
}
private function isDelivered(Shipment $shipment): bool
{
try {
$response = $this->client->call('ACS_Trackingsummary', [
'Voucher_No' => $shipment->tracking_reference,
])->throwIfError();
} catch (AcsApiException) {
return false;
}
return (int) ($response->valueOutput['shipment_status'] ?? 0) === 4;
}
private function guessStatusFromAction(string $action): TrackingStatus
{
$action = strtolower($action);
return match (true) {
str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered,
str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery,
str_contains($action, 'arrival') || str_contains($action, 'departure') => TrackingStatus::InTransit,
default => TrackingStatus::Pending,
};
}
private function persistMultipartVouchers(Shipment $mainShipment): void
{
$response = $this->client->call('ACS_Get_Multipart_Vouchers', [
'Main_Voucher_No' => $mainShipment->tracking_reference,
])->throwIfError();
foreach ($response->tableOutput['Table_Data'] ?? [] as $row) {
Shipment::create([
'order_id' => $mainShipment->order_id,
'carrier' => 'acs',
'tracking_reference' => $row['MultiPart_Voucher_No'],
'parent_reference' => $mainShipment->tracking_reference,
'meta' => $mainShipment->meta?->toArray() ?? [],
]);
}
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Lunar\DataTypes\Price;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
{
use ResolvesFixedPricing;
public ShippingRate $shippingRate;
public function __construct(
private readonly AcsClient $client,
private readonly AreaResolver $areaResolver,
) {}
public function name(): string
{
return 'ACS Courier';
}
public function description(): string
{
return 'Live rate quote from ACS Courier.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
$shippingRate = $shippingOptionRequest->shippingRate;
$shippingMethod = $shippingRate->shippingMethod;
$cart = $shippingOptionRequest->cart;
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
}
$postcode = $cart->shippingAddress?->postcode;
if (! $postcode) {
return null;
}
return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode);
}
private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption
{
try {
$destination = $this->areaResolver->resolve($postcode);
$response = $this->client->call('ACS_Price_Calculation', [
'Billing_Code' => config('acs.billing_code'),
'Acs_Station_Destination' => $destination->stationId,
'Weight' => $this->totalWeightInKg($cart),
'Pickup_Date' => now()->toDateString(),
'Charge_Type' => 2,
])->throwIfError();
} catch (AcsApiException $e) {
report($e);
return null;
}
$amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
return new ShippingOption(
name: $shippingMethod->name ?: $this->name(),
description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(),
price: new Price($amount, $cart->currency, 1),
taxClass: $shippingRate->getTaxClass(),
taxReference: $shippingRate->getTaxReference(),
meta: ['acs_station_destination' => $destination->stationId],
);
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
private function totalWeightInKg($cart): float
{
$weight = 0.0;
foreach ($cart->lines->load('purchasable') as $line) {
$variant = $line->purchasable;
if (! $variant || ! $variant->weight_value) {
continue;
}
$unit = $variant->weight_unit ?? 'kg';
$value = (float) $variant->weight_value;
$weight += match ($unit) {
'g' => $value / 1000,
'lb' => $value * 0.45359237,
'oz' => $value * 0.0283495231,
default => $value, // kg
} * $line->quantity;
}
return max($weight, 0.5); // ACS minimum billable weight
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Http\Client\Response;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
class AcsResponse
{
private function __construct(
public readonly bool $hasError,
public readonly ?string $errorMessage,
public readonly array $valueOutput,
public readonly array $tableOutput,
) {}
public static function fromHttpResponse(Response $response): self
{
$body = $response->json() ?? [];
// ACS's own JSON key is misspelled ("Responce") — preserved here verbatim.
$output = $body['ACSOutputResponce'] ?? [];
return new self(
hasError: (bool) ($body['ACSExecution_HasError'] ?? ! $response->successful()),
errorMessage: $body['ACSExecutionErrorMessage'] ?? null,
valueOutput: $output['ACSValueOutput'][0] ?? [],
tableOutput: $output['ACSTableOutput'] ?? [],
);
}
public function throwIfError(): self
{
if ($this->hasError) {
throw new AcsApiException(
$this->errorMessage ?? ($this->valueOutput['Error_Message'] ?? 'Unknown ACS API error'),
$this->tableOutput,
);
}
return $this;
}
}
@@ -0,0 +1,81 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
class AreaResolver
{
public const CACHE_KEY = 'acs.areas';
public function __construct(private readonly AcsClient $client) {}
/**
* Resolve a Greek postcode to its ACS station/branch codes.
*
* Reads from the table warmed daily by WarmAcsAreaCacheJob. Falls
* back to a live lookup for that single postcode if the warmed cache is
* missing (e.g. the daily job never ran or failed) or doesn't contain it.
*/
public function resolve(string $postcode): AcsArea
{
$areas = Cache::get(self::CACHE_KEY);
if ($areas !== null && isset($areas[$postcode])) {
return $this->toArea($areas[$postcode]);
}
return $this->toArea($this->fetch($postcode));
}
/**
* Fetch and cache the full country's postcode-to-station map in one call.
*/
public function warmAll(): void
{
$areas = [];
foreach ($this->fetchAll() as $row) {
$areas[$row['Zip_Code']] = $row;
}
Cache::forever(self::CACHE_KEY, $areas);
}
private function fetch(string $postcode): array
{
$response = $this->client->call('ACS_Area_Find_By_Zip_Code', [
'Zip_Code' => $postcode,
'Show_Only_Inaccessible_Areas' => 0,
'Country' => 'GR',
])->throwIfError();
$area = $response->tableOutput['Table_Data'][0] ?? null;
if (! $area) {
throw new AcsApiException("No ACS area found for postcode {$postcode}");
}
return $area;
}
private function fetchAll(): array
{
$response = $this->client->call('ACS_Area_Find_By_Zip_Code', [
'Zip_Code' => null,
'Show_Only_Inaccessible_Areas' => 0,
'Country' => 'GR',
])->throwIfError();
return $response->tableOutput['Table_Data'] ?? [];
}
private function toArea(array $row): AcsArea
{
return new AcsArea(
stationId: $row['Station_ID'],
branchId: (int) $row['Branch_ID'],
);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs\Exceptions;
use RuntimeException;
class AcsApiException extends RuntimeException
{
public function __construct(string $message, public readonly array $tableOutput = [])
{
parent::__construct($message);
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Core\Shipping\Carriers\Acs\AreaResolver;
class WarmAcsAreaCacheJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function handle(AreaResolver $areaResolver): void
{
$areaResolver->warmAll();
}
}
@@ -0,0 +1,97 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
class BoxNowClient
{
private const TOKEN_CACHE_KEY = 'boxnow.access_token';
public function __construct(private readonly array $config) {}
/**
* GET/POST against the main delivery/parcel API, authenticated with a
* cached Bearer token.
*/
public function request(string $method, string $path, array $payload = []): array
{
$response = Http::withToken($this->token())
->timeout($this->config['timeout'])
->{$method}("{$this->config['base_url']}{$path}", $payload);
if ($response->status() === 401) {
// Token expired early / was revoked — refresh once and retry.
Cache::forget(self::TOKEN_CACHE_KEY);
$response = Http::withToken($this->token())
->timeout($this->config['timeout'])
->{$method}("{$this->config['base_url']}{$path}", $payload);
}
if ($response->failed()) {
throw new BoxNowApiException(
"Box Now API error ({$response->status()}) on {$method} {$path}",
$response->json() ?? [],
);
}
return $response->json() ?? [];
}
/**
* The origins/destinations lookups are served faster from a separate
* location API host, per Box Now's own documentation.
*/
public function locationRequest(string $path, array $query = []): array
{
$response = Http::withToken($this->token())
->timeout($this->config['timeout'])
->get("{$this->config['location_api_url']}{$path}", $query);
if ($response->failed()) {
throw new BoxNowApiException(
"Box Now location API error ({$response->status()}) on GET {$path}",
$response->json() ?? [],
);
}
return $response->json() ?? [];
}
/**
* Fetch raw bytes (e.g. a PDF label) rather than JSON.
*/
public function requestRaw(string $path): string
{
$response = Http::withToken($this->token())
->timeout($this->config['timeout'])
->get("{$this->config['base_url']}{$path}");
if ($response->failed()) {
throw new BoxNowApiException("Box Now API error ({$response->status()}) on GET {$path}");
}
return $response->body();
}
private function token(): string
{
return Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(55), function () {
$response = Http::timeout($this->config['timeout'])
->post("{$this->config['base_url']}/auth-sessions", [
'grant_type' => 'client_credentials',
'client_id' => $this->config['client_id'],
'client_secret' => $this->config['client_secret'],
]);
if ($response->failed()) {
throw new BoxNowApiException('Box Now authentication failed', $response->json() ?? []);
}
return $response->json('access_token');
});
}
}
@@ -0,0 +1,150 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
/**
* Unlike ACS, Box Now has no manifest/pickup-list step — creating a
* delivery request also books the courier pickup, so this only implements
* CarrierFulfillmentInterface (not SupportsManifestBatching).
*
* Box Now delivers to lockers, not addresses. The storefront locker-picker
* is out of scope for this pass — createShipment() requires the chosen
* locker's Box Now locationId via ShipmentRequest::$destinationLocationId
* (e.g. set manually by admin staff until checkout UI exists).
*/
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
{
public function __construct(private readonly BoxNowClient $client) {}
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$address = $order->shippingAddress;
$destinationLocationId = $request->destinationLocationId;
if (! $destinationLocationId) {
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
}
$isCod = $request->paymentMode === 'cod';
$response = $this->client->request('post', '/delivery-requests', [
'orderNumber' => $order->reference.'-'.$order->id,
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
'paymentMode' => $isCod ? 'cod' : 'prepaid',
'amountToBeCollected' => $isCod
? number_format($request->amountToCollect ?? $order->total->decimal, 2, '.', '')
: '0.00',
'origin' => [
'contactNumber' => config('boxnow.sender.phone'),
'contactEmail' => config('boxnow.sender.email'),
'contactName' => config('boxnow.sender.name'),
'locationId' => config('boxnow.origin_location_id'),
],
'destination' => [
'contactNumber' => $address->contact_phone,
'contactEmail' => $address->contact_email,
'contactName' => trim("{$address->first_name} {$address->last_name}"),
'locationId' => $destinationLocationId,
],
'items' => [
[
'id' => (string) $order->id,
'name' => 'Order '.$order->reference,
'value' => '0.00',
'compartmentSize' => 1,
'weight' => $request->weight ?? 0,
],
],
]);
$parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException(
'Box Now delivery request succeeded but returned no parcel id.',
$response,
));
return Shipment::create([
'order_id' => $order->id,
'carrier' => 'box-now',
'tracking_reference' => $parcelId,
'meta' => [
'delivery_request_id' => $response['id'] ?? null,
'locker_id' => $destinationLocationId,
],
]);
}
public function printLabel(Shipment $shipment): string
{
$bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf");
$shipment->update(['label_printed_at' => now()]);
return $bytes;
}
public function cancelShipment(Shipment $shipment): void
{
$this->client->request('post', "/parcels/{$shipment->tracking_reference}:cancel");
$shipment->update(['cancelled_at' => now()]);
}
public function trackShipment(Shipment $shipment): Collection
{
$response = $this->client->request('get', '/parcels', [
'parcelId' => $shipment->tracking_reference,
]);
$parcel = $response['data'][0] ?? null;
if (! $parcel) {
return collect();
}
$events = $parcel['events'] ?? [];
// Fall back to a single checkpoint from the parcel's current state
// if Box Now didn't return a detailed events history.
if (empty($events)) {
$events = [[
'type' => $parcel['state'] ?? 'new',
'locationDisplayName' => null,
'createTime' => $parcel['updateTime'] ?? $parcel['createTime'] ?? now()->toIso8601String(),
]];
}
return collect($events)->map(fn (array $event) => new TrackingCheckpoint(
status: $this->mapState($event['type'] ?? $parcel['state'] ?? 'new'),
carrierStatus: $event['type'] ?? $parcel['state'] ?? null,
message: null,
location: $event['locationDisplayName'] ?? null,
occurredAt: Carbon::parse($event['createTime']),
meta: $event,
));
}
private function mapState(string $state): TrackingStatus
{
return match ($state) {
'new' => TrackingStatus::Pending,
'in-transit', 'in-depot' => TrackingStatus::InTransit,
'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery,
'delivered' => TrackingStatus::Delivered,
'returned', 'accepted-for-return' => TrackingStatus::Returned,
'cancelled' => TrackingStatus::Cancelled,
'expired-return', 'missing' => TrackingStatus::Failed,
default => TrackingStatus::Unknown,
};
}
}
@@ -0,0 +1,48 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
/**
* Box Now has no pricing API, so this always resolves the method's normal
* charge_by + price-break configuration — the same mechanism the built-in
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
* there is no live option to offer.
*/
class BoxNowRateDriver implements ShippingRateInterface
{
use ResolvesFixedPricing;
public ShippingRate $shippingRate;
public function name(): string
{
return 'Box Now Locker Delivery';
}
public function description(): string
{
return 'Deliver to a Box Now parcel locker.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod,
$shippingOptionRequest->cart,
);
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow\Exceptions;
use RuntimeException;
class BoxNowApiException extends RuntimeException
{
public function __construct(string $message, public readonly array $body = [])
{
parent::__construct($message);
}
}
@@ -0,0 +1,42 @@
<?php
namespace Modules\Core\Shipping\Concerns;
use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\Pricing;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate;
/**
* Shared by any carrier driver that also supports Lunar's own price-break
* pricing (charge_by = cart_total | weight) as a fallback to, or standalone
* alternative for, live API pricing. Mirrors the vendor ShipBy driver's
* charge_by handling exactly, so behavior is consistent with the rest of
* Lunar's shipping system rather than inventing a separate mechanism.
*/
trait ResolvesFixedPricing
{
private function resolveFixedPrice(ShippingRate $shippingRate, ShippingMethod $shippingMethod, $cart): ?ShippingOption
{
$chargeBy = $shippingMethod->data['charge_by'] ?? 'cart_total';
$tier = $chargeBy === 'weight'
? $cart->lines->load('purchasable')->sum(fn ($line) => ($line->purchasable->weight_value ?? 0) * $line->quantity)
: $cart->lines->sum('subTotal.value');
$pricing = Pricing::for($shippingRate)->qty($tier)->get();
if (! $pricing->matched) {
return null;
}
return new ShippingOption(
name: $shippingMethod->name ?: $this->name(),
description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(),
price: $pricing->matched->price,
taxClass: $shippingRate->getTaxClass(),
taxReference: $shippingRate->getTaxReference(),
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Lunar\Models\Order;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
use Modules\Core\Shipping\Models\Shipment;
interface CarrierFulfillmentInterface
{
/**
* Create a shipment with the carrier for the given order.
*/
public function createShipment(Order $order, ShipmentRequest $request): Shipment;
/**
* Fetch the printable label for a shipment (raw file bytes).
*/
public function printLabel(Shipment $shipment): string;
/**
* Cancel a shipment with the carrier.
*/
public function cancelShipment(Shipment $shipment): void;
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Shipping\Contracts;
/**
* Marker for a Lunar\Shipping\Interfaces\ShippingRateInterface driver that
* calculates its price from a live carrier API rather than the manually
* configured ShippingRate price/price-breaks. Admin UI uses this to hide
* the manual pricing fields for such drivers — carriers without a pricing
* API simply don't implement it, and manual pricing remains required.
*/
interface SupportsLivePricing {}
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Illuminate\Support\Collection;
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
/**
* Optional capability for carriers that batch shipments into a manifest
* before courier pickup (e.g. ACS's end-of-day pickup list). Carriers
* without this concept simply don't implement it.
*/
interface SupportsManifestBatching
{
/**
* Shipments created but not yet included in an issued manifest.
*/
public function pendingForManifest(): Collection;
/**
* Finalize the given shipments into a manifest with the carrier.
*/
public function issueManifest(Collection $shipments): ManifestResult;
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Illuminate\Support\Collection;
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
use Modules\Core\Shipping\Models\Shipment;
/**
* Optional capability for carriers that expose shipment tracking. Carriers
* without a tracking API simply don't implement it.
*/
interface SupportsTracking
{
/**
* Return the shipment's known checkpoints from the carrier — as many
* as the carrier's API returns in one call, not just the latest one.
* Deduplication against what's already stored happens elsewhere.
*
* @return Collection<int, TrackingCheckpoint>
*/
public function trackShipment(Shipment $shipment): Collection;
}
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Shipping\DataTransferObjects;
use Illuminate\Support\Collection;
class ManifestResult
{
private function __construct(
public readonly bool $success,
public readonly ?string $reference,
public readonly Collection $includedShipments,
public readonly Collection $blockedShipments,
public readonly ?string $reason,
) {}
public static function success(string $reference, Collection $includedShipments): self
{
return new self(true, $reference, $includedShipments, collect(), null);
}
public static function blocked(Collection $blockedShipments, string $reason): self
{
return new self(false, null, collect(), $blockedShipments, $reason);
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Shipping\DataTransferObjects;
/**
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
* Every field is optional — a carrier reads only what it needs and ignores
* the rest (e.g. destinationLocationId only matters to locker-delivery
* carriers like Box Now; ACS has no use for it).
*/
class ShipmentRequest
{
public function __construct(
public readonly ?float $weight = null,
public readonly int $packageCount = 1,
public readonly ?string $destinationLocationId = null,
public readonly ?string $paymentMode = null,
public readonly ?float $amountToCollect = null,
) {}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Shipping\DataTransferObjects;
use Carbon\CarbonInterface;
use Modules\Core\Shipping\Enums\TrackingStatus;
/**
* One normalized checkpoint in a shipment's carrier-reported history.
* Returned (potentially several at once) by SupportsTracking::trackShipment().
*/
class TrackingCheckpoint
{
public function __construct(
public readonly TrackingStatus $status,
public readonly ?string $carrierStatus,
public readonly ?string $message,
public readonly ?string $location,
public readonly CarbonInterface $occurredAt,
public readonly array $meta = [],
) {}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Shipping\Enums;
/**
* Normalized shipment status, mapped from each carrier's own status codes
* (e.g. ACS's shipment_status/non_delivery_reason_code, Box Now's parcel
* state) so the rest of the system never needs to know carrier-specific
* vocabulary.
*/
enum TrackingStatus: string
{
case Pending = 'pending';
case InTransit = 'in_transit';
case OutForDelivery = 'out_for_delivery';
case Delivered = 'delivered';
case Failed = 'failed';
case Returned = 'returned';
case Cancelled = 'cancelled';
case Unknown = 'unknown';
public function isTerminal(): bool
{
return match ($this) {
self::Delivered, self::Returned, self::Cancelled => true,
default => false,
};
}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Shipping\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* Fired once per newly-recorded ShipmentInfo checkpoint. Listeners (e.g.
* customer notifications, order status sync) are added separately.
*/
class ShipmentStatusUpdatedByCarrier
{
use Dispatchable;
public function __construct(public readonly ShipmentInfo $shipmentInfo) {}
}
@@ -0,0 +1,116 @@
<?php
namespace Modules\Core\Shipping\Extensions;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Order;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
class OrderViewExtension extends ViewPageExtension
{
public function headerActions(array $actions): array
{
$actions[] = $this->createShipmentAction();
return $actions;
}
private function createShipmentAction(): Actions\Action
{
return Actions\Action::make('create_shipment')
->label('Create Shipment')
->icon('heroicon-o-truck')
->modalSubmitActionLabel('Create Shipment')
->form([
Forms\Components\TextInput::make('weight')
->label('Package weight (kg)')
->numeric()
->minValue(0)
->helperText('Leave blank to use the carrier\'s default.'),
Forms\Components\TextInput::make('destination_location_id')
->label('Box Now locker ID')
->helperText('Only required for Box Now shipments.')
->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null),
Forms\Components\Toggle::make('confirm')
->label('Confirm')
->helperText('This will create a real shipment with the carrier.')
->rules([
function () {
return function (string $attribute, $value, \Closure $fail) {
if ($value !== true) {
$fail('Please confirm before creating the shipment.');
}
};
},
]),
])
->action(function (Order $record, array $data, Actions\Action $action) {
$service = $this->resolveFulfillmentService($record);
if (! $service) {
Notification::make()
->title('No carrier fulfillment integration is configured for this order.')
->danger()
->send();
$action->halt();
return;
}
$request = new ShipmentRequest(
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
destinationLocationId: $data['destination_location_id'] ?? null,
);
try {
$service->createShipment($record, $request);
} catch (\Throwable $e) {
report($e);
Notification::make()
->title('Failed to create shipment: '.$e->getMessage())
->danger()
->send();
$action->halt();
return;
}
Notification::make()
->title('Shipment created.')
->success()
->send();
})
->visible(fn (Order $record) => $record->shipments()->exists() === false
&& $this->resolveFulfillmentService($record) !== null);
}
private function resolveCarrier(Order $record): ?string
{
$code = $record->shippingAddress?->shipping_option;
if (! $code) {
return null;
}
return ShippingMethod::where('code', $code)->value('driver');
}
private function resolveFulfillmentService(Order $record): ?CarrierFulfillmentInterface
{
$carrier = $this->resolveCarrier($record);
if (! $carrier) {
return null;
}
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
}
}
@@ -0,0 +1,48 @@
<?php
namespace Modules\Core\Shipping\Extensions;
use Filament\Actions;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Select;
use Lunar\Admin\Support\Extending\BaseExtension;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
/**
* ListShippingMethod::getDefaultHeaderActions() builds its CreateAction's
* form inline (calling ShippingMethodResource::getDriverFormComponent()
* directly, a hardcoded 2-option Select) rather than through the resource's
* own extendForm() pipeline, so ShippingMethodResourceExtension's driver
* fix never reaches it. Re-declares the same create-action form with a
* dynamic driver Select instead.
*/
class ShippingMethodListExtension extends BaseExtension
{
public function headerActions(array $actions): array
{
foreach ($actions as $action) {
if ($action instanceof Actions\CreateAction) {
$action->form([
ShippingMethodResource::getNameFormComponent(),
Group::make([
ShippingMethodResource::getCodeFormComponent(),
$this->driverSelect(),
])->columns(2),
ShippingMethodResource::getDescriptionFormComponent(),
]);
}
}
return $actions;
}
private function driverSelect(): Select
{
return Select::make('driver')
->label('Type')
->options(fn () => collect(Shipping::getSupportedDrivers())
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
->default('flat-rate');
}
}
@@ -0,0 +1,152 @@
<?php
namespace Modules\Core\Shipping\Extensions;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Concerns\HasChildComponents;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
class ShippingMethodResourceExtension extends ResourceExtension
{
public function extendForm(Form $form): Form
{
return $form->schema(
$this->replaceChargeByField(
$this->replaceDriverField($form->getComponents())
)
);
}
/**
* Extend the vendor's cart_total/weight charge_by Select with a third
* "live" option only offered when the currently selected driver
* supports live pricing (see SupportsLivePricing). Picking it is what
* tells the driver to call its carrier API instead of resolving a
* price break.
*/
private function replaceChargeByField(array $components): array
{
return array_map(function (Component $component) {
if (method_exists($component, 'getName') && $component->getName() === 'charge_by') {
return $this->chargeBySelect();
}
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema(
$this->replaceChargeByField($component->getChildComponents())
);
}
return $component;
}, $components);
}
private function chargeBySelect(): Select
{
return Select::make('charge_by')
->label('Charge by')
->options(function (Get $get) {
$options = [
'cart_total' => 'Cart Total',
'weight' => 'Weight',
];
// "charge_by" is nested inside a Group with
// ->statePath('data'), while "driver" sits one level up, at
// the form root. Note: an *absolute* path here would need to
// additionally account for the page's own form wrapper
// (EditRecord::getFormStatePath() === 'data'), which relative
// paths never cross — so "../driver" (relative) is the
// correct, page-independent way to reach it, not an
// absolute 'driver' string.
if ($this->driverSupportsLivePricing($get('../driver'))) {
$options['live'] = 'Live API pricing';
}
return $options;
})
->live();
}
private function driverSupportsLivePricing(?string $driver): bool
{
if (! $driver) {
return false;
}
try {
return Shipping::driver($driver) instanceof SupportsLivePricing;
} catch (\InvalidArgumentException) {
return false;
}
}
public function extendTable(Table $table): Table
{
return $table->columns(
array_map(function ($column) {
if (method_exists($column, 'getName') && $column->getName() === 'driver') {
return $this->driverColumn();
}
return $column;
}, $table->getColumns())
);
}
private function driverColumn(): TextColumn
{
return TextColumn::make('driver')
->label('Type')
->formatStateUsing(fn ($state) => $this->driverLabel($state));
}
private function driverLabel(string $key): string
{
$driver = collect(Shipping::getSupportedDrivers())->get($key);
return $driver?->name() ?? $key;
}
/**
* Recursively walk the form tree and replace the hardcoded driver
* Select (nested inside Section > Group) with one listing every
* registered driver, built-in or custom.
*
* @param array<Component> $components
* @return array<Component>
*/
private function replaceDriverField(array $components): array
{
return array_map(function (Component $component) {
if (method_exists($component, 'getName') && $component->getName() === 'driver') {
return $this->driverSelect();
}
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema(
$this->replaceDriverField($component->getChildComponents())
);
}
return $component;
}, $components);
}
private function driverSelect(): Select
{
return Select::make('driver')
->label('Type')
->options(fn () => collect(Shipping::getSupportedDrivers())
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
->default('flat-rate')
->live();
}
}
@@ -0,0 +1,106 @@
<?php
namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Models\Shipment;
class ManagePickupManifests extends Page implements HasTable
{
use InteractsWithTable;
protected static ?string $navigationIcon = 'heroicon-o-truck';
protected static ?string $navigationLabel = 'Pickup Manifests';
protected static string $view = 'core::shipping.filament.pages.manage-pickup-manifests';
public function table(Table $table): Table
{
return $table
->query($this->pendingQuery())
->columns([
TextColumn::make('carrier')->badge(),
TextColumn::make('tracking_reference')->label('Tracking #'),
TextColumn::make('order.reference')->label('Order'),
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
])
->actions([
Action::make('print')
->label('Print')
->icon('heroicon-o-printer')
->action(fn (Shipment $record) => $this->printShipment($record)),
])
->bulkActions([
BulkAction::make('print_selected')
->label('Print selected')
->icon('heroicon-o-printer')
->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => $this->printShipment($shipment))),
BulkAction::make('issue_manifest')
->label('Issue Manifest')
->icon('heroicon-o-check-circle')
->action(fn (Collection $records) => $this->issueManifest($records)),
]);
}
private function pendingQuery(): Builder
{
$carriers = collect(Shipping::getSupportedDrivers())->keys()->filter(
fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsManifestBatching
);
return Shipment::query()
->whereIn('carrier', $carriers)
->whereNull('manifest_reference')
->whereNull('cancelled_at');
}
private function printShipment(Shipment $shipment): void
{
$this->fulfillmentService($shipment->carrier)?->printLabel($shipment);
}
private function issueManifest(Collection $shipments): void
{
$shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) {
$service = $this->fulfillmentService($carrier);
if (! $service instanceof SupportsManifestBatching) {
return;
}
$result = $service->issueManifest($group);
if (! $result->success) {
Notification::make()
->title("Manifest blocked for {$carrier}: {$result->reason}")
->danger()
->send();
return;
}
Notification::make()
->title("Manifest issued for {$carrier}: {$result->reference}")
->success()
->send();
});
}
private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
{
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate;
/**
* Bound in place of the vendor ManageShippingRates page via the container
* (see ShippingServiceProvider), since that page has no extension hook of
* its own. Every reference to the vendor class name routes, sub-nav,
* ShippingZoneResource::getPages() is untouched; the container simply
* hands back this subclass whenever the vendor class is resolved.
*
* Hides the price / price-break fields for a rate whose method has
* charge_by = "live" (see ShippingMethodResourceExtension, which adds that
* option to methods whose driver supports live pricing) those fields
* would otherwise be dead configuration the driver never reads. Pricing
* strategy (cart_total / weight / live) stays entirely on the Shipping
* Method, matching Lunar's own existing charge_by convention; nothing new
* is stored on the rate itself.
*/
class ManageShippingRates extends BaseManageShippingRates
{
public function form(Form $form): Form
{
$form = parent::form($form);
return $form->schema(
$this->hidePriceFieldsWhenLive($form->getComponents())
);
}
private function hidePriceFieldsWhenLive(array $components): array
{
$isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live';
foreach ($components as $component) {
if (! method_exists($component, 'getName')) {
continue;
}
if ($component->getName() === 'price') {
$component->visible($isNotLive)->required($isNotLive)->dehydrated(true);
}
if ($component->getName() === 'prices') {
$component->visible($isNotLive)->dehydrated(true);
}
}
return $components;
}
public function table(Table $table): Table
{
$table = parent::table($table);
return $table->columns(
array_map(function ($column) {
if (method_exists($column, 'getName') && $column->getName() === 'basePrices.0') {
return TextColumn::make('basePrices.0')
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label'))
->formatStateUsing(function ($state, ShippingRate $record) {
if (static::methodChargeBy($record->shipping_method_id) === 'live') {
return 'Live API pricing';
}
return $state?->price->formatted;
});
}
return $column;
}, $table->getColumns())
);
}
protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void
{
if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') {
return;
}
parent::saveShippingRate($shippingRate, $data);
}
protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string
{
if (blank($method)) {
return null;
}
if (! $method instanceof ShippingMethod) {
$method = ShippingMethod::find($method);
}
return $method?->data['charge_by'] ?? null;
}
}
@@ -0,0 +1,107 @@
<?php
namespace Modules\Core\Shipping\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* Carrier-agnostic: polls every Shipment not yet in a terminal state,
* skipping carriers whose fulfillment service doesn't implement
* SupportsTracking. New checkpoints are recorded in shipment_info and
* dispatch ShipmentStatusUpdatedByCarrier one event per new checkpoint.
*/
class PollShipmentTrackingJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function handle(): void
{
$trackableCarriers = collect(Shipping::getSupportedDrivers())->keys()->filter(
fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsTracking
);
if ($trackableCarriers->isEmpty()) {
return;
}
Shipment::query()
->whereIn('carrier', $trackableCarriers)
->whereNull('cancelled_at')
->whereDoesntHave('shipmentInfo', function ($query) {
$query->whereIn('status', [
TrackingStatus::Delivered->value,
TrackingStatus::Returned->value,
TrackingStatus::Cancelled->value,
]);
})
->chunkById(50, function ($shipments) {
$shipments->groupBy('carrier')->each(
fn ($group, $carrier) => $this->pollCarrierShipments($carrier, $group)
);
});
}
private function pollCarrierShipments(string $carrier, $shipments): void
{
$service = $this->fulfillmentService($carrier);
if (! $service instanceof SupportsTracking) {
return;
}
foreach ($shipments as $shipment) {
$this->recordNewCheckpoints($shipment, $service->trackShipment($shipment));
}
}
private function recordNewCheckpoints(Shipment $shipment, $checkpoints): void
{
$existing = $shipment->shipmentInfo()
->get(['status', 'occurred_at'])
->map(fn ($info) => $info->status->value.'|'.$info->occurred_at->toIso8601String())
->flip();
foreach ($checkpoints as $checkpoint) {
$fingerprint = $checkpoint->status->value.'|'.$checkpoint->occurredAt->toIso8601String();
if ($existing->has($fingerprint)) {
continue;
}
$info = ShipmentInfo::create([
'shipment_id' => $shipment->id,
'status' => $checkpoint->status,
'carrier_status' => $checkpoint->carrierStatus,
'message' => $checkpoint->message,
'location' => $checkpoint->location,
'occurred_at' => $checkpoint->occurredAt,
'meta' => $checkpoint->meta,
]);
ShipmentStatusUpdatedByCarrier::dispatch($info);
}
}
private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
{
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Shipping\Models;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Lunar\Models\Order;
class Shipment extends Model
{
protected $guarded = [];
protected $casts = [
'meta' => AsArrayObject::class,
'label_printed_at' => 'datetime',
'cancelled_at' => 'datetime',
];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function shipmentInfo(): HasMany
{
return $this->hasMany(ShipmentInfo::class);
}
public function latestShipmentInfo(): ?ShipmentInfo
{
return $this->shipmentInfo()->latest('occurred_at')->first();
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Shipping\Models;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Core\Shipping\Enums\TrackingStatus;
/**
* One recorded checkpoint in a Shipment's carrier-reported tracking
* history. Append-only never updated in place, so the full history is
* preserved rather than overwriting the last-known status.
*/
class ShipmentInfo extends Model
{
protected $table = 'shipment_info';
protected $guarded = [];
protected $casts = [
'status' => TrackingStatus::class,
'occurred_at' => 'datetime',
'meta' => AsArrayObject::class,
];
public function shipment(): BelongsTo
{
return $this->belongsTo(Shipment::class);
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace Modules\Core\Stoic;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Yaml\Yaml;
class Stoic
{
public static array $config = [];
public static array $presets = [];
public static string $thumbs_path;
private static function loadConfig(): void
{
if (!empty(static::$config)) {
return;
}
$config = Yaml::parseFile(resource_path("stoic/stoic_config.yml"));
static::$config = $config;
static::$thumbs_path = $config["thumbs_path"];
static::$presets = $config["presets"];
}
public static function presets(): array
{
static::loadConfig();
return static::$presets;
}
// Returns the public URL for a stoic image at a given preset.
public static function image(string $filename, string $preset): string
{
static::loadConfig();
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($ext === "svg") {
$rel = static::thumbsRelPath() . "images/" . $filename;
return Storage::disk("public")->url($rel);
}
$webp = substr($filename, 0, -strlen($ext)) . "webp";
$rel = static::thumbsRelPath() . "presets/" . $preset . "/" . $webp;
return Storage::disk("public")->url($rel);
}
// Generates the full srcset string for all configured presets.
public static function srcset(string $filename): string
{
static::loadConfig();
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($ext === "svg") {
return "";
}
return collect(static::$presets)
->map(
fn($p) => static::image($filename, $p["code"]) .
" " .
$p["width"] .
"w",
)
->implode(", ");
}
// Returns [width, height] for a given preset from the sidecar JSON.
public static function dimensions(string $filename, string $preset): array
{
static::loadConfig();
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($ext === "svg") {
return [null, null];
}
$rel = static::thumbsRelPath() . "images/" . $filename . ".json";
$path = Storage::disk("public")->path($rel);
$meta = json_decode(@file_get_contents($path), true);
$p = $meta["presets"][$preset] ?? null;
if (!$p) {
return [null, null];
}
return [(int) $p["w"], (int) $p["h"]];
}
// Path of the thumbs directory relative to the public storage disk root.
// Uses a fixed marker so it works regardless of the app's base path (local vs Docker).
private static function thumbsRelPath(): string
{
$marker = "/storage/app/public/";
$pos = strpos(static::$thumbs_path, $marker);
if ($pos !== false) {
return substr(static::$thumbs_path, $pos + strlen($marker));
}
return ltrim(static::$thumbs_path, "/");
}
public static function token(string $email): string
{
$ttl = 60;
$payload = base64_encode(
json_encode([
"email" => $email,
"exp" => time() + $ttl,
]),
);
$sig = hash_hmac(
"sha256",
$payload,
config("services.stoic.sso_secret"),
);
$token = "t=" . urlencode($payload) . "&s=" . $sig;
return $token;
}
public static function hasMany(
string $relation,
array $slugs,
string $prefix,
) {
$slugs = array_map(
fn($i) => str_replace($prefix . "/", "", $i),
$slugs,
);
return $relation::whereIn("slug", $slugs)->get();
}
public static function hasOne(
string $relation,
string $slug,
string $prefix,
) {
$slug = str_replace($prefix . "/", "", $slug);
return $relation::firstWhere("slug", $slug);
}
}