Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3b1a1e9
feat(shop): category listing page with faceted filters
bahman026 Jun 27, 2026
a17b680
feat(shop): product card variety-image fallback and open in new tab
bahman026 Jun 27, 2026
140bd61
docs(shop): document filters, card behavior, SSR runtime and tags
bahman026 Jun 27, 2026
bbdab96
docs: rewrite root README with project overview
bahman026 Jun 27, 2026
1faecf1
feat(shop): brand page with category/price/availability filters
bahman026 Jun 27, 2026
fbe75a9
feat(shop): CMS pages + FAQ at clean URLs, working footer links
bahman026 Jun 27, 2026
64850b0
feat(shop): sitemap.xml, robots.txt and per-page SEO
bahman026 Jun 27, 2026
72529fd
docs(shop): require separate atomic commits in AGENTS.md
bahman026 Jun 27, 2026
4f01f4d
feat(shop): product search with header autocomplete
bahman026 Jun 27, 2026
a916213
feat(shop): mobile login/register with OTP or password
bahman026 Jun 27, 2026
8680983
feat(shop): account area with dashboard and profile
bahman026 Jun 27, 2026
3c697a0
feat(shop): address management with Neshan map
bahman026 Jun 27, 2026
ca4a8b5
feat(shop): PWA manifest and iOS add-to-home-screen prompt
bahman026 Jun 27, 2026
20d2fb5
feat(shop): cart and checkout shipping flow
bahman026 Jun 27, 2026
562727b
docs(admin): add missing settings.autoload cache entry
bahman026 Jul 23, 2026
0f590c3
fix(admin): add orders.address_id — schema had no way to record shipp…
bahman026 Jul 23, 2026
7144e42
docs(shop): resync stale IMPLEMENTATION.md mirror with admin's
bahman026 Jul 23, 2026
a60ebb4
feat(shop): order creation + Zarinpal sandbox payment checkout
bahman026 Jul 23, 2026
bfa2269
fix(shop): category filter facets respect attribute_groups.order
bahman026 Jul 23, 2026
0794f3a
fix(shop): product specs/highlights keep their attribute-group label
bahman026 Jul 23, 2026
86004b2
fix(shop): deterministic variant axis/option ordering in the variety …
bahman026 Jul 23, 2026
dc244e2
fix(admin): sort Ancestor/AttributeGroup tables by their configured o…
bahman026 Jul 23, 2026
04d46b1
fix(admin): remove non-existent order column from attribute_group_cat…
bahman026 Jul 23, 2026
eb1f416
docs(admin): clarify Receipts vs Transactions split in panel subheadings
bahman026 Jul 23, 2026
d40aead
chore: docker-compose cleanup
bahman026 Jul 23, 2026
70aedc6
chore: add root .mcp.json for laravel-boost MCP server
bahman026 Jul 23, 2026
326e71d
feat(admin): add order tracking code
bahman026 Jul 23, 2026
8d738a6
fix(admin): fix seeder FK-order bugs, stop TestSeeder clobbering real…
bahman026 Jul 23, 2026
2907556
feat(shop): account order history, tracking codes, pay-again retry, a…
bahman026 Jul 23, 2026
1946b5c
fix(shop): allow a user with no mobile to load the account area
bahman026 Jul 23, 2026
acfd2e3
feat(shop): downloadable receipt and order-detail redesign
bahman026 Jul 23, 2026
df5d402
feat(shop): wishlist (add/remove on product page, account list)
bahman026 Jul 23, 2026
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
19 changes: 19 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"mcpServers": {
"laravel-boost": {
"type": "stdio",
"command": "docker",
"args": [
"exec",
"-i",
"-u",
"www-data",
"shop_flow_admin_app",
"php",
"artisan",
"boost:mcp"
],
"env": {}
}
}
}
122 changes: 110 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,117 @@
### Documant of the database:
https://docs.google.com/document/d/e/2PACX-1vTqah2hdQeeiu3Le07zfOfp5vK-ojLwJtQmzbgdoq_wmJu-0dBdTcFsS0uSiUtYpSglEwMD5xSFIiG5/pub
### Setting Up and Running the Project in Docker:
# ShopFlow

ShopFlow is an **open-source, single-vendor e-commerce platform** (MIT licensed) for building a Persian, right-to-left online store. It is designed for one business selling its own products (no marketplace/seller system), with a strong focus on SEO and a server-rendered storefront so pages are fast and indexable.

It is built as a monorepo of two Laravel apps that share one PostgreSQL database:

**In the `infrastructure/docker` directory, create a `.env` file and fill in the values according to `.env.example`. Then run the following command to create the Postgres and Redis containers:**
```bash
sudo docker compose up -d --build
- **`admin/`** — the management panel (Laravel 13 + Filament 5, PHP 8.5). It **owns the database schema** (all migrations live here) and is where the team manages catalog, orders, content, and settings.
- **`shop/`** — the customer-facing storefront (Laravel 13 + Inertia + Vue 3 with SSR, PHP 8.5). It mostly **reads** catalog/pricing data and writes carts, orders, addresses, and payments. The UI is Persian, RTL-first.

The two apps never duplicate tables: `admin` migrates the shared schema, and `shop` adds read-focused Eloquent models that map to the same tables.

**note:** Make sure to configure the database settings in the .env files for both Admin and API projects as follows, if you haven't changed the host and port in the Docker Compose files:
```bash
DB_CONNECTION=pgsql
DB_HOST=db
DB_PORT=5432
## What ShopFlow provides

The platform models a full online-store domain:

- **Catalog** — hierarchical categories, products with purchasable varieties (e.g. size/color), brands, attributes and attribute groups, and images.
- **Discovery** — faceted category filtering (brand, attribute, price, availability), sorting, pagination, and attribute-based SEO landing pages (tags).
- **Pricing & promotions** — per-variety pricing, sale prices, discounts and coupons.
- **Cart & checkout** — carts, orders with line snapshots, per-city shipping methods, and inventory that is only decremented on a successful payment.
- **Payments** — manual receipts (card-to-card / Paya) and online gateways (Mellat / Parsian / Zarinpal) via transactions.
- **Customers** — accounts, addresses kept as immutable history, wishlists, product reviews, points, and newsletters.
- **SEO** — SSR HTML, unique titles/meta, canonical URLs, Open Graph, JSON-LD structured data, sitemap, and redirects.

The admin panel covers the full schema today. The storefront is built feature by feature against `shop/docs/STOREFRONT_IMPLEMENTATION.md`; see that roadmap for current status.

## Repository structure

```
ShopFlow/
├── admin/ # Filament admin panel (owns the DB schema)
├── shop/ # Inertia + Vue storefront (SSR)
├── infrastructure/
│ └── docker/ # Shared Postgres + Redis (docker compose)
├── .github/workflows/ # CI (deploy-application.yml)
└── README.md
```

## Tech stack

- PHP 8.5, Laravel 13
- Admin: Filament 5
- Storefront: Inertia.js 3 + Vue 3 (SSR), Tailwind CSS v4, FontAwesome
- PostgreSQL (shared), Redis
- Quality: Pest, Pint, PHPStan (level 5), 100% type coverage, ESLint + Prettier

## Database

`admin` is the single source of truth for the schema. The full table reference lives in:

- In-repo: `admin/docs/ShoFlow db doc.md` (and a copy under `shop/docs/`)
- Online: https://docs.google.com/document/d/e/2PACX-1vTqah2hdQeeiu3Le07zfOfp5vK-ojLwJtQmzbgdoq_wmJu-0dBdTcFsS0uSiUtYpSglEwMD5xSFIiG5/pub

Run migrations and seeders from `admin/` only. The storefront must not migrate these tables.

## Getting started

### 1. Shared services (Postgres + Redis)

In `infrastructure/docker`, create a `.env` from `.env.example`, then start the containers:

```bash
cd infrastructure/docker
sudo docker compose up -d --build
```

### 2. Configure each app

In both `admin/.env` and `shop/.env`, point the database at the shared Postgres (matching the values from `infrastructure/docker/.env`):

```bash
DB_CONNECTION=pgsql
DB_HOST=db
DB_PORT=5432
# DB_DATABASE / DB_USERNAME / DB_PASSWORD must match infrastructure/docker/.env
```

### 3. Admin (schema owner — set up first)

```bash
cd admin
composer install
php artisan key:generate
php artisan migrate --seed
npm install && npm run build
```

### 4. Storefront

```bash
cd shop
composer install
php artisan key:generate
npm install && npm run build
```

Fill in the values for DB_DATABASE, DB_USERNAME, and DB_PASSWORD according to the .env file in the infrastructure directory.
For app-specific details (Docker containers, SSR, conventions), see each app's own `README.md`, `AGENTS.md`, and `docs/`.

## Testing & quality

The storefront bundles all checks into one command (run inside its container):

```bash
cd shop
composer test-dev # Pest, Pint, Pest type-coverage (--min=100), PHPStan, ESLint, Prettier
```

CI runs the same checks via `.github/workflows/deploy-application.yml`.

## Documentation

- `shop/docs/STOREFRONT_IMPLEMENTATION.md` — storefront roadmap and status
- `admin/docs/` and `shop/docs/` — schema, variety guide, orders/inventory, cache keys, tags
- `shop/AGENTS.md` / `admin/AGENTS.md` — conventions for contributors and AI agents

## License

Open-sourced under the [MIT license](LICENSE).
4 changes: 4 additions & 0 deletions admin/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ When adding a new entity, build the files in this order, matching the existing f
- `modifyQueryUsing` for relationship selects is the **3rd parameter** of `->relationship()`, not a chainable method: `->relationship('name', 'title', fn (Builder $q): Builder => $q->with('relation'))`. Calling `->modifyQueryUsing()` as a separate method throws `BadMethodCallException`.
- `->getOptionLabelFromRecordUsing(fn (Model $record): string => ...)` customises the label shown for each option in a relationship select. Pair with eager-loading in the `modifyQueryUsing` closure to avoid N+1.
- Control navigation order within a group with `protected static ?int $navigationSort = 1;` (lower = higher in the list).
- **A model's own `order` column must be paired with `->defaultSort('order')` on its table.** A sortable `order` column alone (e.g. `AncestorResource`, `AttributeGroupResource`) does nothing by default — the list still renders in insertion/id order every time it's opened, silently defeating the whole point of the field. See `FaqResource` for the reference pattern.
- **Never `withPivot()` a column that isn't actually migrated on the pivot table.** `AttributeGroup::categories()`/`Category::attributeGroups()` both declared a `order` pivot column that was never added to `attribute_group_category`, which threw `SQLSTATE[42703]: undefined column` the instant the relation was queried — verify pivot columns against the actual migration, not just intent.
- Add an explanatory subheading to a list page by overriding `mount()` on the `ListRecords` class: set `protected ?string $subheading = null;` and assign `$this->subheading = trans('resource.subheading');` inside `mount()`. Never use a hard-coded string — dynamic assignment is required for locale switching.
- Add a tooltip to a form field with `->hintIcon('heroicon-o-information-circle')->hintIconTooltip('Explanation...')`. Use this instead of always-visible `->hint()` when the text is long.
- Always add `->image()` to `FileUpload` fields that accept images. This restricts the file picker to image types only.
Expand Down Expand Up @@ -511,6 +513,8 @@ When adding a new entity, build the files in this order, matching the existing f
- `TestSeeder` holds factory-generated sample data (`Model::factory()->count(20)->create()`) for manual admin-panel testing. Run it separately with `php artisan db:seed --class=TestSeeder`. Add new sample-data seeders here, not in `DatabaseSeeder`.
- Reference seeders use idempotent `updateOrCreate()` / `firstOrCreate()` so re-seeding is safe.
- When truncating and re-seeding a table whose model has a `deleting` event (e.g. to cascade-delete related images), delete records one by one via `Model::all()->each->delete()` BEFORE truncating the parent. Use `->each->delete()` on a **Collection**, not a query builder — `Model::query()->each` does not exist and will throw an exception.
- **A "delete-then-recreate" seeder must clear every table that FK-references it first, not just its own model's `deleting` event.** `ShippingLineSeeder` deleting all `shipping_lines` threw `SQLSTATE[23503]` because `shipping_methods`/`shipping_cities` (no cascade) still referenced them; `CitySeeder` deleting `cities` hit the same thing via `addresses.city_id` (worse: `Address` uses `SoftDeletes`, so even a soft-deleted row still blocks the FK — use `withTrashed()->forceDelete()`, not a plain `delete()`, to actually clear it). Check every migration for FKs into the table you're about to wipe, not just the ones you already know about.
- **`TestSeeder` must never seed a table that a "real" seeder (`DatabaseSeeder`'s chain) already populates with load-bearing data.** `TestSeeder` used to include `ShippingLineSeeder`/`ShippingMethodSeeder`/`ShippingCitySeeder` (20 random rows each); since `DatabaseSeeder` → `ShippingSeeder` already seeds the 3 real, checkout-critical shipping methods, running `TestSeeder` afterward silently replaced them with random fake ones tied to random specific cities (no nationwide fallback) — breaking the storefront checkout's shipping-method selection with no visible error until a customer tried to check out. Removed from `TestSeeder` entirely; re-run `ShippingSeeder` if this ever regresses.
- Read configurable values from config, not literals (see `AdminSeeder` reading `config('admin.account')`).

## Pest tests
Expand Down
1 change: 1 addition & 0 deletions admin/app/Filament/Resources/AncestorResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public static function form(Schema $schema): Schema
public static function table(Table $table): Table
{
return $table
->defaultSort('order')
->columns([
TextColumn::make('name')
->label(trans('ancestor.name'))
Expand Down
1 change: 1 addition & 0 deletions admin/app/Filament/Resources/AttributeGroupResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public static function form(Schema $schema): Schema
public static function table(Table $table): Table
{
return $table
->defaultSort('order')
->columns([
TextColumn::make('ancestor.name')
->label(trans('attribute_group.ancestor'))
Expand Down
10 changes: 10 additions & 0 deletions admin/app/Filament/Resources/OrderResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ public static function form(Schema $schema): Schema
]),
Fieldset::make(trans('order.section_shipping'))
->schema([
Select::make('address_id')
->label(trans('order.address_id'))
->relationship('address', 'address')
->searchable()
->preload()
->native(false),
Select::make('shipping_line_id')
->label(trans('order.shipping_line_id'))
->relationship('shippingLine', 'name')
Expand Down Expand Up @@ -208,6 +214,10 @@ public static function table(Table $table): Table
TextColumn::make('id')
->label('#')
->sortable(),
TextColumn::make('tracking_code')
->label(trans('order.tracking_code'))
->searchable()
->copyable(),
TextColumn::make('user.email')
->label(trans('order.user_id'))
->searchable()
Expand Down
4 changes: 4 additions & 0 deletions admin/app/Models/Address.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
* @property string $phone
* @property string $postal_code
* @property string $address
* @property string|null $latitude
* @property string|null $longitude
* @property string|null $description
* @property positive-int $city_id
* @property positive-int $user_id
Expand All @@ -38,6 +40,8 @@ class Address extends Model
'phone',
'postal_code',
'address',
'latitude',
'longitude',
'description',
'city_id',
'user_id',
Expand Down
2 changes: 1 addition & 1 deletion admin/app/Models/AttributeGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public function attributes(): HasMany
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class, 'attribute_group_category')
->withPivot(['as_filter', 'required', 'order'])
->withPivot(['as_filter', 'required'])
->withTimestamps();
}

Expand Down
2 changes: 1 addition & 1 deletion admin/app/Models/Category.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public function attributeGroupCategories(): HasMany
public function attributeGroups(): BelongsToMany
{
return $this->belongsToMany(AttributeGroup::class, 'attribute_group_category')
->withPivot(['as_filter', 'required', 'order'])
->withPivot(['as_filter', 'required'])
->withTimestamps();
}

Expand Down
29 changes: 29 additions & 0 deletions admin/app/Models/Order.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

/**
* @property positive-int $id
* @property string $tracking_code
* @property positive-int|null $user_id
* @property positive-int|null $coupon_id
* @property OrderStatusEnum $status
Expand All @@ -41,6 +42,7 @@
* @property string|null $collector_description
* @property positive-int|null $notifier_id
* @property Carbon|null $notified_at
* @property positive-int|null $address_id
* @property positive-int|null $shipping_line_id
* @property positive-int|null $shipping_method_id
* @property string|null $send_description
Expand All @@ -53,6 +55,7 @@
* @property User|null $confirmer
* @property User|null $collector
* @property User|null $notifier
* @property Address|null $address
* @property ShippingLine|null $shippingLine
* @property ShippingMethod|null $shippingMethod
* @property Collection<OrderVariety> $orderVarieties
Expand Down Expand Up @@ -92,6 +95,7 @@ class Order extends Model
'collector_description',
'notifier_id',
'notified_at',
'address_id',
'shipping_line_id',
'shipping_method_id',
'send_description',
Expand All @@ -109,6 +113,26 @@ class Order extends Model
'notified_at' => 'datetime',
];

protected static function booted(): void
{
static::creating(function (Order $order): void {
$order->tracking_code ??= self::generateTrackingCode();
});
}

/**
* A random 10-digit number, not the sequential `id`, so a customer's
* tracking code never reveals order volume/growth over time.
*/
private static function generateTrackingCode(): string
{
do {
$code = (string) random_int(1_000_000_000, 9_999_999_999);
} while (self::query()->where('tracking_code', $code)->exists());

return $code;
}

public function user(): BelongsTo
{
return $this->belongsTo(User::class);
Expand All @@ -134,6 +158,11 @@ public function notifier(): BelongsTo
return $this->belongsTo(User::class, 'notifier_id');
}

public function address(): BelongsTo
{
return $this->belongsTo(Address::class);
}

public function shippingLine(): BelongsTo
{
return $this->belongsTo(ShippingLine::class);
Expand Down
2 changes: 2 additions & 0 deletions admin/database/factories/OrderFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use App\Enums\OrderSrcEnum;
use App\Enums\OrderStatusEnum;
use App\Models\Address;
use App\Models\Order;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
Expand All @@ -27,6 +28,7 @@ public function definition(): array

return [
'user_id' => User::factory(),
'address_id' => Address::factory(),
'coupon_id' => null,
'status' => fake()->randomElement(OrderStatusEnum::cases()),
'coupon_discount' => 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public function up(): void
$table->text('phone');
$table->text('postal_code')->nullable();
$table->text('address');
$table->decimal('latitude', 10, 7)->nullable();
$table->decimal('longitude', 10, 7)->nullable();
$table->string('description')->nullable();
$table->boolean('prime')->default(false);
$table->softDeletes();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Enums\OrderSrcEnum;
use App\Enums\OrderStatusEnum;
use App\Models\Address;
use App\Models\Coupon;
use App\Models\ShippingLine;
use App\Models\ShippingMethod;
Expand All @@ -18,6 +19,10 @@ public function up(): void
{
Schema::create('orders', function (Blueprint $table): void {
$table->id();
// Customer-facing order identifier (e.g. "1168407691"): an opaque
// random 10-digit number, not the sequential `id`, so a customer
// can't infer order volume/growth from their own tracking code.
$table->string('tracking_code', 10)->unique();
$table->foreignIdFor(User::class)->nullable()->constrained()->nullOnDelete();
$table->foreignIdFor(Coupon::class)->nullable()->constrained()->nullOnDelete();
$table->unsignedTinyInteger('status')->default(OrderStatusEnum::PENDING->value);
Expand Down Expand Up @@ -55,6 +60,11 @@ public function up(): void
$table->foreignId('notifier_id')->nullable()->constrained('users')->nullOnDelete();
$table->dateTime('notified_at')->nullable();

// The address the order ships to. Addresses are immutable history
// (edits create a new row), so this always points at the exact
// address snapshot the customer chose at checkout.
$table->foreignIdFor(Address::class)->nullable()->constrained()->nullOnDelete();

$table->foreignIdFor(ShippingLine::class)->nullable()->constrained()->nullOnDelete();
$table->foreignIdFor(ShippingMethod::class)->nullable()->constrained()->nullOnDelete();
$table->text('send_description')->nullable();
Expand Down
7 changes: 7 additions & 0 deletions admin/database/seeders/CitySeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Database\Seeders;

use App\Models\Address;
use App\Models\City;
use App\Models\Province;
use Illuminate\Database\Seeder;
Expand All @@ -16,6 +17,12 @@ class CitySeeder extends Seeder
*/
public function run(): void
{
// addresses.city_id has no cascade action, so a lingering address
// (soft-deleted or not — the row still exists either way) blocks
// deleting cities. Force-delete every address first, including
// already-soft-deleted ones, so re-seeding doesn't hit a FK violation.
Address::withTrashed()->forceDelete();

City::query()->delete();
Province::query()->delete();

Expand Down
Loading
Loading