diff --git a/admin/AGENTS.md b/admin/AGENTS.md index 30b0463f..4df7fe7a 100644 --- a/admin/AGENTS.md +++ b/admin/AGENTS.md @@ -272,6 +272,10 @@ When adding a new entity, build the files in this order, matching the existing f - Assert with `assertDatabaseHas(...)`, `assertModelMissing(...)`, or `expect($model->refresh())->field->toBe(...)`. - Do not assert computed fields against a fresh factory value; assert the expected computed result. +## Business constraints + +- **No seller / marketplace system.** ShopFlow is a single-vendor store. There are no sellers, seller accounts, or seller-scoped entities. Never add `seller_id`, `seller_creator_id`, or any seller relation to models, migrations, or resources. + ## Roadmap & docs - **Before starting any task**, read these three files to understand the current state of the project: diff --git a/admin/app/Enums/ReviewStatusEnum.php b/admin/app/Enums/ReviewStatusEnum.php new file mode 100644 index 00000000..06f32959 --- /dev/null +++ b/admin/app/Enums/ReviewStatusEnum.php @@ -0,0 +1,37 @@ + 'Deleted', + self::PENDING => 'Pending', + self::APPROVED => 'Approved', + self::REJECTED => 'Rejected', + }; + } + + public function color(): string + { + return match ($this) { + self::DELETED => 'danger', + self::PENDING => 'warning', + self::APPROVED => 'success', + self::REJECTED => 'danger', + }; + } +} diff --git a/admin/app/Filament/Resources/CouponResource.php b/admin/app/Filament/Resources/CouponResource.php index ec4519c1..6ef00871 100644 --- a/admin/app/Filament/Resources/CouponResource.php +++ b/admin/app/Filament/Resources/CouponResource.php @@ -109,13 +109,6 @@ public static function form(Schema $schema): Schema ->native(false) ->hintIcon('heroicon-o-information-circle') ->hintIconTooltip('The admin user who created this coupon.'), - Select::make('seller_creator_id') - ->relationship('sellerCreator', 'email') - ->searchable() - ->preload() - ->native(false) - ->hintIcon('heroicon-o-information-circle') - ->hintIconTooltip('The seller who created this coupon, if applicable.'), Select::make('products') ->relationship('products', 'heading') ->multiple() diff --git a/admin/app/Filament/Resources/ReviewResource.php b/admin/app/Filament/Resources/ReviewResource.php new file mode 100644 index 00000000..dd22f064 --- /dev/null +++ b/admin/app/Filament/Resources/ReviewResource.php @@ -0,0 +1,167 @@ +components([ + TextInput::make('heading') + ->required() + ->maxLength(255) + ->columnSpanFull() + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The review title written by the user (e.g. "Great product!").'), + Textarea::make('content') + ->required() + ->rows(5) + ->columnSpanFull() + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The full review text.'), + Select::make('product_id') + ->relationship('product', 'heading') + ->required() + ->searchable() + ->preload() + ->live() + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The product this review is about.'), + Select::make('variety_id') + ->label('Variety (optional)') + ->options(function (Get $get): array { + $productId = $get('product_id'); + if (! $productId) { + return []; + } + + return Variety::query() + ->where('product_id', $productId) + ->get() + ->mapWithKeys(fn (Variety $v): array => [$v->id => $v->attribute_value ?? "Variety #{$v->id}"]) + ->toArray(); + }) + ->nullable() + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The specific variety (e.g. size/color) the user purchased, if known.'), + Select::make('user_id') + ->relationship('user', 'email') + ->searchable() + ->preload() + ->nullable() + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The user who wrote this review. Leave empty for anonymous/admin-entered reviews.'), + Select::make('parent_id') + ->label('Reply to') + ->options(function (?Review $record): array { + return Review::query() + ->whereNull('parent_id') + ->when($record?->id, fn (Builder $q): Builder => $q->where('id', '!=', $record->id)) + ->get() + ->mapWithKeys(fn (Review $r): array => [$r->id => "#{$r->id} — {$r->heading}"]) + ->toArray(); + }) + ->nullable() + ->searchable() + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Set this to make the review a reply to another review.'), + Select::make('status') + ->required() + ->options(ReviewStatusEnum::options()) + ->default(ReviewStatusEnum::PENDING->value) + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Pending reviews are hidden from the storefront until approved.'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('product.heading') + ->label('Product') + ->limit(30) + ->searchable(), + TextColumn::make('heading') + ->limit(40) + ->searchable(), + TextColumn::make('user.email') + ->label('User') + ->placeholder('Anonymous') + ->searchable(), + TextColumn::make('parent_id') + ->label('Reply to') + ->placeholder('—') + ->formatStateUsing(fn (?int $state): string => $state ? "#{$state}" : '—'), + TextColumn::make('status') + ->getStateUsing(fn (Review $record): string => $record->status->label()) + ->color(fn (Review $record): string => $record->status->color()) + ->sortable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListReviews::route('/'), + 'create' => CreateReview::route('/create'), + 'edit' => EditReview::route('/{record}/edit'), + ]; + } +} diff --git a/admin/app/Filament/Resources/ReviewResource/Pages/CreateReview.php b/admin/app/Filament/Resources/ReviewResource/Pages/CreateReview.php new file mode 100644 index 00000000..ee65b1a7 --- /dev/null +++ b/admin/app/Filament/Resources/ReviewResource/Pages/CreateReview.php @@ -0,0 +1,18 @@ +getResource()::getUrl('index'); + } +} diff --git a/admin/app/Filament/Resources/ReviewResource/Pages/EditReview.php b/admin/app/Filament/Resources/ReviewResource/Pages/EditReview.php new file mode 100644 index 00000000..8fd1586d --- /dev/null +++ b/admin/app/Filament/Resources/ReviewResource/Pages/EditReview.php @@ -0,0 +1,26 @@ +getResource()::getUrl('index'); + } + + protected function getHeaderActions(): array + { + return [ + DeleteAction::make(), + ]; + } +} diff --git a/admin/app/Filament/Resources/ReviewResource/Pages/ListReviews.php b/admin/app/Filament/Resources/ReviewResource/Pages/ListReviews.php new file mode 100644 index 00000000..04ad1abb --- /dev/null +++ b/admin/app/Filament/Resources/ReviewResource/Pages/ListReviews.php @@ -0,0 +1,23 @@ + $varieties + * @property Collection $directVarieties + * @property Collection $products */ class Attribute extends Model { @@ -35,8 +38,22 @@ public function attributeGroup(): BelongsTo return $this->belongsTo(AttributeGroup::class); } + /** Varieties linked via the attribute_variety pivot (additional attributes). */ public function varieties(): BelongsToMany { return $this->belongsToMany(Variety::class)->withTimestamps(); } + + /** Varieties where this attribute is the primary one (attribute_id FK). */ + public function directVarieties(): HasMany + { + return $this->hasMany(Variety::class); + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'product_attribute') + ->withPivot('is_highlight') + ->withTimestamps(); + } } diff --git a/admin/app/Models/AttributeGroup.php b/admin/app/Models/AttributeGroup.php index 4791ced6..3e9dd7dd 100644 --- a/admin/app/Models/AttributeGroup.php +++ b/admin/app/Models/AttributeGroup.php @@ -4,9 +4,11 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; /** @@ -15,9 +17,12 @@ * @property positive-int $id * @property positive-int $ancestor_id * @property string $name - * @property Ancestor $ancestor * @property string|null $label * @property int|null $order + * @property Ancestor $ancestor + * @property Collection $attributes + * @property Collection $categories + * @property Collection $products */ class AttributeGroup extends Model { @@ -30,10 +35,6 @@ class AttributeGroup extends Model 'order', ]; - protected $casts = [ - 'is_selective' => 'boolean', - ]; - public function ancestor(): BelongsTo { return $this->belongsTo(Ancestor::class); @@ -43,4 +44,16 @@ public function attributes(): HasMany { return $this->hasMany(Attribute::class); } + + public function categories(): BelongsToMany + { + return $this->belongsToMany(Category::class, 'attribute_group_category') + ->withPivot(['as_filter', 'required', 'order']) + ->withTimestamps(); + } + + public function products(): HasMany + { + return $this->hasMany(Product::class); + } } diff --git a/admin/app/Models/Category.php b/admin/app/Models/Category.php index f65c7bb6..ffc73f32 100644 --- a/admin/app/Models/Category.php +++ b/admin/app/Models/Category.php @@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphOne; @@ -28,6 +30,10 @@ * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property Collection $products + * @property Collection $attributeGroups + * @property Collection $coupons + * @property Category|null $parent + * @property Collection $children * * @method static Builder|Category active() */ @@ -75,4 +81,26 @@ public function attributeGroupCategories(): HasMany { return $this->hasMany(AttributeGroupCategory::class); } + + public function attributeGroups(): BelongsToMany + { + return $this->belongsToMany(AttributeGroup::class, 'attribute_group_category') + ->withPivot(['as_filter', 'required', 'order']) + ->withTimestamps(); + } + + public function coupons(): BelongsToMany + { + return $this->belongsToMany(Coupon::class, 'category_coupon')->withTimestamps(); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(Category::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(Category::class, 'parent_id'); + } } diff --git a/admin/app/Models/Coupon.php b/admin/app/Models/Coupon.php index 12050040..5e23c599 100644 --- a/admin/app/Models/Coupon.php +++ b/admin/app/Models/Coupon.php @@ -25,7 +25,6 @@ * @property positive-int|null $total_uses * @property positive-int|null $user_id * @property positive-int|null $user_creator_id - * @property positive-int|null $seller_creator_id * @property CouponStatusEnum $status * @property bool $is_percent * @property bool $shipping @@ -52,7 +51,6 @@ class Coupon extends Model 'total_uses', 'user_id', 'user_creator_id', - 'seller_creator_id', 'status', 'is_percent', 'shipping', @@ -80,11 +78,6 @@ public function userCreator(): BelongsTo return $this->belongsTo(User::class, 'user_creator_id'); } - public function sellerCreator(): BelongsTo - { - return $this->belongsTo(User::class, 'seller_creator_id'); - } - public function products(): BelongsToMany { return $this->belongsToMany(Product::class, 'coupon_product') diff --git a/admin/app/Models/Product.php b/admin/app/Models/Product.php index 9a10fc37..cb2fb85d 100644 --- a/admin/app/Models/Product.php +++ b/admin/app/Models/Product.php @@ -42,12 +42,14 @@ * @property positive-int|null $height * @property ProductStatusEnum $status * @property positive-int $seen - * @property Image $featuredImage + * @property Image|null $featuredImage * @property Collection $images * @property Collection $varieties - * @property AttributeGroup $attributeGroup - * @property Category $category - * @property Brand $brand + * @property Collection $reviews + * @property Collection $coupons + * @property AttributeGroup|null $attributeGroup + * @property Category|null $category + * @property Brand|null $brand */ class Product extends Model { @@ -163,4 +165,14 @@ public function brand(): BelongsTo { return $this->belongsTo(Brand::class); } + + public function reviews(): HasMany + { + return $this->hasMany(Review::class); + } + + public function coupons(): BelongsToMany + { + return $this->belongsToMany(Coupon::class, 'coupon_product')->withTimestamps(); + } } diff --git a/admin/app/Models/Review.php b/admin/app/Models/Review.php new file mode 100644 index 00000000..bab295d1 --- /dev/null +++ b/admin/app/Models/Review.php @@ -0,0 +1,71 @@ + $replies + */ +class Review extends Model +{ + use HasFactory; + + protected $fillable = [ + 'heading', + 'content', + 'user_id', + 'product_id', + 'variety_id', + 'parent_id', + 'status', + ]; + + protected $casts = [ + 'status' => ReviewStatusEnum::class, + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function variety(): BelongsTo + { + return $this->belongsTo(Variety::class); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(Review::class, 'parent_id'); + } + + public function replies(): HasMany + { + return $this->hasMany(Review::class, 'parent_id'); + } +} diff --git a/admin/app/Models/Variety.php b/admin/app/Models/Variety.php index f78a9107..6d610747 100644 --- a/admin/app/Models/Variety.php +++ b/admin/app/Models/Variety.php @@ -5,11 +5,13 @@ namespace App\Models; use App\Enums\VarietyStatusEnum; +use Database\Factories\VarietyFactory; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property positive-int $id @@ -25,9 +27,12 @@ * @property Product $product * @property Attribute|null $attribute * @property Collection $attributes + * @property Collection $discounts + * @property Collection $reviews */ class Variety extends Model { + /** @use HasFactory */ use HasFactory; protected $fillable = [ @@ -93,4 +98,14 @@ public function attributes(): BelongsToMany { return $this->belongsToMany(Attribute::class)->withTimestamps(); } + + public function discounts(): HasMany + { + return $this->hasMany(Discount::class); + } + + public function reviews(): HasMany + { + return $this->hasMany(Review::class); + } } diff --git a/admin/database/factories/CouponFactory.php b/admin/database/factories/CouponFactory.php index 44c47fcd..b98088e1 100644 --- a/admin/database/factories/CouponFactory.php +++ b/admin/database/factories/CouponFactory.php @@ -32,7 +32,6 @@ public function definition(): array 'total_uses' => fake()->numberBetween(1, 100), 'user_id' => null, 'user_creator_id' => null, - 'seller_creator_id' => null, 'status' => fake()->randomElement(CouponStatusEnum::cases()), 'is_percent' => fake()->boolean(), 'shipping' => fake()->boolean(), diff --git a/admin/database/factories/ReviewFactory.php b/admin/database/factories/ReviewFactory.php new file mode 100644 index 00000000..156d07e1 --- /dev/null +++ b/admin/database/factories/ReviewFactory.php @@ -0,0 +1,40 @@ + + */ +class ReviewFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'heading' => fake()->sentence(5), + 'content' => fake()->paragraph(), + 'user_id' => null, + 'product_id' => Product::factory(), + 'variety_id' => null, + 'parent_id' => null, + 'status' => fake()->randomElement(ReviewStatusEnum::cases()), + ]; + } + + public function withParent(Review $parent): static + { + return $this->state([ + 'parent_id' => $parent->id, + 'product_id' => $parent->product_id, + ]); + } +} diff --git a/admin/database/migrations/2026_06_19_000002_create_coupons_table.php b/admin/database/migrations/2026_06_19_000002_create_coupons_table.php index a3139413..b15ca2ae 100644 --- a/admin/database/migrations/2026_06_19_000002_create_coupons_table.php +++ b/admin/database/migrations/2026_06_19_000002_create_coupons_table.php @@ -27,7 +27,6 @@ public function up(): void $table->unsignedInteger('total_uses')->nullable(); $table->foreignIdFor(User::class)->nullable()->constrained()->nullOnDelete(); $table->foreignId('user_creator_id')->nullable()->constrained('users')->nullOnDelete(); - $table->foreignId('seller_creator_id')->nullable()->constrained('users')->nullOnDelete(); $table->unsignedTinyInteger('status')->default(CouponStatusEnum::ACTIVE->value); $table->boolean('is_percent')->default(false); $table->boolean('shipping')->default(false); diff --git a/admin/database/migrations/2026_06_20_000008_create_reviews_table.php b/admin/database/migrations/2026_06_20_000008_create_reviews_table.php new file mode 100644 index 00000000..3d4c16a4 --- /dev/null +++ b/admin/database/migrations/2026_06_20_000008_create_reviews_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('heading'); + $table->text('content'); + $table->foreignIdFor(User::class)->nullable()->constrained()->nullOnDelete(); + $table->foreignIdFor(Product::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(Variety::class)->nullable()->constrained()->nullOnDelete(); + $table->foreignId('parent_id')->nullable()->constrained('reviews')->nullOnDelete(); + $table->unsignedTinyInteger('status')->default(ReviewStatusEnum::PENDING->value); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('reviews'); + } +}; diff --git a/admin/database/seeders/ReviewSeeder.php b/admin/database/seeders/ReviewSeeder.php new file mode 100644 index 00000000..c1a31798 --- /dev/null +++ b/admin/database/seeders/ReviewSeeder.php @@ -0,0 +1,18 @@ +each->delete(); + + Review::factory()->count(20)->create(); + } +} diff --git a/admin/database/seeders/TestSeeder.php b/admin/database/seeders/TestSeeder.php index d5afbcd1..3b784511 100644 --- a/admin/database/seeders/TestSeeder.php +++ b/admin/database/seeders/TestSeeder.php @@ -22,6 +22,7 @@ public function run(): void MenuSeeder::class, PageSeeder::class, FaqSeeder::class, + ReviewSeeder::class, ]); } } diff --git a/admin/docs/IMPLEMENTATION.md b/admin/docs/IMPLEMENTATION.md index 7b02a825..5d54d3a3 100644 --- a/admin/docs/IMPLEMENTATION.md +++ b/admin/docs/IMPLEMENTATION.md @@ -29,6 +29,7 @@ Catalog layer and platform basics. - [x] Menus + Menu Items (nested via `parent_id`; optional polymorphic image per item) - [x] Pages (CMS static pages; polymorphic image; SCHEDULED status with `published_at`) - [x] FAQs (question + answer; `order` and nullable `position` for placement context) +- [x] Reviews (user reviews for products; `parent_id` for replies; `status` moderation) - [~] Addresses (model only, no resource yet) Sample data for manual admin testing lives in `TestSeeder` (`php artisan db:seed --class=TestSeeder`); `DatabaseSeeder` holds only necessary data. @@ -70,7 +71,7 @@ Depend mostly on Images only. - [x] Menus + Menu Items - [x] Pages - [x] FAQs -- [ ] Reviews +- [x] Reviews - [ ] Wishlists - [ ] Tags - [ ] Brand-Category pages diff --git a/admin/tests/Feature/Filament/Resource/ReviewResourceTest.php b/admin/tests/Feature/Filament/Resource/ReviewResourceTest.php new file mode 100644 index 00000000..d7069b65 --- /dev/null +++ b/admin/tests/Feature/Filament/Resource/ReviewResourceTest.php @@ -0,0 +1,94 @@ +assertOk(); +}); + +it('can list reviews in the table.', function () { + $reviews = Review::factory()->count(5)->create(); + + livewire(ReviewResource\Pages\ListReviews::class) + ->assertCanSeeTableRecords($reviews); +}); + +it('can render edit page.', function () { + $review = Review::factory()->create(); + + get(ReviewResource::getUrl('edit', [ + 'record' => $review, + ]))->assertOk(); +}); + +it('can update review status.', function () { + $review = Review::factory()->create(['status' => ReviewStatusEnum::PENDING]); + + livewire(ReviewResource\Pages\EditReview::class, [ + 'record' => $review->getRouteKey(), + ]) + ->fillForm([ + 'heading' => $review->heading, + 'content' => $review->content, + 'product_id' => $review->product_id, + 'status' => ReviewStatusEnum::APPROVED->value, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($review->refresh()) + ->status->toBe(ReviewStatusEnum::APPROVED); +}); + +it('can create review.', function () { + $newReview = Review::factory()->make(['status' => ReviewStatusEnum::APPROVED]); + + livewire(ReviewResource\Pages\CreateReview::class) + ->fillForm([ + 'heading' => $newReview->heading, + 'content' => $newReview->content, + 'product_id' => $newReview->product_id, + 'status' => ReviewStatusEnum::APPROVED->value, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas(Review::class, [ + 'heading' => $newReview->heading, + 'status' => ReviewStatusEnum::APPROVED->value, + ]); +}); + +it('can delete review.', function () { + $review = Review::factory()->create(); + + livewire(ReviewResource\Pages\EditReview::class, [ + 'record' => $review->getRouteKey(), + ]) + ->callAction(DeleteAction::class); + + $this->assertModelMissing($review); +}); + +it('deletes replies when parent review is deleted.', function () { + $parent = Review::factory()->create(); + $reply = Review::factory()->withParent($parent)->create(); + + $parent->delete(); + + $this->assertModelMissing($parent); + $this->assertDatabaseMissing(Review::class, ['parent_id' => $parent->id]); + expect($reply->refresh()->parent_id)->toBeNull(); +});