diff --git a/admin/app/Filament/Resources/WishlistResource.php b/admin/app/Filament/Resources/WishlistResource.php new file mode 100644 index 00000000..2eb1e9c2 --- /dev/null +++ b/admin/app/Filament/Resources/WishlistResource.php @@ -0,0 +1,95 @@ +components([ + Select::make('user_id') + ->relationship('user', 'email') + ->required() + ->searchable() + ->preload() + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The user who saved this product.'), + Select::make('product_id') + ->relationship('product', 'heading') + ->required() + ->searchable() + ->preload() + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The product saved to this wishlist.'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('user.email') + ->label('User') + ->searchable() + ->sortable(), + TextColumn::make('product.heading') + ->label('Product') + ->limit(40) + ->searchable(), + TextColumn::make('created_at') + ->label('Saved at') + ->dateTime() + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + // + ]) + ->recordActions([ + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListWishlists::route('/'), + 'create' => CreateWishlist::route('/create'), + ]; + } +} diff --git a/admin/app/Filament/Resources/WishlistResource/Pages/CreateWishlist.php b/admin/app/Filament/Resources/WishlistResource/Pages/CreateWishlist.php new file mode 100644 index 00000000..7ad643d2 --- /dev/null +++ b/admin/app/Filament/Resources/WishlistResource/Pages/CreateWishlist.php @@ -0,0 +1,18 @@ +getResource()::getUrl('index'); + } +} diff --git a/admin/app/Filament/Resources/WishlistResource/Pages/ListWishlists.php b/admin/app/Filament/Resources/WishlistResource/Pages/ListWishlists.php new file mode 100644 index 00000000..9d1d7d70 --- /dev/null +++ b/admin/app/Filament/Resources/WishlistResource/Pages/ListWishlists.php @@ -0,0 +1,23 @@ +belongsTo(User::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/admin/database/factories/WishlistFactory.php b/admin/database/factories/WishlistFactory.php new file mode 100644 index 00000000..e69494a4 --- /dev/null +++ b/admin/database/factories/WishlistFactory.php @@ -0,0 +1,27 @@ + + */ +class WishlistFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'product_id' => Product::factory(), + ]; + } +} diff --git a/admin/database/migrations/2026_06_20_000009_create_wishlists_table.php b/admin/database/migrations/2026_06_20_000009_create_wishlists_table.php new file mode 100644 index 00000000..95a2981f --- /dev/null +++ b/admin/database/migrations/2026_06_20_000009_create_wishlists_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(Product::class)->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['user_id', 'product_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('wishlists'); + } +}; diff --git a/admin/database/seeders/TestSeeder.php b/admin/database/seeders/TestSeeder.php index 3b784511..2596b2e6 100644 --- a/admin/database/seeders/TestSeeder.php +++ b/admin/database/seeders/TestSeeder.php @@ -23,6 +23,7 @@ public function run(): void PageSeeder::class, FaqSeeder::class, ReviewSeeder::class, + WishlistSeeder::class, ]); } } diff --git a/admin/database/seeders/WishlistSeeder.php b/admin/database/seeders/WishlistSeeder.php new file mode 100644 index 00000000..9b5f2fe6 --- /dev/null +++ b/admin/database/seeders/WishlistSeeder.php @@ -0,0 +1,18 @@ +each->delete(); + + Wishlist::factory()->count(20)->create(); + } +} diff --git a/admin/docs/IMPLEMENTATION.md b/admin/docs/IMPLEMENTATION.md index 5d54d3a3..452c554c 100644 --- a/admin/docs/IMPLEMENTATION.md +++ b/admin/docs/IMPLEMENTATION.md @@ -30,6 +30,7 @@ Catalog layer and platform basics. - [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) +- [x] Wishlists (`user_id` + `product_id` pivot; cascades on user/product delete; list + delete resource) - [~] 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. @@ -72,7 +73,7 @@ Depend mostly on Images only. - [x] Pages - [x] FAQs - [x] Reviews -- [ ] Wishlists +- [x] Wishlists - [ ] Tags - [ ] Brand-Category pages - [ ] Redirects diff --git a/admin/docs/ShoFlow db doc.md b/admin/docs/ShoFlow db doc.md index 6c07127d..703090e5 100644 --- a/admin/docs/ShoFlow db doc.md +++ b/admin/docs/ShoFlow db doc.md @@ -12,7 +12,6 @@ Used to store user addresses. * `user_id` refers to the user who owns the address. -* `seller_id` refers to the seller who owns the address; either `user_id` or `seller_id` must have a value. * `city_id` selects the city code and is mandatory. * `name` specifies the address name, such as home, office, or any user-defined name. * `phone` specifies the phone number associated with the address. @@ -185,9 +184,8 @@ Stores discount coupons. Unlike discounts, a coupon is applied manually: the cus * `total_used`: How many times this coupon has already been used. * `total_uses`: How many times this coupon is allowed to be used in total. * `user_id`: Limits usage to a specific user. Nullable foreign key to `users`; set to null when the user is deleted. -* `user_creator_id`: The admin user who created the coupon, if created by an admin. Nullable foreign key to `users`. -* `seller_creator_id`: The seller who created the coupon, if created by a seller. Nullable foreign key to `users`. -* `status`: Has states "active" (default, usable), "canceled", "used", and "under review". +* `user_creator_id`: The admin user who created the coupon. Nullable foreign key to `users`. +* `status`: Has states "active" (default, usable), "canceled", "used", and "under review". * `is_percent`: Indicates if the discount is a percentage or a fixed amount. * `shipping`: Indicates if this coupon includes free shipping (applies only to free shipping, not to the price). * `is_for`: Whether the coupon is usable by everyone, or only by users, or only by partners. @@ -243,11 +241,11 @@ In short: discounts are automatic, per-variety, condition-based price rules. The # faqs -* This table is used to store frequently asked questions (FAQ stands for Frequently Asked Questions). -* `heading`: Specifies the title of each question. -* `content`: Provides the answer to each question. -* `order`: Specifies the display order of the FAQs. -* `position`: Added to the FAQs table. All records with a null value in the `position` column are displayed on the FAQ page. Other records with a position are displayed in their respective positions, for example on the homepage or the products page (not the product details, but the page displaying all products regardless of category). +* Stores frequently asked questions shown on the storefront. +* `heading`: The question text shown to the visitor. +* `content`: The answer to the question. +* `order`: Display order — lower numbers appear first. +* `position`: Placement context. Null = shown on the main FAQ page. Any value (e.g. `"homepage"`, `"products"`) shows the FAQ in that specific section of the site. # transactions @@ -601,15 +599,15 @@ Used to store products. # reviews -* Contains user reviews for each product. -* The `heading` column is the title of the review, for example, "Good product, I recommend it." -* The `content` column stores the content of the user's review. -* The `user_id` column specifies which user submitted the review. -* The `seller_id` column specifies which seller submitted the review. Only one of the columns `user_id` or `seller_id` can have a value at any time. -* The `product_id` column specifies which product the review is related to. -* The `variety_id` column specifies which product variant the review is related to. If not null, it means the review is for a purchased product variant. -* The `parent_id` column is used for replying to a review. -* The `status` column specifies the status of the review. +* Contains user reviews for each product. +* `heading`: The review title written by the user (e.g. "Great product!"). +* `content`: The full review text. +* `user_id`: The user who submitted the review. Nullable; set to null if the user is deleted. +* `product_id`: The product being reviewed. Cascade-deletes the review when the product is deleted. +* `variety_id`: The specific variety (e.g. size/color) the user purchased. Nullable; set to null if the variety is deleted. +* `parent_id`: Used to reply to another review (self-referential FK). Set to null when the parent is deleted. +* `status`: Moderation state — `PENDING` (default, hidden from storefront), `APPROVED` (visible), `REJECTED`, `DELETED`. +* Note: no `seller_id` — ShopFlow is single-vendor, there are no sellers. # roles @@ -872,11 +870,12 @@ For storing warehouses. Warehouses can determine the location of each product, a # wishlists -Stores the products added to user wishlists. +Stores the products saved to user wishlists. -* `user_id`: Indicates which user this record belongs to. -* `product_id`: Indicates which product this record belongs to. -* `created_at`: Indicates when this record was created. +* `user_id`: The user who saved the product. Cascade-deletes the entry when the user is deleted. +* `product_id`: The saved product. Cascade-deletes the entry when the product is deleted. +* Unique constraint on `(user_id, product_id)` — a user can only save each product once. +* Admin resource is read-only (list + delete); entries are created by users on the storefront. # working\_hours diff --git a/admin/tests/Feature/Filament/Resource/WishlistResourceTest.php b/admin/tests/Feature/Filament/Resource/WishlistResourceTest.php new file mode 100644 index 00000000..58f90beb --- /dev/null +++ b/admin/tests/Feature/Filament/Resource/WishlistResourceTest.php @@ -0,0 +1,60 @@ +assertOk(); +}); + +it('can list wishlists in the table.', function () { + $wishlists = Wishlist::factory()->count(5)->create(); + + livewire(WishlistResource\Pages\ListWishlists::class) + ->assertCanSeeTableRecords($wishlists); +}); + +it('can create wishlist entry.', function () { + $wishlist = Wishlist::factory()->make(); + + livewire(WishlistResource\Pages\CreateWishlist::class) + ->fillForm([ + 'user_id' => $wishlist->user_id, + 'product_id' => $wishlist->product_id, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas(Wishlist::class, [ + 'user_id' => $wishlist->user_id, + 'product_id' => $wishlist->product_id, + ]); +}); + +it('can delete wishlist entry.', function () { + $wishlist = Wishlist::factory()->create(); + + livewire(WishlistResource\Pages\ListWishlists::class) + ->callTableAction(DeleteAction::class, $wishlist); + + $this->assertModelMissing($wishlist); +}); + +it('cascades delete when user is deleted.', function () { + $wishlist = Wishlist::factory()->create(); + $user = $wishlist->user; + + $user->delete(); + + $this->assertModelMissing($wishlist); +});