Compare commits
16 Commits
dfbf85ef33
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 96ad89b6b8 | |||
| c07b36e1f9 | |||
| 2767a4bb69 | |||
| 1d2a90059d | |||
| 364368ba3d | |||
| 51c43c11b5 | |||
| 90ac32094c | |||
| df03866285 | |||
| 825ce885c0 | |||
| cd9632a6bf | |||
| 677b240a8d | |||
| ab213f69ec | |||
| ebe905acfa | |||
| d35dff3731 | |||
| ce08287641 | |||
| 15bff15cad |
@@ -0,0 +1,45 @@
|
|||||||
|
# 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.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()`).
|
||||||
@@ -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/`.
|
||||||
@@ -122,3 +122,5 @@ php artisan migrate
|
|||||||
- [`docs/otp-auth.md`](docs/otp-auth.md) — OTP authentication flow
|
- [`docs/otp-auth.md`](docs/otp-auth.md) — OTP authentication flow
|
||||||
- [`docs/activity-log.md`](docs/activity-log.md) — Activity logging
|
- [`docs/activity-log.md`](docs/activity-log.md) — Activity logging
|
||||||
- [`docs/lunar.md`](docs/lunar.md) — Lunar framework reference
|
- [`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
|
||||||
|
|||||||
+7
-3
@@ -2,7 +2,7 @@
|
|||||||
"name": "boboko/core",
|
"name": "boboko/core",
|
||||||
"description": "Core module — authentication and shared panel behaviour",
|
"description": "Core module — authentication and shared panel behaviour",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"version": "1.0.0",
|
"version": "0.1.1",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Modules\\Core\\": "src/"
|
"Modules\\Core\\": "src/"
|
||||||
@@ -12,7 +12,9 @@
|
|||||||
"php": "^8.5",
|
"php": "^8.5",
|
||||||
"lunarphp/lunar": "1.3.0",
|
"lunarphp/lunar": "1.3.0",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^3.0"
|
"laravel/tinker": "^3.0",
|
||||||
|
"symfony/yaml": "^7.0",
|
||||||
|
"lunarphp/table-rate-shipping": "^1.3"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
@@ -27,7 +29,9 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"providers": [
|
"providers": [
|
||||||
"Modules\\Core\\Providers\\CoreServiceProvider"
|
"Modules\\Core\\Providers\\CoreServiceProvider",
|
||||||
|
"Modules\\Core\\Providers\\AuthServiceProvider",
|
||||||
|
"Modules\\Core\\Providers\\CustomerServiceProvider"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+24
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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:import "Lunar\Models\Product" # index products
|
||||||
php artisan scout:flush "Lunar\Models\Order" # flush order index
|
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
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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,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 }}
|
||||||
|
/>
|
||||||
@@ -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\Content;
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
class CustomerOtpMail extends Mailable
|
class UserOtpMail extends Mailable
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $name,
|
public readonly string $name,
|
||||||
@@ -20,6 +20,6 @@ class CustomerOtpMail extends Mailable
|
|||||||
|
|
||||||
public function content(): Content
|
public function content(): Content
|
||||||
{
|
{
|
||||||
return new Content(view: 'core::auth.mail.customer-otp');
|
return new Content(view: 'core::auth.mail.user-otp');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?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" is wired up for now.
|
||||||
|
private const SOURCES = [
|
||||||
|
"shopify",
|
||||||
|
// "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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-10
@@ -3,12 +3,12 @@
|
|||||||
namespace Modules\Core;
|
namespace Modules\Core;
|
||||||
|
|
||||||
use Filament\Contracts\Plugin;
|
use Filament\Contracts\Plugin;
|
||||||
use Filament\Navigation\NavigationItem;
|
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Lunar\Admin\Filament\Resources\StaffResource;
|
use Lunar\Admin\Filament\Resources\StaffResource;
|
||||||
use Lunar\Admin\Models\Staff as LunarStaff;
|
use Lunar\Admin\Models\Staff as LunarStaff;
|
||||||
use Lunar\Admin\Support\Facades\LunarPanel;
|
use Lunar\Admin\Support\Facades\LunarPanel;
|
||||||
|
use Lunar\Shipping\ShippingPlugin;
|
||||||
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
||||||
use Modules\Core\Auth\Filament\Pages\Login;
|
use Modules\Core\Auth\Filament\Pages\Login;
|
||||||
use Modules\Core\Auth\Mail\InviteMail;
|
use Modules\Core\Auth\Mail\InviteMail;
|
||||||
@@ -24,16 +24,10 @@ class CorePlugin implements Plugin
|
|||||||
{
|
{
|
||||||
$panel->path('boboko')
|
$panel->path('boboko')
|
||||||
->brandName('Boboko')
|
->brandName('Boboko')
|
||||||
->brandLogo(asset('logos/core/boboko-logo.svg'))
|
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
|
||||||
->darkModeBrandLogo(asset('logos/core/boboko-logo-white.svg'))
|
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
|
||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
->navigationItems([
|
->plugin(ShippingPlugin::make());
|
||||||
NavigationItem::make("Lucent")
|
|
||||||
->url("/lucent")
|
|
||||||
->icon("heroicon-o-sun")
|
|
||||||
->group("Content")
|
|
||||||
->sort(1),
|
|
||||||
]);
|
|
||||||
|
|
||||||
LunarPanel::extensions([
|
LunarPanel::extensions([
|
||||||
StaffResource::class => StaffResourceExtension::class,
|
StaffResource::class => StaffResourceExtension::class,
|
||||||
|
|||||||
@@ -3,17 +3,21 @@
|
|||||||
namespace Modules\Core\Customer\Extensions;
|
namespace Modules\Core\Customer\Extensions;
|
||||||
|
|
||||||
use Lunar\Admin\Filament\Resources\CustomerResource\RelationManagers\AddressRelationManager as BaseAddressRelationManager;
|
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 Lunar\Admin\Support\Extending\BaseExtension;
|
||||||
use Modules\Core\Customer\RelationManagers\AddressRelationManager;
|
use Modules\Core\Customer\RelationManagers\AddressRelationManager;
|
||||||
|
use Modules\Core\Customer\RelationManagers\UserRelationManager;
|
||||||
|
|
||||||
class CustomerResourceExtension extends BaseExtension
|
class CustomerResourceExtension extends BaseExtension
|
||||||
{
|
{
|
||||||
public function getRelations(array $relations): array
|
public function getRelations(array $relations): array
|
||||||
{
|
{
|
||||||
return array_map(
|
return array_map(
|
||||||
fn ($relation) => $relation == BaseAddressRelationManager::class
|
fn ($relation) => match ($relation) {
|
||||||
? AddressRelationManager::class
|
BaseAddressRelationManager::class => AddressRelationManager::class,
|
||||||
: $relation,
|
BaseUserRelationManager::class => UserRelationManager::class,
|
||||||
|
default => $relation,
|
||||||
|
},
|
||||||
$relations
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,8 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Customer\Models;
|
namespace Modules\Core\Customer\Models;
|
||||||
|
|
||||||
use Illuminate\Auth\Authenticatable;
|
class Customer extends \Lunar\Models\Customer
|
||||||
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
|
|
||||||
use Illuminate\Foundation\Auth\Access\Authorizable;
|
|
||||||
|
|
||||||
class Customer extends \Lunar\Models\Customer implements AuthenticatableContract
|
|
||||||
{
|
{
|
||||||
use Authenticatable;
|
|
||||||
use Authorizable;
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'otp_expires_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function getTitleAttribute(): ?string
|
public function getTitleAttribute(): ?string
|
||||||
{
|
{
|
||||||
return null;
|
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(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\MigrateImport;
|
||||||
|
|
||||||
|
use Lunar\Models\Language;
|
||||||
|
|
||||||
|
class DefaultLocale
|
||||||
|
{
|
||||||
|
public static function code(): string
|
||||||
|
{
|
||||||
|
return Language::getDefault()->code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\MigrateImport;
|
||||||
|
|
||||||
|
interface Importer
|
||||||
|
{
|
||||||
|
public function import(ImportSpec $spec): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\MigrateImport;
|
||||||
|
|
||||||
|
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(),
|
||||||
|
// ["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,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(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,224 @@
|
|||||||
|
<?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\Product;
|
||||||
|
use Lunar\Models\ProductVariant;
|
||||||
|
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);
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,44 +2,13 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Providers;
|
namespace Modules\Core\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
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;
|
use Modules\Core\Command\CreateAdminCommand;
|
||||||
|
|
||||||
class AuthServiceProvider extends ServiceProvider
|
class AuthServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function boot(): void
|
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()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->app->booted(fn () => $this->commands([CreateAdminCommand::class]));
|
$this->app->booted(fn () => $this->commands([CreateAdminCommand::class]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,42 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Providers;
|
namespace Modules\Core\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Modules\Core\Command\AnonymizeCommand;
|
use Modules\Core\Command\AnonymizeCommand;
|
||||||
use Modules\Core\Command\ExportCleanupCommand;
|
use Modules\Core\Command\ExportCleanupCommand;
|
||||||
use Modules\Core\Command\ExportCommand;
|
use Modules\Core\Command\ExportCommand;
|
||||||
use Modules\Core\Command\ImportCommand;
|
use Modules\Core\Command\ImportCommand;
|
||||||
|
use Modules\Core\Command\InstallLunarCommand;
|
||||||
|
use Modules\Core\Command\MigrateImportCommand;
|
||||||
|
|
||||||
class CoreServiceProvider extends ServiceProvider
|
class CoreServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void {}
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->mergeConfigFrom(__DIR__ . '/../../config/core.php', 'core');
|
||||||
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core');
|
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core');
|
||||||
|
Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core');
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
|
||||||
|
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__ . '/../../resources/logos' => public_path('logos/core'),
|
__DIR__ . '/../../config/core.php' => config_path('core.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');
|
], 'core-assets');
|
||||||
|
|
||||||
if ($this->app->runningInConsole()) {
|
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]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,20 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Providers;
|
namespace Modules\Core\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Lunar\Admin\Filament\Resources\CustomerResource;
|
|
||||||
use Lunar\Admin\Support\Facades\LunarPanel;
|
|
||||||
use Lunar\Facades\ModelManifest;
|
use Lunar\Facades\ModelManifest;
|
||||||
use Lunar\Models\Contracts\Customer as LunarCustomer;
|
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;
|
use Modules\Core\Customer\Models\Customer;
|
||||||
|
|
||||||
class CustomerServiceProvider extends ServiceProvider
|
class CustomerServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
LunarPanel::extensions([
|
|
||||||
CustomerResource::class => CustomerResourceExtension::class,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ModelManifest::replace(LunarCustomer::class, Customer::class);
|
ModelManifest::replace(LunarCustomer::class, Customer::class);
|
||||||
|
|
||||||
|
Event::listen(UserCreated::class, CreateCustomerForUser::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user