Feature: Adding Product Reviews Importer and views

This commit is contained in:
2026-07-10 15:19:48 +03:00
parent 1d25e0d746
commit ed496716eb
10 changed files with 424 additions and 18 deletions
+18 -17
View File
@@ -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();
+11
View File
@@ -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',
+3 -1
View File
@@ -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(
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\MigrateImport\JudgeMe;
class JudgeMeCsvReader
{
/**
* @return array<int, array<string, string>>
*/
public function read(string $csvPath): array
{
$handle = fopen($csvPath, 'r');
if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}");
}
$headers = fgetcsv($handle);
$rows = [];
while (($row = fgetcsv($handle)) !== false) {
$rows[] = array_combine($headers, $row);
}
fclose($handle);
return $rows;
}
}
@@ -0,0 +1,105 @@
<?php
namespace Modules\Core\MigrateImport\JudgeMe;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Modules\Core\MigrateImport\Importer;
use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver;
use Modules\Core\MigrateImport\Models\ImportMapping;
use Modules\Core\Review\Models\ProductReview;
class JudgeMeExportImporter implements Importer
{
private const SOURCE = 'judgeme';
public function __construct(
private readonly JudgeMeCsvReader $csvReader = new JudgeMeCsvReader,
private readonly ProductResolver $productResolver = new ProductResolver,
) {}
public function import(ImportSpec $spec): void
{
foreach ($this->csvReader->read($spec->filePath) as $row) {
$this->importReview($row);
}
}
private function importReview(array $row): void
{
$handle = trim((string) ($row['product_handle'] ?? ''));
$externalId = $row['metaobject_handle'] ?? '';
$product = $handle !== '' ? $this->productResolver->resolve($handle) : null;
if (! $product) {
Log::warning('JudgeMe import: no product found for handle, skipping review', [
'product_handle' => $handle,
'metaobject_handle' => $externalId,
]);
return;
}
$existing = ImportMapping::resolve(self::SOURCE, 'review', $externalId);
$review = $existing instanceof ProductReview ? $existing : new ProductReview;
$review->product_id = $product->id;
$review->title = trim((string) ($row['title'] ?? '')) ?: null;
$review->body = $row['body'] ?? null;
$review->rating = (int) ($row['rating'] ?? 0);
$review->reviewed_at = $this->parseDate($row['review_date'] ?? null);
$review->reviewer_name = $row['reviewer_name'] ?? null;
$review->reviewer_email = trim((string) ($row['reviewer_email'] ?? '')) ?: null;
$review->reply = trim((string) ($row['reply'] ?? '')) ?: null;
$review->replied_at = $this->parseDate($row['reply_date'] ?? null);
$review->location = trim((string) ($row['location'] ?? '')) ?: null;
$review->source = trim((string) ($row['source'] ?? '')) ?: null;
$review->save();
ImportMapping::record(self::SOURCE, 'review', $externalId, $review);
$pictureUrls = $this->parsePictureUrls($row['picture_urls'] ?? null);
if ($pictureUrls && $review->getMedia(ProductReview::IMAGES_COLLECTION)->isEmpty()) {
$this->importImages($review, $pictureUrls);
}
}
/**
* @param array<int, string> $urls
*/
private function importImages(ProductReview $review, array $urls): void
{
foreach ($urls as $url) {
try {
$review->addMediaFromUrl($url)->toMediaCollection(ProductReview::IMAGES_COLLECTION);
} catch (\Throwable $e) {
Log::warning('JudgeMe import: failed to download review image', [
'review_id' => $review->id,
'url' => $url,
'error' => $e->getMessage(),
]);
}
}
}
private function parseDate(?string $value): ?Carbon
{
$value = trim((string) $value);
return $value !== '' ? Carbon::parse($value) : null;
}
private function parsePictureUrls(?string $value): ?array
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
return array_map('trim', explode(',', $value));
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\MigrateImport\JudgeMe\Resolvers;
use Lunar\Models\Product;
use Lunar\Models\Url;
class ProductResolver
{
public function resolve(string $handle): ?Product
{
$url = Url::query()
->where('slug', $handle)
->where('element_type', (new Product)->getMorphClass())
->first();
return $url?->element;
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Review\Extensions;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Review\Pages\ManageProductReviews;
class ProductResourceExtension extends ResourceExtension
{
public function extendPages(array $pages): array
{
$pages['reviews'] = ManageProductReviews::route('/{record}/reviews');
return $pages;
}
public function extendSubNavigation(array $pages): array
{
return [...$pages, ManageProductReviews::class];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Review\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Lunar\Models\Product;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class ProductReview extends Model implements HasMedia
{
use InteractsWithMedia;
public const IMAGES_COLLECTION = 'review-images';
protected $guarded = [];
protected $casts = [
'reviewed_at' => 'datetime',
'replied_at' => 'datetime',
];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function registerMediaCollections(): void
{
$this->addMediaCollection(self::IMAGES_COLLECTION);
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace Modules\Core\Review\Pages;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Support\Pages\BaseManageRelatedRecords;
use Modules\Core\Review\Models\ProductReview;
class ManageProductReviews extends BaseManageRelatedRecords
{
protected static string $resource = ProductResource::class;
protected static string $relationship = 'reviews';
public static function getNavigationIcon(): ?string
{
return 'heroicon-o-star';
}
public function getTitle(): string
{
return 'Reviews';
}
public static function getNavigationLabel(): string
{
return 'Reviews';
}
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('rating')
->label('Rating')
->disabled(),
TextInput::make('title')
->label('Title')
->disabled(),
Textarea::make('body')
->label('Body')
->disabled()
->columnSpanFull(),
Group::make([
TextInput::make('reviewer_name')
->label('Reviewer name')
->disabled(),
TextInput::make('reviewer_email')
->label('Reviewer email')
->disabled(),
])->columns(2),
Group::make([
TextInput::make('location')
->label('Location')
->disabled(),
TextInput::make('source')
->label('Source')
->disabled(),
])->columns(2),
Group::make([
TextInput::make('reviewed_at')
->label('Reviewed at')
->disabled(),
TextInput::make('replied_at')
->label('Replied at')
->disabled(),
])->columns(2),
Textarea::make('reply')
->label('Reply')
->disabled()
->columnSpanFull(),
Placeholder::make('images')
->label('Images')
->content(function (?ProductReview $record) {
if (! $record) {
return null;
}
$media = $record->getMedia(ProductReview::IMAGES_COLLECTION);
if ($media->isEmpty()) {
return 'No images';
}
$tags = $media->map(fn ($item) => sprintf(
'<a href="%1$s" target="_blank"><img src="%1$s" style="height:6rem;width:6rem;object-fit:cover;border-radius:0.5rem;display:inline-block;margin:0.25rem" /></a>',
$item->getUrl(),
))->implode('');
return new HtmlString($tags);
})
->columnSpanFull(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('title')
->columns([
TextColumn::make('rating')
->label('Rating')
->formatStateUsing(fn (int $state) => str_repeat('★', $state).str_repeat('☆', 5 - $state))
->sortable(),
TextColumn::make('title')
->label('Title')
->limit(40)
->searchable(),
TextColumn::make('reviewer_name')
->label('Reviewer')
->searchable(),
])
->defaultSort('reviewed_at', 'desc')
->filters([
//
])
->actions([
ViewAction::make(),
Action::make('reply')
->label(fn (ProductReview $record) => $record->reply ? 'Edit reply' : 'Reply')
->icon('heroicon-o-chat-bubble-left-right')
->form([
Textarea::make('reply')
->label('Reply')
->required(),
])
->fillForm(fn (ProductReview $record) => ['reply' => $record->reply])
->action(function (ProductReview $record, array $data) {
$record->update([
'reply' => $data['reply'],
'replied_at' => Carbon::now(),
]);
}),
DeleteAction::make(),
])
->bulkActions([
DeleteBulkAction::make(),
]);
}
}