Skip to content

Commit 20d2fb5

Browse files
feat(shop): cart and checkout shipping flow
Guest/user cart (carts table) with add/update/remove, header count badge, and merge of the guest cart into the account on login. Product buy box mirrors the cart per variety. Checkout shipping step picks an address (or adds one inline) and a per-destination shipping method, seeded in admin ShippingSeeder. Persian validation messages for the address form. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ca4a8b5 commit 20d2fb5

35 files changed

Lines changed: 2138 additions & 57 deletions

‎admin/database/seeders/DatabaseSeeder.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public function run(): void
2121
AncestorSeeder::class,
2222
AttributeSeeder::class,
2323
AttributeGroupCategorySeeder::class,
24+
ShippingSeeder::class,
2425
SettingSeeder::class,
2526
]);
2627
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Database\Seeders;
6+
7+
use App\Models\Province;
8+
use App\Models\ShippingCity;
9+
use App\Models\ShippingLine;
10+
use App\Models\ShippingMethod;
11+
use Illuminate\Database\Seeder;
12+
13+
class ShippingSeeder extends Seeder
14+
{
15+
/**
16+
* Seed the three storefront shipping methods. Costs are placeholder demo
17+
* values; descriptions and delivery windows match the storefront design.
18+
*/
19+
public function run(): void
20+
{
21+
ShippingCity::query()->delete();
22+
ShippingMethod::query()->delete();
23+
ShippingLine::query()->delete();
24+
25+
$tehran = Province::query()->where('name', 'تهران')->value('id');
26+
27+
// پیک ویژه تهران: same-day style courier, Tehran only.
28+
$courier = ShippingLine::query()->create([
29+
'name' => 'پیک ویژه تهران',
30+
'cost' => 50000,
31+
]);
32+
$courierMethod = ShippingMethod::query()->create([
33+
'shipping_line_id' => $courier->id,
34+
'name' => 'پیک ویژه تهران',
35+
'type' => 'پیک',
36+
'status' => true,
37+
]);
38+
ShippingCity::query()->create([
39+
'shipping_method_id' => $courierMethod->id,
40+
'province_id' => $tehran,
41+
'amount' => 50000,
42+
'sending_days' => '۲۴ ساعت کاری',
43+
'description' => 'تحویل ۲۴ ساعت کاری پس از ثبت سفارش (روزهای تعطیل جزو زمان آماده‌سازی و ارسال محاسبه نمی‌شوند). سفارش‌هایی که در روزهای تعطیل رسمی ثبت شوند، در اولین روز کاری بعد پردازش و روز کاری پس از آن ارسال خواهند شد.',
44+
'status' => true,
45+
]);
46+
47+
// پست پیشتاز: nationwide.
48+
$post = ShippingLine::query()->create([
49+
'name' => 'پست',
50+
'cost' => 45000,
51+
]);
52+
$postMethod = ShippingMethod::query()->create([
53+
'shipping_line_id' => $post->id,
54+
'name' => 'پست پیشتاز',
55+
'type' => 'پست',
56+
'status' => true,
57+
]);
58+
ShippingCity::query()->create([
59+
'shipping_method_id' => $postMethod->id,
60+
'amount' => 45000,
61+
'sending_days' => '۲ تا ۴ روز کاری',
62+
'description' => 'ارسال از طریق پست پیشتاز (۲ تا ۴ روز کاری).',
63+
'status' => true,
64+
]);
65+
66+
// تحویل حضوری از فروشگاه: nationwide, postpaid (pay on delivery).
67+
$pickup = ShippingLine::query()->create([
68+
'name' => 'تحویل حضوری از فروشگاه',
69+
'cost' => 0,
70+
]);
71+
$pickupMethod = ShippingMethod::query()->create([
72+
'shipping_line_id' => $pickup->id,
73+
'name' => 'تحویل حضوری از فروشگاه',
74+
'type' => 'حضوری',
75+
'status' => true,
76+
]);
77+
ShippingCity::query()->create([
78+
'shipping_method_id' => $pickupMethod->id,
79+
'pay_on_delivery' => true,
80+
'amount' => null,
81+
'description' => 'تحویل حضوری (شنبه تا چهارشنبه، به‌جز روزهای تعطیل) — بعد از آماده‌سازی، زمان تحویل با شما از طرف فروشگاه هماهنگ می‌شود. هزینه ارسال به صورت پس‌کرایه می‌باشد.',
82+
'status' => true,
83+
]);
84+
}
85+
}

‎shop/AGENTS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ This is `shop/`, the customer-facing storefront (Laravel 13 + Inertia). The Fila
190190
- **Auth is mobile-first** (`AuthController`, routes under `/login`). OTP is the primary path (and registers on first login); password login is an alternative. Codes live in cache via `SendOtpCode`/`VerifyOtpCode` and are "sent" by a logged stub - replace with a real SMS provider later. The shared `users` table has NOT NULL `email`/`password` and a non-unique `mobile`, so OTP sign-ups seed placeholders (`User::placeholderEmail`) and a random password. `auth.user` + auth `flash` are shared in `HandleInertiaRequests`.
191191
- **Account area** lives under `/account` (auth middleware, `AccountController`). `AccountLayout.vue` is the shared shell (sidebar nav + user card + logout) wrapping `AppLayout`; account pages are `noindex`. Built: dashboard (`Account/Dashboard.vue`) and profile edit (`Account/Profile.vue`, edits name/email; mobile read-only; placeholder email hidden via `User::hasPlaceholderEmail`). Not-yet-built sidebar links render `Account/ComingSoon.vue`. Profile saves flash a generic `status` message (shared in `HandleInertiaRequests`); `UserDTO` shapes the user payload.
192192
- **Addresses** (`AddressController`, `/account/addresses`) are immutable history: editing creates a NEW row (`UpdateUserAddress`) that inherits `prime` and soft-deletes the old one (kept for order history, hidden from the active list). First address auto-primary; one primary per user via the model `saved` hook; any address can be promoted from the list (`setPrimary`, `PUT /account/addresses/{address}/primary`); delete is soft-only (`destroy`, `DELETE /account/addresses/{address}`) to preserve order history, and deleting the default promotes the newest remaining address. The shared table has no plate/unit columns, so those round-trip through `description` as JSON (`App\Support\AddressDescription`); recipient name uses the account name (no per-address column). Province/city are cascading (`/account/addresses-cities`). **Neshan maps**: the location (lat/long) is a section separate from the province/city selects. Two key types. Picking a point on either map sets lat/long and auto-fills the address via reverse geocoding (`ReverseGeocode`, `/account/addresses-reverse`, service key). With a `web.` map key (`services.neshan.map_key`, `NESHAN_MAP_KEY`, shared per-page) the form shows the interactive `NeshanMap.vue` (draggable marker, client-side tiles, fast). Without a `web.` key the form falls back to `MapPicker.vue`: a draggable Neshan static map (proxied `StaticMap` -> `/account/addresses-static`, service key) with a fixed center pin (the selected point is always the map center), drag-to-pan (pixel delta -> lat/long via Web Mercator) and zoom buttons; the proxy caches images (30 days) and fails gracefully on timeout (the static plan is slow, so the web key is preferred). All Neshan calls use the server-side `service.` key (`NESHAN_SERVICE_KEY`); only the `web.` key ever reaches the browser. Note: `service.` keys are IP-scoped in the Neshan panel, the server's (public) egress IP must be allowed. The nullable `latitude`/`longitude` columns live on the admin-owned `addresses` table (in the `create_addresses_table` migration).
193+
- **Cart** (`carts`, admin-owned: one row per variety line) works for guests and users. `ResolveCartOwner` keys the cart by `user_id` when logged in, else the guest `session_id`; on login `MergeGuestCart` (called from `AuthController@login` with the pre-regeneration session id) folds the guest lines onto the account, combining and clamping to inventory. `CartController` (`/cart`) + `Cart/` actions (`AddToCart`, `GetCartLines`, `BuildCartSummary`) drive add/update/remove; quantity is always clamped to the variety `inventory` server-side. The cart is inventory-neutral (never touches `varieties.inventory`; see `ORDER.md`). Pricing per line is the variety `sale_price ?? price` via `CalculatePricing`; `CartSummaryDTO` totals items, savings and payable. `Cart/Index.vue` renders the checkout stepper (`CheckoutSteps.vue`), `CartLine.vue` rows and `CartSummary.vue`. Add-to-cart is wired in the product `BuyBox` (requires a selected variety). The header badge reads the shared `cart.count` prop (`HandleInertiaRequests`, guarded by `rescue`).
194+
- **Checkout** (`/checkout`, auth) is being built. Step 2 is shipping (`CheckoutController@shipping` + `Checkout/Shipping.vue`): pick a saved address (or add one inline when none exist, reusing `AddressFormModal`) and a shipping method; an empty cart redirects back to `/cart`. Shipping methods are resolved per destination by `GetShippingMethods` over the admin-owned `shipping_lines → shipping_methods → shipping_cities` hierarchy (most specific scope wins: exact city > province > nationwide null/null); cost `null` + `pay_on_delivery` means postpaid ("پس‌کرایه"), `0` means free. Changing the address refreshes the list via `/checkout/methods` (JSON, like the cities endpoint). The selected `address_id` + `shipping_method_id` are validated (method must be available for the address) and stored in the session; the cost is added to the summary payable (`CartSummary` `showShipping`/`shipping` props). Seed data is in admin `ShippingSeeder` (پیک ویژه تهران Tehran-only، پست پیشتاز nationwide، تحویل حضوری از فروشگاه pay-on-delivery). The payment step (`Checkout/Payment.vue`) is a placeholder until the gateway/receipt flow (Phase 4); order creation and coupons are not built yet. Shop has read-only models `ShippingLine`/`ShippingMethod`/`ShippingCity` for these shared tables.
195+
- **PWA / install** is wired via `public/manifest.webmanifest` + `public/icons/*` + Apple meta tags in `app.blade.php`. `InstallPrompt.vue` (mounted in `AppLayout`) is an iOS-Safari-only guided "Add to Home Screen" bottom-sheet (iOS has no native prompt); it skips standalone mode and snoozes 7 days after dismissal (`localStorage`). Android/desktop Chrome rely on the native manifest install prompt.
193196

194197
## Frontend (Inertia + Vue)
195198

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\Cart;
6+
7+
use App\Models\Cart;
8+
use App\Models\Variety;
9+
10+
class AddToCart
11+
{
12+
/**
13+
* Add (or increase) a variety line for the given owner. Quantity is clamped
14+
* to the variety's available inventory; never touches inventory itself.
15+
*
16+
* @param array{user_id: int}|array{session_id: string} $owner
17+
*/
18+
public function __invoke(array $owner, Variety $variety, int $count): Cart
19+
{
20+
$line = Cart::query()
21+
->where($owner)
22+
->where('variety_id', $variety->id)
23+
->first();
24+
25+
$current = $line === null ? 0 : $line->count;
26+
$desired = $current + max(1, $count);
27+
$clamped = max(1, min($desired, $variety->inventory));
28+
29+
if ($line === null) {
30+
return Cart::query()->create([
31+
...$owner,
32+
'variety_id' => $variety->id,
33+
'count' => $clamped,
34+
]);
35+
}
36+
37+
$line->update(['count' => $clamped]);
38+
39+
return $line;
40+
}
41+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\Cart;
6+
7+
use App\DTOs\CartLineDTO;
8+
use App\DTOs\CartSummaryDTO;
9+
use Illuminate\Support\Collection;
10+
11+
class BuildCartSummary
12+
{
13+
/**
14+
* Totals for a set of cart lines. Discount is the saving from variety sale
15+
* prices; coupons are not applied here.
16+
*
17+
* @param Collection<int, CartLineDTO> $lines
18+
*/
19+
public function __invoke(Collection $lines): CartSummaryDTO
20+
{
21+
$itemsTotal = (int) $lines->sum(fn (CartLineDTO $line): int => $line->lineOriginalTotal());
22+
$payable = (int) $lines->sum(fn (CartLineDTO $line): int => $line->lineTotal());
23+
24+
return new CartSummaryDTO(
25+
count: (int) $lines->sum(fn (CartLineDTO $line): int => $line->count),
26+
itemsTotal: $itemsTotal,
27+
discount: $itemsTotal - $payable,
28+
payable: $payable,
29+
);
30+
}
31+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\Cart;
6+
7+
use App\Actions\Catalog\CalculatePricing;
8+
use App\Actions\Catalog\TransformImage;
9+
use App\Actions\Product\VarietyAttributes;
10+
use App\DTOs\CartLineDTO;
11+
use App\Models\Attribute;
12+
use App\Models\Cart;
13+
use App\Models\Variety;
14+
use Illuminate\Support\Collection;
15+
16+
class GetCartLines
17+
{
18+
public function __construct(
19+
private CalculatePricing $pricing,
20+
private TransformImage $transformImage,
21+
private VarietyAttributes $varietyAttributes,
22+
) {}
23+
24+
/**
25+
* The cart lines for an owner, newest first, shaped for the storefront.
26+
*
27+
* @param array{user_id: int}|array{session_id: string} $owner
28+
* @return Collection<int, CartLineDTO>
29+
*/
30+
public function __invoke(array $owner): Collection
31+
{
32+
return Cart::query()
33+
->where($owner)
34+
->with([
35+
'variety.product.featuredImage',
36+
'variety.image',
37+
'variety.attribute.attributeGroup',
38+
'variety.attributes.attributeGroup',
39+
])
40+
->latest()
41+
->get()
42+
->map(fn (Cart $line): CartLineDTO => $this->line($line))
43+
->values();
44+
}
45+
46+
private function line(Cart $line): CartLineDTO
47+
{
48+
$variety = $line->variety;
49+
$product = $variety->product;
50+
$pricing = $this->pricing->forVariety($variety);
51+
52+
$image = $this->transformImage->__invoke(
53+
$variety->image ?? $product->featuredImage,
54+
);
55+
56+
return new CartLineDTO(
57+
id: $line->id,
58+
varietyId: $variety->id,
59+
heading: $product->heading,
60+
url: '/products/'.$product->slug,
61+
image: $image,
62+
color: $variety->color,
63+
attributes: $this->attributes($variety),
64+
unitPrice: $pricing['salePrice'] ?? $pricing['price'],
65+
originalPrice: $pricing['price'],
66+
discountPercent: $pricing['discountPercent'],
67+
count: $line->count,
68+
inventory: $variety->inventory,
69+
inStock: $variety->has_stock && $variety->inventory > 0,
70+
);
71+
}
72+
73+
/**
74+
* @return array<int, array{group: string|null, value: string}>
75+
*/
76+
private function attributes(Variety $variety): array
77+
{
78+
return collect($this->varietyAttributes->__invoke($variety))
79+
->map(fn (Attribute $attribute): array => [
80+
'group' => $attribute->attributeGroup?->name,
81+
'value' => $attribute->value,
82+
])
83+
->all();
84+
}
85+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\Cart;
6+
7+
use App\Models\Cart;
8+
use App\Models\User;
9+
10+
class MergeGuestCart
11+
{
12+
/**
13+
* Move a guest session's cart lines onto the user after login. When the
14+
* same variety already exists for the user, quantities are combined and
15+
* clamped to the variety's inventory.
16+
*/
17+
public function __invoke(User $user, ?string $sessionId): void
18+
{
19+
if ($sessionId === null || $sessionId === '') {
20+
return;
21+
}
22+
23+
$guestLines = Cart::query()
24+
->where('session_id', $sessionId)
25+
->whereNull('user_id')
26+
->with('variety')
27+
->get();
28+
29+
foreach ($guestLines as $guestLine) {
30+
$existing = Cart::query()
31+
->where('user_id', $user->id)
32+
->where('variety_id', $guestLine->variety_id)
33+
->first();
34+
35+
if ($existing === null) {
36+
$guestLine->update([
37+
'user_id' => $user->id,
38+
'session_id' => null,
39+
]);
40+
41+
continue;
42+
}
43+
44+
$cap = $guestLine->variety->inventory;
45+
$existing->update([
46+
'count' => max(1, min($existing->count + $guestLine->count, $cap)),
47+
]);
48+
$guestLine->delete();
49+
}
50+
}
51+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\Cart;
6+
7+
use App\Models\User;
8+
use Illuminate\Http\Request;
9+
10+
class ResolveCartOwner
11+
{
12+
/**
13+
* The column/value pair that identifies the current cart: the user id when
14+
* logged in, otherwise the guest session id.
15+
*
16+
* @return array{user_id: int}|array{session_id: string}
17+
*/
18+
public function __invoke(Request $request): array
19+
{
20+
$user = $request->user();
21+
22+
if ($user instanceof User) {
23+
return ['user_id' => $user->id];
24+
}
25+
26+
return ['session_id' => $request->session()->getId()];
27+
}
28+
}

0 commit comments

Comments
 (0)