From ed496716eb8822b5f141db6c8c0e806fff665425 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Fri, 10 Jul 2026 15:19:48 +0300 Subject: [PATCH] Feature: Adding Product Reviews Importer and views --- ...10_000001_create_product_reviews_table.php | 32 ++++ src/Command/MigrateImportCommand.php | 35 ++-- src/CorePlugin.php | 11 ++ src/MigrateImport/ImporterFactory.php | 4 +- .../JudgeMe/JudgeMeCsvReader.php | 29 ++++ .../JudgeMe/JudgeMeExportImporter.php | 105 ++++++++++++ .../JudgeMe/Resolvers/ProductResolver.php | 19 +++ .../Extensions/ProductResourceExtension.php | 21 +++ src/Review/Models/ProductReview.php | 33 ++++ src/Review/Pages/ManageProductReviews.php | 153 ++++++++++++++++++ 10 files changed, 424 insertions(+), 18 deletions(-) create mode 100644 database/migrations/2026_07_10_000001_create_product_reviews_table.php create mode 100644 src/MigrateImport/JudgeMe/JudgeMeCsvReader.php create mode 100644 src/MigrateImport/JudgeMe/JudgeMeExportImporter.php create mode 100644 src/MigrateImport/JudgeMe/Resolvers/ProductResolver.php create mode 100644 src/Review/Extensions/ProductResourceExtension.php create mode 100644 src/Review/Models/ProductReview.php create mode 100644 src/Review/Pages/ManageProductReviews.php diff --git a/database/migrations/2026_07_10_000001_create_product_reviews_table.php b/database/migrations/2026_07_10_000001_create_product_reviews_table.php new file mode 100644 index 0000000..16f1295 --- /dev/null +++ b/database/migrations/2026_07_10_000001_create_product_reviews_table.php @@ -0,0 +1,32 @@ +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'); + } +}; diff --git a/src/Command/MigrateImportCommand.php b/src/Command/MigrateImportCommand.php index c96b541..1d2634a 100644 --- a/src/Command/MigrateImportCommand.php +++ b/src/Command/MigrateImportCommand.php @@ -8,43 +8,44 @@ use Modules\Core\MigrateImport\RunMigrateImportJob; class MigrateImportCommand extends Command { - protected $signature = "boboko:migrate:import + 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)}"; + {--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 = [ - "shopify", + 'shopify', + 'judgeme', // "woocommerce", ]; // Supported import types per source. Only "export" is wired up for now. private const TYPES = [ - "export", + 'export', // "api", ]; public function handle(): void { - $source = $this->option("source") - ?? $this->choice("Select a source:", self::SOURCES, 0); + $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); + $type = $this->option('type') + ?? $this->choice('Select an import type:', self::TYPES, 0); - if ($type === "api") { + if ($type === 'api') { $credentials = [ - "api_key" => $this->secret("API key:"), - "api_secret" => $this->secret("API secret:"), + 'api_key' => $this->secret('API key:'), + 'api_secret' => $this->secret('API secret:'), ]; $filePath = null; } 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}:")); while (blank($filePath) || ! is_file($filePath)) { @@ -70,7 +71,7 @@ class MigrateImportCommand extends Command RunMigrateImportJob::dispatch($spec); - $this->info("Import queued."); + $this->info('Import queued.'); } // Answers are relative to storage/app/private/imports (e.g. "shopify" or @@ -82,7 +83,7 @@ class MigrateImportCommand extends Command return $answer; } - $path = str_starts_with($answer, "/") ? $answer : "{$importsPath}/{$answer}"; + $path = str_starts_with($answer, '/') ? $answer : "{$importsPath}/{$answer}"; if (is_dir($path)) { $csv = collect(glob("{$path}/*.csv"))->first(); diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 1f1af20..38a356f 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -4,14 +4,19 @@ namespace Modules\Core; use Filament\Contracts\Plugin; use Filament\Panel; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Mail; +use Lunar\Admin\Filament\Resources\ProductResource; use Lunar\Admin\Filament\Resources\StaffResource; use Lunar\Admin\Models\Staff as LunarStaff; use Lunar\Admin\Support\Facades\LunarPanel; +use Lunar\Models\Product; use Lunar\Shipping\ShippingPlugin; use Modules\Core\Auth\Extensions\StaffResourceExtension; use Modules\Core\Auth\Filament\Pages\Login; use Modules\Core\Auth\Mail\InviteMail; +use Modules\Core\Review\Extensions\ProductResourceExtension; +use Modules\Core\Review\Models\ProductReview; class CorePlugin implements Plugin { @@ -31,8 +36,14 @@ class CorePlugin implements Plugin LunarPanel::extensions([ StaffResource::class => StaffResourceExtension::class, + ProductResource::class => ProductResourceExtension::class, ]); + Product::macro('reviews', function (): HasMany { + /** @var Product $this */ + return $this->hasMany(ProductReview::class); + }); + LunarStaff::addActivitylogExcept([ 'otp_code', 'otp_expires_at', diff --git a/src/MigrateImport/ImporterFactory.php b/src/MigrateImport/ImporterFactory.php index 7421a46..7f1a617 100644 --- a/src/MigrateImport/ImporterFactory.php +++ b/src/MigrateImport/ImporterFactory.php @@ -2,6 +2,7 @@ namespace Modules\Core\MigrateImport; +use Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter; use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter; class ImporterFactory @@ -9,8 +10,9 @@ class ImporterFactory public static function make(ImportSpec $spec): Importer { return match ([$spec->source, $spec->type]) { - ["shopify", "export"] => new ShopifyExportImporter(), + ['shopify', 'export'] => new ShopifyExportImporter, // ["shopify", "api"] => new ShopifyApiImporter(), + ['judgeme', 'export'] => new JudgeMeExportImporter, // ["woocommerce", "export"] => new WooCommerceExportImporter(), // ["woocommerce", "api"] => new WooCommerceApiImporter(), default => throw new \InvalidArgumentException( diff --git a/src/MigrateImport/JudgeMe/JudgeMeCsvReader.php b/src/MigrateImport/JudgeMe/JudgeMeCsvReader.php new file mode 100644 index 0000000..13b3df8 --- /dev/null +++ b/src/MigrateImport/JudgeMe/JudgeMeCsvReader.php @@ -0,0 +1,29 @@ +> + */ + 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; + } +} diff --git a/src/MigrateImport/JudgeMe/JudgeMeExportImporter.php b/src/MigrateImport/JudgeMe/JudgeMeExportImporter.php new file mode 100644 index 0000000..937a133 --- /dev/null +++ b/src/MigrateImport/JudgeMe/JudgeMeExportImporter.php @@ -0,0 +1,105 @@ +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 $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)); + } +} diff --git a/src/MigrateImport/JudgeMe/Resolvers/ProductResolver.php b/src/MigrateImport/JudgeMe/Resolvers/ProductResolver.php new file mode 100644 index 0000000..ad68d91 --- /dev/null +++ b/src/MigrateImport/JudgeMe/Resolvers/ProductResolver.php @@ -0,0 +1,19 @@ +where('slug', $handle) + ->where('element_type', (new Product)->getMorphClass()) + ->first(); + + return $url?->element; + } +} diff --git a/src/Review/Extensions/ProductResourceExtension.php b/src/Review/Extensions/ProductResourceExtension.php new file mode 100644 index 0000000..120154e --- /dev/null +++ b/src/Review/Extensions/ProductResourceExtension.php @@ -0,0 +1,21 @@ + 'datetime', + 'replied_at' => 'datetime', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection(self::IMAGES_COLLECTION); + } +} diff --git a/src/Review/Pages/ManageProductReviews.php b/src/Review/Pages/ManageProductReviews.php new file mode 100644 index 0000000..a81d1ce --- /dev/null +++ b/src/Review/Pages/ManageProductReviews.php @@ -0,0 +1,153 @@ +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( + '', + $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(), + ]); + } +}