Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions admin/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions admin/app/Enums/ReviewStatusEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace App\Enums;

use App\Traits\HasOptions;

enum ReviewStatusEnum: int
{
use HasOptions;

case DELETED = 10;
case PENDING = 20;
case APPROVED = 30;
case REJECTED = 40;

public function label(): string
{
return match ($this) {
self::DELETED => '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',
};
}
}
7 changes: 0 additions & 7 deletions admin/app/Filament/Resources/CouponResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
167 changes: 167 additions & 0 deletions admin/app/Filament/Resources/ReviewResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

declare(strict_types=1);

namespace App\Filament\Resources;

use App\Enums\ReviewStatusEnum;
use App\Filament\Resources\ReviewResource\Pages\CreateReview;
use App\Filament\Resources\ReviewResource\Pages\EditReview;
use App\Filament\Resources\ReviewResource\Pages\ListReviews;
use App\Models\Review;
use App\Models\Variety;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

class ReviewResource extends Resource
{
protected static ?string $model = Review::class;

protected static string | \UnitEnum | null $navigationGroup = 'Content';

protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-chat-bubble-left-right';

protected static ?int $navigationSort = 8;

public static function form(Schema $schema): Schema
{
return $schema
->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'),
];
}
}
18 changes: 18 additions & 0 deletions admin/app/Filament/Resources/ReviewResource/Pages/CreateReview.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace App\Filament\Resources\ReviewResource\Pages;

use App\Filament\Resources\ReviewResource;
use Filament\Resources\Pages\CreateRecord;

class CreateReview extends CreateRecord
{
protected static string $resource = ReviewResource::class;

protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}
26 changes: 26 additions & 0 deletions admin/app/Filament/Resources/ReviewResource/Pages/EditReview.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace App\Filament\Resources\ReviewResource\Pages;

use App\Filament\Resources\ReviewResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;

class EditReview extends EditRecord
{
protected static string $resource = ReviewResource::class;

protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}

protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
23 changes: 23 additions & 0 deletions admin/app/Filament/Resources/ReviewResource/Pages/ListReviews.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace App\Filament\Resources\ReviewResource\Pages;

use App\Filament\Resources\ReviewResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;

class ListReviews extends ListRecords
{
protected static string $resource = ReviewResource::class;

protected ?string $subheading = 'Moderate user reviews for products. Approve, reject, or reply to reviews from here.';

protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
17 changes: 17 additions & 0 deletions admin/app/Models/Attribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

/**
* App\Models\Attribute
Expand All @@ -19,6 +20,8 @@
* @property string $value
* @property AttributeGroup $attributeGroup
* @property Collection<Variety> $varieties
* @property Collection<Variety> $directVarieties
* @property Collection<Product> $products
*/
class Attribute extends Model
{
Expand All @@ -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();
}
}
23 changes: 18 additions & 5 deletions admin/app/Models/AttributeGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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<Attribute> $attributes
* @property Collection<Category> $categories
* @property Collection<Product> $products
*/
class AttributeGroup extends Model
{
Expand All @@ -30,10 +35,6 @@ class AttributeGroup extends Model
'order',
];

protected $casts = [
'is_selective' => 'boolean',
];

public function ancestor(): BelongsTo
{
return $this->belongsTo(Ancestor::class);
Expand All @@ -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);
}
}
Loading
Loading