Compare commits
11 Commits
v0.1.0
...
08135c4c8f
| Author | SHA1 | Date | |
|---|---|---|---|
| 08135c4c8f | |||
| e18b2fa44c | |||
| 4c00369005 | |||
| cf6aa343f1 | |||
| ee9893bd64 | |||
| 0524380ab0 | |||
| ed496716eb | |||
| 1d25e0d746 | |||
| 96ad89b6b8 | |||
| c07b36e1f9 | |||
| 2767a4bb69 |
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-07-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Meilisearch product search**: pulled in `lunarphp/search` (Lunar's driver-agnostic search abstraction — `database`/`meilisearch`/`typesense` engines, selectable via Scout's own `SCOUT_DRIVER` config) and `lunarphp/meilisearch`, wiring Meilisearch in as the search engine for products.
|
||||||
|
- `Search\ProductIndexer` overrides Lunar's own indexer to strip HTML tags from string fields (e.g. `name_en`, `description_en`) before they reach the search index — Lunar's default indexer sends raw attribute HTML straight through, which pollutes relevance ranking and highlighting with markup.
|
||||||
|
- Meilisearch itself is treated as app-level infrastructure, not a `boboko-core` concern: the actual Meilisearch container, host port, and master key live in each consuming app's own `docker-compose.yml`/`.env` (e.g. `3dealer`), the same way Postgres and Valkey do — `boboko-core` only declares the PHP package dependency and the indexing code.
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-07-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Product reviews** (`Modules\Core\Review`): a new `ProductReview` model + `product_reviews` table (plain, unprefixed — same convention as `import_mappings`), linked to Lunar's `Product` via a `Product::reviews()` macro (registered in `CorePlugin`, since `Lunar\Models\Product` is a vendor model and can't be edited directly).
|
||||||
|
- **JudgeMe CSV review importer** (`MigrateImport\JudgeMe\JudgeMeExportImporter`), wired into the existing `boboko:migrate:import --source=judgeme --type=export` command: reads a Judge.me review export, resolves each row's `product_handle` to a Lunar product via `Lunar\Models\Url`, and creates/updates `ProductReview` rows idempotently via `import_mappings` (`source=judgeme`, `source_type=review`, keyed on Judge.me's `metaobject_handle`). Rows with no matching product are skipped with a logged warning rather than failing the whole import.
|
||||||
|
- Review images (`picture_urls` in the CSV) are downloaded and stored as real media via Spatie MediaLibrary (`ProductReview::IMAGES_COLLECTION`), not just linked by URL — consistent with how product images are handled.
|
||||||
|
- **Admin UI**: a new "Reviews" sub-navigation page on the product edit screen (`Review\Pages\ManageProductReviews`, wired via `Review\Extensions\ProductResourceExtension`), listing rating/title/reviewer with View, Reply, and Delete actions. The Reply action lets staff write/edit a reply directly from the table, setting `replied_at`. The View modal shows full review detail (body, reviewer email, location, source, dates, reply, downloaded images).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Shopify\ShopifyExportImporter` never wrote a Lunar `Url` (slug) row for imported products, despite `docs/shopify-import.md` specifying it should — meaning no code outside the importer itself could resolve "which Lunar product has handle X" (only the importer's own private `import_mappings` bookkeeping could). It now creates/updates a default `Url` row (`slug` = Shopify handle) per product on every import, which the new JudgeMe review importer depends on for product resolution.
|
||||||
|
|
||||||
## [0.1.0] - 2026-07-09
|
## [0.1.0] - 2026-07-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+4
-2
@@ -2,7 +2,7 @@
|
|||||||
"name": "boboko/core",
|
"name": "boboko/core",
|
||||||
"description": "Core module — authentication and shared panel behaviour",
|
"description": "Core module — authentication and shared panel behaviour",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Modules\\Core\\": "src/"
|
"Modules\\Core\\": "src/"
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"symfony/yaml": "^7.0",
|
"symfony/yaml": "^7.0",
|
||||||
"lunarphp/table-rate-shipping": "^1.3"
|
"lunarphp/table-rate-shipping": "^1.3",
|
||||||
|
"lunarphp/search": "*",
|
||||||
|
"lunarphp/meilisearch": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Search Engine
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default search connection that gets used while
|
||||||
|
| using Laravel Scout. This connection is used when syncing all models
|
||||||
|
| to the search service. You should adjust this based on your needs.
|
||||||
|
|
|
||||||
|
| Supported: "algolia", "meilisearch", "typesense",
|
||||||
|
| "database", "collection", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => env('SCOUT_DRIVER', 'collection'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Index Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify a prefix that will be applied to all search index
|
||||||
|
| names used by Scout. This prefix may be useful if you have multiple
|
||||||
|
| "tenants" or applications sharing the same search infrastructure.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env('SCOUT_PREFIX', ''),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Data Syncing
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to control if the operations that sync your data
|
||||||
|
| with your search engines are queued. When this is set to "true" then
|
||||||
|
| all automatic data syncing will get queued for better performance.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'queue' => env('SCOUT_QUEUE', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Database Transactions
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This configuration option determines if your data will only be synced
|
||||||
|
| with your search indexes after every open database transaction has
|
||||||
|
| been committed, thus preventing any discarded data from syncing.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'after_commit' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Chunk Sizes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These options allow you to control the maximum chunk size when you are
|
||||||
|
| mass importing data into the search engine. This allows you to fine
|
||||||
|
| tune each of these chunk sizes based on the power of the servers.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'chunk' => [
|
||||||
|
'searchable' => 500,
|
||||||
|
'unsearchable' => 500,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Soft Deletes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows to control whether to keep soft deleted records in
|
||||||
|
| the search indexes. Maintaining soft deleted records can be useful
|
||||||
|
| if your application still needs to search for the records later.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'soft_delete' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Identify User
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to control whether to notify the search engine
|
||||||
|
| of the user performing the search. This is sometimes useful if the
|
||||||
|
| engine supports any analytics based on this application's users.
|
||||||
|
|
|
||||||
|
| Supported engines: "algolia"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'identify' => env('SCOUT_IDENTIFY', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Algolia Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your Algolia settings. Algolia is a cloud hosted
|
||||||
|
| search engine which works great with Scout out of the box. Just plug
|
||||||
|
| in your application ID and admin API key to get started searching.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'algolia' => [
|
||||||
|
'id' => env('ALGOLIA_APP_ID', ''),
|
||||||
|
'secret' => env('ALGOLIA_SECRET', ''),
|
||||||
|
'index-settings' => [
|
||||||
|
// 'users' => [
|
||||||
|
// 'searchableAttributes' => ['id', 'name', 'email'],
|
||||||
|
// 'attributesForFaceting'=> ['filterOnly(email)'],
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Meilisearch Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your Meilisearch settings. Meilisearch is an open
|
||||||
|
| source search engine with minimal configuration. Below, you can state
|
||||||
|
| the host and key information for your own Meilisearch installation.
|
||||||
|
|
|
||||||
|
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'meilisearch' => [
|
||||||
|
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
|
||||||
|
'key' => env('MEILISEARCH_KEY'),
|
||||||
|
'index-settings' => [
|
||||||
|
// 'users' => [
|
||||||
|
// 'filterableAttributes'=> ['id', 'name', 'email'],
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Typesense Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your Typesense settings. Typesense is an open
|
||||||
|
| source search engine using minimal configuration. Below, you will
|
||||||
|
| state the host, key, and schema configuration for the instance.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'typesense' => [
|
||||||
|
'client-settings' => [
|
||||||
|
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
|
||||||
|
'nodes' => [
|
||||||
|
[
|
||||||
|
'host' => env('TYPESENSE_HOST', 'localhost'),
|
||||||
|
'port' => env('TYPESENSE_PORT', '8108'),
|
||||||
|
'path' => env('TYPESENSE_PATH', ''),
|
||||||
|
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'nearest_node' => [
|
||||||
|
'host' => env('TYPESENSE_HOST', 'localhost'),
|
||||||
|
'port' => env('TYPESENSE_PORT', '8108'),
|
||||||
|
'path' => env('TYPESENSE_PATH', ''),
|
||||||
|
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
|
||||||
|
],
|
||||||
|
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
|
||||||
|
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
|
||||||
|
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
|
||||||
|
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
|
||||||
|
],
|
||||||
|
// 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
|
||||||
|
'model-settings' => [
|
||||||
|
// User::class => [
|
||||||
|
// 'collection-schema' => [
|
||||||
|
// 'fields' => [
|
||||||
|
// [
|
||||||
|
// 'name' => 'id',
|
||||||
|
// 'type' => 'string',
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// 'name' => 'name',
|
||||||
|
// 'type' => 'string',
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// 'name' => 'created_at',
|
||||||
|
// 'type' => 'int64',
|
||||||
|
// ],
|
||||||
|
// ],
|
||||||
|
// 'default_sorting_field' => 'created_at',
|
||||||
|
// ],
|
||||||
|
// 'search-parameters' => [
|
||||||
|
// 'query_by' => 'name'
|
||||||
|
// ],
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
'import_action' => env('TYPESENSE_IMPORT_ACTION', 'upsert'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('product_reviews', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('product_id')->constrained(config('lunar.database.table_prefix').'products');
|
||||||
|
$table->string('title')->nullable();
|
||||||
|
$table->text('body')->nullable();
|
||||||
|
$table->unsignedTinyInteger('rating');
|
||||||
|
$table->timestamp('reviewed_at')->nullable();
|
||||||
|
$table->string('reviewer_name')->nullable();
|
||||||
|
$table->string('reviewer_email')->nullable();
|
||||||
|
$table->text('reply')->nullable();
|
||||||
|
$table->timestamp('replied_at')->nullable();
|
||||||
|
$table->string('location')->nullable();
|
||||||
|
$table->string('source')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('product_reviews');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('shipments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('order_id')->constrained(config('lunar.database.table_prefix').'orders');
|
||||||
|
$table->string('carrier');
|
||||||
|
$table->string('tracking_reference')->unique();
|
||||||
|
$table->string('parent_reference')->nullable();
|
||||||
|
$table->timestamp('label_printed_at')->nullable();
|
||||||
|
$table->string('manifest_reference')->nullable();
|
||||||
|
$table->timestamp('cancelled_at')->nullable();
|
||||||
|
$table->json('meta')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('shipments');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,43 +8,44 @@ use Modules\Core\MigrateImport\RunMigrateImportJob;
|
|||||||
|
|
||||||
class MigrateImportCommand extends Command
|
class MigrateImportCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = "boboko:migrate:import
|
protected $signature = 'boboko:migrate:import
|
||||||
{--source= : The source platform to import from}
|
{--source= : The source platform to import from}
|
||||||
{--type= : The import mechanism for the chosen source}
|
{--type= : The import mechanism for the chosen source}
|
||||||
{--file= : Path to the export file (when type is export)}";
|
{--file= : Path to the export file (when type is export)}';
|
||||||
|
|
||||||
protected $description = "Migrate data (products, customers, etc.) from an external source";
|
protected $description = 'Migrate data (products, customers, etc.) from an external source';
|
||||||
|
|
||||||
// Supported sources. Only "shopify" is wired up for now.
|
// Supported sources. Only "shopify" and "judgeme" are wired up for now.
|
||||||
private const SOURCES = [
|
private const SOURCES = [
|
||||||
"shopify",
|
'shopify',
|
||||||
|
'judgeme',
|
||||||
// "woocommerce",
|
// "woocommerce",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Supported import types per source. Only "export" is wired up for now.
|
// Supported import types per source. Only "export" is wired up for now.
|
||||||
private const TYPES = [
|
private const TYPES = [
|
||||||
"export",
|
'export',
|
||||||
// "api",
|
// "api",
|
||||||
];
|
];
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
$source = $this->option("source")
|
$source = $this->option('source')
|
||||||
?? $this->choice("Select a source:", self::SOURCES, 0);
|
?? $this->choice('Select a source:', self::SOURCES, 0);
|
||||||
|
|
||||||
$type = $this->option("type")
|
$type = $this->option('type')
|
||||||
?? $this->choice("Select an import type:", self::TYPES, 0);
|
?? $this->choice('Select an import type:', self::TYPES, 0);
|
||||||
|
|
||||||
if ($type === "api") {
|
if ($type === 'api') {
|
||||||
$credentials = [
|
$credentials = [
|
||||||
"api_key" => $this->secret("API key:"),
|
'api_key' => $this->secret('API key:'),
|
||||||
"api_secret" => $this->secret("API secret:"),
|
'api_secret' => $this->secret('API secret:'),
|
||||||
];
|
];
|
||||||
$filePath = null;
|
$filePath = null;
|
||||||
} else {
|
} else {
|
||||||
$importsPath = storage_path("app/private/imports");
|
$importsPath = storage_path('app/private/imports');
|
||||||
|
|
||||||
$filePath = $this->option("file")
|
$filePath = $this->option('file')
|
||||||
?? $this->resolveImportPath($importsPath, $this->ask("Path to the export file, relative to {$importsPath}:"));
|
?? $this->resolveImportPath($importsPath, $this->ask("Path to the export file, relative to {$importsPath}:"));
|
||||||
|
|
||||||
while (blank($filePath) || ! is_file($filePath)) {
|
while (blank($filePath) || ! is_file($filePath)) {
|
||||||
@@ -70,7 +71,7 @@ class MigrateImportCommand extends Command
|
|||||||
|
|
||||||
RunMigrateImportJob::dispatch($spec);
|
RunMigrateImportJob::dispatch($spec);
|
||||||
|
|
||||||
$this->info("Import queued.");
|
$this->info('Import queued.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Answers are relative to storage/app/private/imports (e.g. "shopify" or
|
// Answers are relative to storage/app/private/imports (e.g. "shopify" or
|
||||||
@@ -82,7 +83,7 @@ class MigrateImportCommand extends Command
|
|||||||
return $answer;
|
return $answer;
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = str_starts_with($answer, "/") ? $answer : "{$importsPath}/{$answer}";
|
$path = str_starts_with($answer, '/') ? $answer : "{$importsPath}/{$answer}";
|
||||||
|
|
||||||
if (is_dir($path)) {
|
if (is_dir($path)) {
|
||||||
$csv = collect(glob("{$path}/*.csv"))->first();
|
$csv = collect(glob("{$path}/*.csv"))->first();
|
||||||
|
|||||||
@@ -4,14 +4,19 @@ namespace Modules\Core;
|
|||||||
|
|
||||||
use Filament\Contracts\Plugin;
|
use Filament\Contracts\Plugin;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Lunar\Admin\Filament\Resources\ProductResource;
|
||||||
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\Models\Product;
|
||||||
use Lunar\Shipping\ShippingPlugin;
|
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;
|
||||||
|
use Modules\Core\Review\Extensions\ProductResourceExtension;
|
||||||
|
use Modules\Core\Review\Models\ProductReview;
|
||||||
|
|
||||||
class CorePlugin implements Plugin
|
class CorePlugin implements Plugin
|
||||||
{
|
{
|
||||||
@@ -31,8 +36,14 @@ class CorePlugin implements Plugin
|
|||||||
|
|
||||||
LunarPanel::extensions([
|
LunarPanel::extensions([
|
||||||
StaffResource::class => StaffResourceExtension::class,
|
StaffResource::class => StaffResourceExtension::class,
|
||||||
|
ProductResource::class => ProductResourceExtension::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Product::macro('reviews', function (): HasMany {
|
||||||
|
/** @var Product $this */
|
||||||
|
return $this->hasMany(ProductReview::class);
|
||||||
|
});
|
||||||
|
|
||||||
LunarStaff::addActivitylogExcept([
|
LunarStaff::addActivitylogExcept([
|
||||||
'otp_code',
|
'otp_code',
|
||||||
'otp_expires_at',
|
'otp_expires_at',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Modules\Core\MigrateImport;
|
namespace Modules\Core\MigrateImport;
|
||||||
|
|
||||||
|
use Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter;
|
||||||
use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter;
|
use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter;
|
||||||
|
|
||||||
class ImporterFactory
|
class ImporterFactory
|
||||||
@@ -9,8 +10,9 @@ class ImporterFactory
|
|||||||
public static function make(ImportSpec $spec): Importer
|
public static function make(ImportSpec $spec): Importer
|
||||||
{
|
{
|
||||||
return match ([$spec->source, $spec->type]) {
|
return match ([$spec->source, $spec->type]) {
|
||||||
["shopify", "export"] => new ShopifyExportImporter(),
|
['shopify', 'export'] => new ShopifyExportImporter,
|
||||||
// ["shopify", "api"] => new ShopifyApiImporter(),
|
// ["shopify", "api"] => new ShopifyApiImporter(),
|
||||||
|
['judgeme', 'export'] => new JudgeMeExportImporter,
|
||||||
// ["woocommerce", "export"] => new WooCommerceExportImporter(),
|
// ["woocommerce", "export"] => new WooCommerceExportImporter(),
|
||||||
// ["woocommerce", "api"] => new WooCommerceApiImporter(),
|
// ["woocommerce", "api"] => new WooCommerceApiImporter(),
|
||||||
default => throw new \InvalidArgumentException(
|
default => throw new \InvalidArgumentException(
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\MigrateImport\JudgeMe;
|
||||||
|
|
||||||
|
class JudgeMeCsvReader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, string>>
|
||||||
|
*/
|
||||||
|
public function read(string $csvPath): array
|
||||||
|
{
|
||||||
|
$handle = fopen($csvPath, 'r');
|
||||||
|
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new \RuntimeException("Could not open CSV file: {$csvPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = fgetcsv($handle);
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
while (($row = fgetcsv($handle)) !== false) {
|
||||||
|
$rows[] = array_combine($headers, $row);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\MigrateImport\JudgeMe;
|
||||||
|
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Modules\Core\MigrateImport\Importer;
|
||||||
|
use Modules\Core\MigrateImport\ImportSpec;
|
||||||
|
use Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver;
|
||||||
|
use Modules\Core\MigrateImport\Models\ImportMapping;
|
||||||
|
use Modules\Core\Review\Models\ProductReview;
|
||||||
|
|
||||||
|
class JudgeMeExportImporter implements Importer
|
||||||
|
{
|
||||||
|
private const SOURCE = 'judgeme';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly JudgeMeCsvReader $csvReader = new JudgeMeCsvReader,
|
||||||
|
private readonly ProductResolver $productResolver = new ProductResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function import(ImportSpec $spec): void
|
||||||
|
{
|
||||||
|
foreach ($this->csvReader->read($spec->filePath) as $row) {
|
||||||
|
$this->importReview($row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function importReview(array $row): void
|
||||||
|
{
|
||||||
|
$handle = trim((string) ($row['product_handle'] ?? ''));
|
||||||
|
$externalId = $row['metaobject_handle'] ?? '';
|
||||||
|
|
||||||
|
$product = $handle !== '' ? $this->productResolver->resolve($handle) : null;
|
||||||
|
|
||||||
|
if (! $product) {
|
||||||
|
Log::warning('JudgeMe import: no product found for handle, skipping review', [
|
||||||
|
'product_handle' => $handle,
|
||||||
|
'metaobject_handle' => $externalId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = ImportMapping::resolve(self::SOURCE, 'review', $externalId);
|
||||||
|
|
||||||
|
$review = $existing instanceof ProductReview ? $existing : new ProductReview;
|
||||||
|
$review->product_id = $product->id;
|
||||||
|
$review->title = trim((string) ($row['title'] ?? '')) ?: null;
|
||||||
|
$review->body = $row['body'] ?? null;
|
||||||
|
$review->rating = (int) ($row['rating'] ?? 0);
|
||||||
|
$review->reviewed_at = $this->parseDate($row['review_date'] ?? null);
|
||||||
|
$review->reviewer_name = $row['reviewer_name'] ?? null;
|
||||||
|
$review->reviewer_email = trim((string) ($row['reviewer_email'] ?? '')) ?: null;
|
||||||
|
$review->reply = trim((string) ($row['reply'] ?? '')) ?: null;
|
||||||
|
$review->replied_at = $this->parseDate($row['reply_date'] ?? null);
|
||||||
|
$review->location = trim((string) ($row['location'] ?? '')) ?: null;
|
||||||
|
$review->source = trim((string) ($row['source'] ?? '')) ?: null;
|
||||||
|
$review->save();
|
||||||
|
|
||||||
|
ImportMapping::record(self::SOURCE, 'review', $externalId, $review);
|
||||||
|
|
||||||
|
$pictureUrls = $this->parsePictureUrls($row['picture_urls'] ?? null);
|
||||||
|
|
||||||
|
if ($pictureUrls && $review->getMedia(ProductReview::IMAGES_COLLECTION)->isEmpty()) {
|
||||||
|
$this->importImages($review, $pictureUrls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $urls
|
||||||
|
*/
|
||||||
|
private function importImages(ProductReview $review, array $urls): void
|
||||||
|
{
|
||||||
|
foreach ($urls as $url) {
|
||||||
|
try {
|
||||||
|
$review->addMediaFromUrl($url)->toMediaCollection(ProductReview::IMAGES_COLLECTION);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('JudgeMe import: failed to download review image', [
|
||||||
|
'review_id' => $review->id,
|
||||||
|
'url' => $url,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseDate(?string $value): ?Carbon
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
return $value !== '' ? Carbon::parse($value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parsePictureUrls(?string $value): ?array
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map('trim', explode(',', $value));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\MigrateImport\JudgeMe\Resolvers;
|
||||||
|
|
||||||
|
use Lunar\Models\Product;
|
||||||
|
use Lunar\Models\Url;
|
||||||
|
|
||||||
|
class ProductResolver
|
||||||
|
{
|
||||||
|
public function resolve(string $handle): ?Product
|
||||||
|
{
|
||||||
|
$url = Url::query()
|
||||||
|
->where('slug', $handle)
|
||||||
|
->where('element_type', (new Product)->getMorphClass())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $url?->element;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ use Illuminate\Support\Facades\Log;
|
|||||||
use Lunar\Models\Collection;
|
use Lunar\Models\Collection;
|
||||||
use Lunar\Models\CollectionGroup;
|
use Lunar\Models\CollectionGroup;
|
||||||
use Lunar\Models\Currency;
|
use Lunar\Models\Currency;
|
||||||
|
use Lunar\Models\Language;
|
||||||
use Lunar\Models\Product;
|
use Lunar\Models\Product;
|
||||||
use Lunar\Models\ProductVariant;
|
use Lunar\Models\ProductVariant;
|
||||||
|
use Lunar\Models\Url;
|
||||||
use Modules\Core\MigrateImport\ImportSpec;
|
use Modules\Core\MigrateImport\ImportSpec;
|
||||||
use Modules\Core\MigrateImport\Importer;
|
use Modules\Core\MigrateImport\Importer;
|
||||||
use Modules\Core\MigrateImport\Models\ImportMapping;
|
use Modules\Core\MigrateImport\Models\ImportMapping;
|
||||||
@@ -91,6 +93,18 @@ class ShopifyExportImporter implements Importer
|
|||||||
|
|
||||||
ImportMapping::record(self::SOURCE, 'product', $group->handle, $product);
|
ImportMapping::record(self::SOURCE, 'product', $group->handle, $product);
|
||||||
|
|
||||||
|
Url::updateOrCreate(
|
||||||
|
[
|
||||||
|
'element_type' => $product->getMorphClass(),
|
||||||
|
'element_id' => $product->id,
|
||||||
|
'language_id' => Language::getDefault()->id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => $group->handle,
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
$this->tagResolver->resolve($product, $row['Tags'] ?? null);
|
$this->tagResolver->resolve($product, $row['Tags'] ?? null);
|
||||||
$this->collectionResolver->resolve($product, $collectionGroup, $row['Product Category'] ?? null);
|
$this->collectionResolver->resolve($product, $collectionGroup, $row['Product Category'] ?? null);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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;
|
use Modules\Core\Command\MigrateImportCommand;
|
||||||
|
|
||||||
class CoreServiceProvider extends ServiceProvider
|
class CoreServiceProvider extends ServiceProvider
|
||||||
@@ -25,6 +26,7 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__ . '/../../config/core.php' => config_path('core.php'),
|
__DIR__ . '/../../config/core.php' => config_path('core.php'),
|
||||||
|
__DIR__ . '/../../config/scout.php' => config_path('scout.php'),
|
||||||
], 'core-config');
|
], 'core-config');
|
||||||
|
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
@@ -34,6 +36,9 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
if ($this->app->runningInConsole()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]);
|
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]);
|
||||||
|
|
||||||
|
//Overriding lunar:install
|
||||||
|
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Review\Extensions;
|
||||||
|
|
||||||
|
use Lunar\Admin\Support\Extending\ResourceExtension;
|
||||||
|
use Modules\Core\Review\Pages\ManageProductReviews;
|
||||||
|
|
||||||
|
class ProductResourceExtension extends ResourceExtension
|
||||||
|
{
|
||||||
|
public function extendPages(array $pages): array
|
||||||
|
{
|
||||||
|
$pages['reviews'] = ManageProductReviews::route('/{record}/reviews');
|
||||||
|
|
||||||
|
return $pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extendSubNavigation(array $pages): array
|
||||||
|
{
|
||||||
|
return [...$pages, ManageProductReviews::class];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Review\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Lunar\Models\Product;
|
||||||
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
|
|
||||||
|
class ProductReview extends Model implements HasMedia
|
||||||
|
{
|
||||||
|
use InteractsWithMedia;
|
||||||
|
|
||||||
|
public const IMAGES_COLLECTION = 'review-images';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'reviewed_at' => 'datetime',
|
||||||
|
'replied_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Product::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerMediaCollections(): void
|
||||||
|
{
|
||||||
|
$this->addMediaCollection(self::IMAGES_COLLECTION);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Review\Pages;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\Group;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Tables\Actions\Action;
|
||||||
|
use Filament\Tables\Actions\DeleteAction;
|
||||||
|
use Filament\Tables\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Tables\Actions\ViewAction;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
use Lunar\Admin\Filament\Resources\ProductResource;
|
||||||
|
use Lunar\Admin\Support\Pages\BaseManageRelatedRecords;
|
||||||
|
use Modules\Core\Review\Models\ProductReview;
|
||||||
|
|
||||||
|
class ManageProductReviews extends BaseManageRelatedRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = ProductResource::class;
|
||||||
|
|
||||||
|
protected static string $relationship = 'reviews';
|
||||||
|
|
||||||
|
public static function getNavigationIcon(): ?string
|
||||||
|
{
|
||||||
|
return 'heroicon-o-star';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'Reviews';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return 'Reviews';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form
|
||||||
|
->schema([
|
||||||
|
TextInput::make('rating')
|
||||||
|
->label('Rating')
|
||||||
|
->disabled(),
|
||||||
|
TextInput::make('title')
|
||||||
|
->label('Title')
|
||||||
|
->disabled(),
|
||||||
|
Textarea::make('body')
|
||||||
|
->label('Body')
|
||||||
|
->disabled()
|
||||||
|
->columnSpanFull(),
|
||||||
|
Group::make([
|
||||||
|
TextInput::make('reviewer_name')
|
||||||
|
->label('Reviewer name')
|
||||||
|
->disabled(),
|
||||||
|
TextInput::make('reviewer_email')
|
||||||
|
->label('Reviewer email')
|
||||||
|
->disabled(),
|
||||||
|
])->columns(2),
|
||||||
|
Group::make([
|
||||||
|
TextInput::make('location')
|
||||||
|
->label('Location')
|
||||||
|
->disabled(),
|
||||||
|
TextInput::make('source')
|
||||||
|
->label('Source')
|
||||||
|
->disabled(),
|
||||||
|
])->columns(2),
|
||||||
|
Group::make([
|
||||||
|
TextInput::make('reviewed_at')
|
||||||
|
->label('Reviewed at')
|
||||||
|
->disabled(),
|
||||||
|
TextInput::make('replied_at')
|
||||||
|
->label('Replied at')
|
||||||
|
->disabled(),
|
||||||
|
])->columns(2),
|
||||||
|
Textarea::make('reply')
|
||||||
|
->label('Reply')
|
||||||
|
->disabled()
|
||||||
|
->columnSpanFull(),
|
||||||
|
Placeholder::make('images')
|
||||||
|
->label('Images')
|
||||||
|
->content(function (?ProductReview $record) {
|
||||||
|
if (! $record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$media = $record->getMedia(ProductReview::IMAGES_COLLECTION);
|
||||||
|
|
||||||
|
if ($media->isEmpty()) {
|
||||||
|
return 'No images';
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags = $media->map(fn ($item) => sprintf(
|
||||||
|
'<a href="%1$s" target="_blank"><img src="%1$s" style="height:6rem;width:6rem;object-fit:cover;border-radius:0.5rem;display:inline-block;margin:0.25rem" /></a>',
|
||||||
|
$item->getUrl(),
|
||||||
|
))->implode('');
|
||||||
|
|
||||||
|
return new HtmlString($tags);
|
||||||
|
})
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('title')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('rating')
|
||||||
|
->label('Rating')
|
||||||
|
->formatStateUsing(fn (int $state) => str_repeat('★', $state).str_repeat('☆', 5 - $state))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('title')
|
||||||
|
->label('Title')
|
||||||
|
->limit(40)
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('reviewer_name')
|
||||||
|
->label('Reviewer')
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->defaultSort('reviewed_at', 'desc')
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
ViewAction::make(),
|
||||||
|
Action::make('reply')
|
||||||
|
->label(fn (ProductReview $record) => $record->reply ? 'Edit reply' : 'Reply')
|
||||||
|
->icon('heroicon-o-chat-bubble-left-right')
|
||||||
|
->form([
|
||||||
|
Textarea::make('reply')
|
||||||
|
->label('Reply')
|
||||||
|
->required(),
|
||||||
|
])
|
||||||
|
->fillForm(fn (ProductReview $record) => ['reply' => $record->reply])
|
||||||
|
->action(function (ProductReview $record, array $data) {
|
||||||
|
$record->update([
|
||||||
|
'reply' => $data['reply'],
|
||||||
|
'replied_at' => Carbon::now(),
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Search;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Lunar\Search\ProductIndexer as BaseProductIndexer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lunar's own indexer puts raw attribute HTML (e.g. name_en, description_en) into
|
||||||
|
* the search index, which pollutes relevance ranking and highlighting with markup.
|
||||||
|
* Strip tags from string fields before they reach Meilisearch.
|
||||||
|
*/
|
||||||
|
class ProductIndexer extends BaseProductIndexer
|
||||||
|
{
|
||||||
|
public function toSearchableArray(Model $model): array
|
||||||
|
{
|
||||||
|
$data = parent::toSearchableArray($model);
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (is_string($value)) {
|
||||||
|
$data[$key] = trim(strip_tags($value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Contracts;
|
||||||
|
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
|
interface CarrierFulfillmentInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a shipment with the carrier for the given order.
|
||||||
|
*/
|
||||||
|
public function createShipment(Order $order, array $overrides = []): Shipment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the printable label for a shipment (raw file bytes).
|
||||||
|
*/
|
||||||
|
public function printLabel(Shipment $shipment): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a shipment with the carrier.
|
||||||
|
*/
|
||||||
|
public function cancelShipment(Shipment $shipment): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker for a Lunar\Shipping\Interfaces\ShippingRateInterface driver that
|
||||||
|
* calculates its price from a live carrier API rather than the manually
|
||||||
|
* configured ShippingRate price/price-breaks. Admin UI uses this to hide
|
||||||
|
* the manual pricing fields for such drivers — carriers without a pricing
|
||||||
|
* API simply don't implement it, and manual pricing remains required.
|
||||||
|
*/
|
||||||
|
interface SupportsLivePricing {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Contracts;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional capability for carriers that batch shipments into a manifest
|
||||||
|
* before courier pickup (e.g. ACS's end-of-day pickup list). Carriers
|
||||||
|
* without this concept simply don't implement it.
|
||||||
|
*/
|
||||||
|
interface SupportsManifestBatching
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Shipments created but not yet included in an issued manifest.
|
||||||
|
*/
|
||||||
|
public function pendingForManifest(): Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize the given shipments into a manifest with the carrier.
|
||||||
|
*/
|
||||||
|
public function issueManifest(Collection $shipments): ManifestResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
|
||||||
|
class Shipment extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'meta' => AsArrayObject::class,
|
||||||
|
'label_printed_at' => 'datetime',
|
||||||
|
'cancelled_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function order(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Order::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user