# CrystalParadise.in — Wholesale B2B Project Spec (v1)

> The single source of truth for building the wholesale site from scratch. Hand this to Claude Code at the start of every implementation session.

---

## 1. Scope (locked)

**In scope for v1:** Catalog with hidden prices, B2B registration with admin approval, role-based price visibility (server-side enforced), cart + checkout, Razorpay payment, Shiprocket shipping, Filament admin panel, order management, GST handling.

**Out of scope for v1 (deferred):** ERP sync, WhatsApp automation, downloadable catalog PDF, customer-specific pricing tiers (everyone approved sees the same wholesale price for v1).

**Why defer those?** Each is a multi-day integration on its own. Shipping v1 without them gets the site earning revenue; they slot in cleanly later because the data model below already has the right hooks.

---

## 2. Tech stack (locked)

| Layer | Choice | Why |
|---|---|---|
| Framework | **Laravel 11** | Modern PHP, batteries-included, your dev already knows it |
| Database | **MySQL 8** | Default with Laravel, runs everywhere |
| Auth scaffolding | **Laravel Breeze (Blade)** | Generates login/register/reset, we extend it |
| Frontend rendering | **Blade templates** | Server-rendered HTML, SEO-friendly, no build step |
| Interactivity | **Livewire 4** | Write dynamic components in PHP — no React, no JS framework |
| Light client JS | **Alpine.js** | 15KB. For dropdowns, modals, simple toggles |
| Styling | **Tailwind CSS** | Comes with Breeze; utility-first, fast to build |
| Admin panel | **Filament 5** | Free. Generates CRUD UIs from Eloquent models. Saves ~6 weeks |
| Payment | **Razorpay PHP SDK** (`razorpay/razorpay`) | Official, GST invoice support |
| Shipping | **Shiprocket REST API** | No official SDK; wrap in a service class |
| Queue driver | **Redis** | For sending approval emails async, future ERP jobs |
| Cache driver | **Redis** | Same instance |
| Hosting | **VPS** (Hostinger/DigitalOcean) + **Laravel Forge** for deploys | Forge ($12/mo) makes server management painless |

### Teaching note: what these pieces actually do

- **Eloquent ORM** (built into Laravel): lets you work with database rows as PHP objects. Instead of writing SQL, you write `$user = User::find(1); $user->orders;` and Laravel figures out the joins.
- **Livewire**: when a user clicks a button, instead of triggering JavaScript, the browser quietly sends an AJAX call to PHP, the PHP component re-renders, and Livewire patches the changed HTML back into the page. It feels like React but is 100% PHP code.
- **Filament**: a Laravel package that reads your Eloquent models and auto-generates an admin dashboard — tables, forms, filters, bulk actions. You write a single "Resource" class per model and get a working admin page.
- **Queues**: long-running tasks (like sending an email) get pushed to a queue and processed in the background by a worker process, so the user's web request doesn't wait.

---

## 3. Folder structure

This is what your `app/` directory should look like by the end of v1. Create folders as you go — don't create empty ones up front.

```
app/
├── Enums/
│   ├── UserStatus.php          # pending|approved|rejected|blocked
│   ├── BusinessType.php        # wholesaler|astrologer|retailer|other
│   └── OrderStatus.php         # pending|paid|shipped|delivered|cancelled
├── Filament/
│   └── Resources/
│       ├── UserResource.php    # Admin approval queue + management
│       ├── ProductResource.php
│       ├── CategoryResource.php
│       └── OrderResource.php
├── Http/
│   ├── Controllers/
│   │   ├── Auth/                  # From Breeze, customised
│   │   ├── CatalogController.php  # Product listing + detail
│   │   ├── CartController.php
│   │   └── CheckoutController.php
│   └── Middleware/
│       ├── EnsureUserIsApproved.php   # Gates cart/checkout
│       └── RedirectIfPending.php      # After login if not approved
├── Livewire/
│   ├── ProductCard.php         # Shows price-or-"Login to see"
│   ├── CartIcon.php            # Header cart count, updates live
│   ├── QuantitySelector.php
│   └── RegistrationForm.php    # Multi-step registration
├── Models/
│   ├── User.php
│   ├── Product.php
│   ├── Category.php
│   ├── Order.php
│   ├── OrderItem.php
│   ├── Address.php
│   └── Cart.php
├── Policies/
│   └── ProductPolicy.php       # Authorises viewing prices
├── Services/
│   ├── PriceVisibilityService.php  # THE security-critical class
│   ├── RazorpayService.php
│   ├── ShiprocketService.php
│   └── GstCalculator.php
└── Notifications/
    ├── AccountApproved.php     # Email after approval
    └── AccountRejected.php
```

### Teaching note: why this split exists

- **Controllers** handle HTTP requests (parse input, return a view or JSON). They should be *thin* — no business logic.
- **Models** represent database tables. They hold relationships (`$order->items`) and accessors (computed properties like `$user->isApproved()`).
- **Services** hold business logic that doesn't belong in a controller or model — typically anything that talks to an external API (Razorpay) or implements a complex rule (price visibility).
- **Policies** answer yes/no authorisation questions: "Can this user see this product's price?" Laravel calls them automatically from controllers and views.
- **Middleware** runs before a request reaches a controller — perfect for "block unapproved users from /cart".
- **Livewire components** are interactive UI pieces. Each is one PHP class + one Blade view.
- **Enums** are PHP types that restrict a value to a fixed set (status can ONLY be pending/approved/rejected/blocked, nothing else). PHP 8.1+ has built-in enums and Laravel auto-casts them in models.

---

## 4. Database schema

Every table, every column, every why. Migrations should be created in this order.

### 4.1 `users` table (extend Breeze default)

Breeze gives you `id, name, email, password, email_verified_at, remember_token, timestamps`. **Add** the following columns via a second migration:

```php
$table->string('mobile', 15)->nullable()->unique();
$table->string('company_name')->nullable();
$table->string('gst_number', 15)->nullable()->index();
$table->string('business_type');           // cast to BusinessType enum
$table->string('city')->nullable();
$table->string('state')->nullable();
$table->string('website')->nullable();
$table->string('instagram')->nullable();
$table->string('status')->default('pending')->index();   // cast to UserStatus enum
$table->text('verification_notes')->nullable();    // admin-only notes
$table->timestamp('status_changed_at')->nullable();
$table->foreignId('status_changed_by')->nullable()->constrained('users');
$table->timestamp('approved_at')->nullable();
```

**Why each one:**
- `mobile` is the primary contact channel in Indian B2B — index for fast lookup, unique because phone = identity.
- `gst_number` indexed because admins will search by it during verification. Nullable because astrologers don't need one (spec section 13).
- `status` is **the** field that gates everything. Indexed because every query that lists products will check it. Stored as string in DB, cast to a PHP enum in the model so you can't accidentally set an invalid value.
- `status_changed_by` is a foreign key to the admin user who approved/rejected — vital for audit trails.
- `approved_at` separate from `status_changed_at` because you want to know when someone was *first* approved, even if blocked later.

### 4.2 `categories` table

```php
$table->id();
$table->string('name');
$table->string('slug')->unique();         // for SEO URLs like /category/rudraksha
$table->foreignId('parent_id')->nullable()->constrained('categories'); // for nesting
$table->string('image')->nullable();
$table->integer('sort_order')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
```

**Why `parent_id`?** Crystal/astrology shops typically have nested categories (Crystals → Rose Quartz → Bracelets). The self-referencing foreign key handles unlimited nesting depth.

### 4.3 `products` table

```php
$table->id();
$table->string('sku')->unique();          // your internal stock code
$table->string('name');
$table->string('slug')->unique();
$table->foreignId('category_id')->constrained();
$table->text('short_description')->nullable();
$table->longText('description')->nullable();
$table->decimal('mrp', 10, 2);            // sticker price
$table->decimal('wholesale_price', 10, 2);  // price for approved users
$table->decimal('cost_price', 10, 2)->nullable();  // admin-only, for margin
$table->integer('moq')->default(1);       // Minimum Order Quantity
$table->integer('stock')->default(0);
$table->decimal('weight_grams', 8, 2)->nullable();  // for Shiprocket rate calc
$table->string('hsn_code', 10)->nullable();         // for GST invoice
$table->decimal('gst_rate', 5, 2)->default(18.00);  // GST % e.g. 18.00
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();   // never hard-delete products that have order history
```

**Why two price columns?** `mrp` is for the (rare) future case of showing crossed-out prices. `wholesale_price` is what approved users actually pay. Storing both keeps the option open.

**Why `softDeletes()`?** When a product is deleted, old orders still reference it. Soft delete sets a `deleted_at` timestamp instead of removing the row, so order history stays intact.

**Why `hsn_code` and `gst_rate` per-product?** GST law: every product needs an HSN code on the invoice, and different products have different GST rates (0%, 5%, 12%, 18%, 28%). Storing per-product means correct invoices.

### 4.4 `product_images` table

```php
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->string('path');                    // storage path
$table->integer('sort_order')->default(0);
$table->boolean('is_primary')->default(false);
$table->timestamps();
```

Separate table because each product has multiple images. `cascadeOnDelete` so images vanish when the product does.

### 4.5 `addresses` table

```php
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('label')->nullable();      // "Shop", "Warehouse"
$table->string('name');
$table->string('mobile');
$table->string('line1');
$table->string('line2')->nullable();
$table->string('city');
$table->string('state');
$table->string('pincode', 6)->index();    // Shiprocket needs this
$table->boolean('is_default')->default(false);
$table->timestamps();
```

### 4.6 `carts` and `cart_items`

Two options: session-based cart (lost on logout) or DB-persisted cart. **Go DB-persisted** because B2B users browse over days before ordering.

```php
// carts
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();

// cart_items
$table->id();
$table->foreignId('cart_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained();
$table->integer('quantity');
$table->timestamps();
$table->unique(['cart_id', 'product_id']);  // one row per product per cart
```

### 4.7 `orders` and `order_items`

```php
// orders
$table->id();
$table->string('order_number')->unique();   // e.g. "CP-2026-00042" (human-readable)
$table->foreignId('user_id')->constrained();
$table->string('status')->default('pending');  // cast to OrderStatus enum
$table->decimal('subtotal', 10, 2);
$table->decimal('gst_total', 10, 2);
$table->decimal('shipping_charge', 10, 2)->default(0);
$table->decimal('total', 10, 2);
$table->json('shipping_address');           // snapshot at order time
$table->json('billing_address');
$table->string('razorpay_order_id')->nullable();
$table->string('razorpay_payment_id')->nullable();
$table->string('shiprocket_shipment_id')->nullable();
$table->string('awb_code')->nullable();     // tracking number
$table->text('notes')->nullable();
$table->timestamps();

// order_items
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained();
$table->string('product_name');             // snapshot
$table->string('product_sku');              // snapshot
$table->string('hsn_code')->nullable();     // snapshot
$table->decimal('unit_price', 10, 2);       // snapshot
$table->decimal('gst_rate', 5, 2);          // snapshot
$table->integer('quantity');
$table->decimal('line_total', 10, 2);
$table->timestamps();
```

**Why "snapshot" columns everywhere?** Crucial rule of e-commerce: an order is an immutable historical record. If a product's price changes tomorrow, the order placed today must still show yesterday's price. So we copy `product_name`, `unit_price`, `hsn_code` etc. *into* the order_items row at the moment of order creation. Don't ever join to the products table to display order history.

**Why `json` for addresses?** Same reason — if the user later edits their address, the old order shouldn't change.

---

## 5. User approval state machine

This is the most-misunderstood part of the spec. Get it right up front.

```
                ┌─────────────────┐
                │  Registration   │
                └────────┬────────┘
                         │
                         ▼
                   ┌──────────┐
        ┌──────────│ pending  │──────────┐
        │          └──────────┘          │
        │ admin                  admin   │
        │ approves               rejects │
        ▼                                ▼
   ┌──────────┐                   ┌──────────┐
   │ approved │────admin blocks──▶│ rejected │
   └──────────┘                   └──────────┘
        ▲                                │
        │                                │
        └────admin re-approves───────────┘
              (from blocked, not rejected)
                  │
                  ▼
            ┌──────────┐
            │ blocked  │
            └──────────┘
```

### Allowed actions per state

| State | Can log in? | Sees prices? | Can place orders? |
|---|---|---|---|
| `pending` | ❌ (sees "under review" page) | ❌ | ❌ |
| `approved` | ✅ | ✅ | ✅ |
| `rejected` | ❌ (sees rejection page) | ❌ | ❌ |
| `blocked` | ❌ (sees blocked page) | ❌ | ❌ |

**Why separate `rejected` and `blocked`?** Rejected = never approved (failed verification). Blocked = previously approved, now disabled (e.g. fraud, payment defaults). Different admin workflows, different email templates, different re-enable paths.

### Implementation in code

```php
// app/Enums/UserStatus.php
enum UserStatus: string
{
    case Pending = 'pending';
    case Approved = 'approved';
    case Rejected = 'rejected';
    case Blocked = 'blocked';

    public function canLogin(): bool
    {
        return $this === self::Approved;
    }

    public function canSeePrices(): bool
    {
        return $this === self::Approved;
    }
}
```

Then in `User` model:
```php
protected $casts = [
    'status' => UserStatus::class,
    'approved_at' => 'datetime',
];

public function isApproved(): bool
{
    return $this->status === UserStatus::Approved;
}
```

**Why an enum?** Compile-time safety — you literally cannot write `$user->status = 'aproved'` (typo) because the type system rejects it. Much safer than free-form strings.

---

## 6. Price visibility — the three-layer architecture

This is the single most security-critical piece of the build. Your spec section 12 calls it out for a reason: many B2B sites get this wrong by hiding prices only in CSS, which means anyone with View Source sees the price anyway.

### Rule: a price must be hidden in three independent places. Failing any one is a security bug.

#### Layer 1 — Model accessor (the data layer)

In `app/Models/Product.php`:

```php
public function priceFor(?User $user): ?float
{
    if (!$user || !$user->isApproved()) {
        return null;          // unauthorised users get nothing
    }
    return (float) $this->wholesale_price;
}
```

**Why a method, not a property?** Because price visibility depends on *who's asking*. You can't have a static `$product->price` — it's contextual to the current user.

#### Layer 2 — API/Resource layer (the JSON layer)

If you ever expose products via JSON (for Livewire, AJAX, or a future mobile app), wrap them in a Laravel API Resource that strips the price for unauthorised users:

```php
// app/Http/Resources/ProductResource.php
public function toArray($request): array
{
    $user = $request->user();
    return [
        'id' => $this->id,
        'name' => $this->name,
        'slug' => $this->slug,
        'image' => $this->primaryImage?->path,
        'price' => $this->priceFor($user),   // null if unauthorised
        'moq'   => $this->when($user?->isApproved(), $this->moq),
    ];
}
```

**Why `when()`?** Laravel's `when()` helper omits the field entirely from the JSON if the condition is false — so unauthorised users don't even see that MOQ exists in the response.

#### Layer 3 — Blade view (the rendering layer)

In every product card and product detail page:

```blade
@auth
    @if(auth()->user()->isApproved())
        <span class="price">₹{{ number_format($product->priceFor(auth()->user()), 2) }}</span>
    @else
        <span class="text-amber-600">Your account is under review</span>
    @endif
@else
    <a href="{{ route('login') }}" class="text-blue-600">Login to see price</a>
@endauth
```

### Testing the layers

Write three automated tests, one per layer. A merge that breaks any of them must be blocked:

```php
// tests/Feature/PriceVisibilityTest.php
public function test_guest_cannot_see_price_in_html(): void
{
    $product = Product::factory()->create(['wholesale_price' => 999]);
    $response = $this->get("/product/{$product->slug}");
    $response->assertOk();
    $response->assertDontSee('999');          // price string not in HTML
    $response->assertSee('Login to see price');
}

public function test_pending_user_cannot_see_price(): void { /* … */ }
public function test_approved_user_sees_price(): void { /* … */ }
```

**Why `assertDontSee` not `assertSee("login to see")`?** The strong test is "the actual number is not present anywhere in the response body". This catches CSS-only hiding mistakes.

---

## 7. Routes

```php
// routes/web.php (simplified)

// PUBLIC
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/category/{slug}', [CatalogController::class, 'category'])->name('category');
Route::get('/product/{slug}', [CatalogController::class, 'product'])->name('product');

// AUTH (from Breeze)
require __DIR__.'/auth.php';

// LOGGED-IN BUT ANY STATUS
Route::middleware('auth')->group(function () {
    Route::get('/account/pending', PendingPage::class)->name('account.pending');
    Route::get('/account/rejected', RejectedPage::class)->name('account.rejected');
    Route::get('/account/blocked', BlockedPage::class)->name('account.blocked');
});

// LOGGED-IN AND APPROVED ONLY
Route::middleware(['auth', 'approved'])->group(function () {
    Route::get('/account', [AccountController::class, 'index'])->name('account');
    Route::get('/cart', [CartController::class, 'index'])->name('cart');
    Route::post('/cart/add', [CartController::class, 'add'])->name('cart.add');
    Route::get('/checkout', [CheckoutController::class, 'index'])->name('checkout');
    Route::post('/checkout/place', [CheckoutController::class, 'place'])->name('checkout.place');
    Route::get('/orders', [OrderController::class, 'index'])->name('orders');
    Route::get('/orders/{order}', [OrderController::class, 'show'])->name('orders.show');
});

// ADMIN (auto-mounted by Filament at /admin)
```

The `approved` middleware is your custom `EnsureUserIsApproved` class that redirects unapproved users to their status page.

---

## 8. Registration & approval flow (end-to-end)

1. **User submits registration form** → controller validates input (including conditional GST validation: required if business_type ∈ wholesaler/retailer/other; optional if astrologer).
2. **User record created** with `status = 'pending'`. Password is hashed.
3. **Admin gets notified** — `Notification::send($admins, new NewRegistrationPending($user))`. Sends an email; later you can add a Slack/WhatsApp channel.
4. **User is auto-logged-in** but immediately redirected to `/account/pending` because the `approved` middleware blocks them from everything else.
5. **Admin opens Filament dashboard** at `/admin/users`, sees the pending queue, reviews details, hits "Approve" or "Reject" (with optional notes).
6. **On approval**: `$user->update(['status' => UserStatus::Approved, 'approved_at' => now()])` → dispatches `AccountApproved` notification (email to user).
7. **User logs back in**, middleware lets them through, prices visible everywhere.

### GST validation rule (spec section 13)

```php
// in StoreRegistrationRequest::rules()
'gst_number' => [
    Rule::requiredIf(fn () => in_array($this->business_type, ['wholesaler', 'retailer', 'other'])),
    'nullable',
    'regex:/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/',
],
```

The regex validates GSTIN format (15 chars, structured). Always validate format on input — don't trust the user.

---

## 9. Order & checkout flow

1. Approved user clicks "Add to cart" on a product → Livewire component adds to `cart_items`, updates cart icon live.
2. User goes to `/cart` → reviews items, can update quantity (respecting MOQ).
3. User clicks "Checkout" → fills/selects address, sees order summary with GST breakdown.
4. User clicks "Pay" → backend:
   - Creates an `orders` row with `status = pending`.
   - Calls Razorpay API to create a Razorpay order (returns `razorpay_order_id`).
   - Renders Razorpay's JS checkout button with that order ID.
5. User completes payment → Razorpay calls our webhook → we verify signature → mark order `paid` → clear cart → send confirmation email.
6. Admin sees order in Filament → clicks "Push to Shiprocket" → our `ShiprocketService` creates the shipment → returns AWB → saved to order.
7. User sees order status update in `/orders`.

### Razorpay integration — minimum viable code shape

```php
// app/Services/RazorpayService.php
class RazorpayService
{
    public function __construct(private \Razorpay\Api\Api $api) {}

    public function createOrder(Order $order): array
    {
        return $this->api->order->create([
            'receipt'         => $order->order_number,
            'amount'          => (int) ($order->total * 100),  // paise
            'currency'        => 'INR',
            'payment_capture' => 1,
        ])->toArray();
    }

    public function verifySignature(string $orderId, string $paymentId, string $signature): bool
    {
        try {
            $this->api->utility->verifyPaymentSignature([
                'razorpay_order_id'   => $orderId,
                'razorpay_payment_id' => $paymentId,
                'razorpay_signature'  => $signature,
            ]);
            return true;
        } catch (\Razorpay\Api\Errors\SignatureVerificationError) {
            return false;
        }
    }
}
```

**Why `* 100`?** Razorpay (and most payment gateways) deal in the smallest currency unit. INR's smallest unit is paise. ₹100 = 10000 paise. Always store rupees in DB, multiply when calling the API.

**Why verify signature?** When Razorpay redirects back, the URL contains a signature you can verify cryptographically. Without this check, an attacker could fake a successful payment by manipulating URL params.

---

## 10. Filament admin panel — what to build

Filament auto-generates most of the UI from your models. You write a Resource class per model, declaring fields and table columns. The Resources you need:

### `UserResource`
- Table columns: company_name, mobile, business_type (badge), gst_number, status (color-coded badge), created_at
- Filters: status, business_type
- Bulk actions: bulk approve, bulk reject (with confirmation)
- Single-record actions: "Approve" / "Reject" buttons that prompt for verification_notes
- View page: full registration details + audit log of status changes

### `ProductResource`
- Standard CRUD with image upload, category select, GST rate dropdown
- Bulk actions: activate/deactivate, bulk price update (CSV import optional in v1)
- Inline editing for stock

### `CategoryResource`
- Tree view (Filament has a tree-view plugin) for nested categories

### `OrderResource`
- Read-mostly. Status transitions via actions ("Mark as shipped", "Push to Shiprocket")
- Invoice PDF generation (Laravel + DomPDF, or skip until v1.1)

### Filament is genuinely 80% configuration, 20% custom code. Don't over-engineer.

---

## 11. Laravel concepts mini-glossary

While you build, you'll see these terms constantly. Keep this handy.

| Term | What it is |
|---|---|
| **Migration** | A PHP file that describes a database schema change (create table, add column). Versioned, reversible, run with `php artisan migrate`. |
| **Seeder** | A PHP file that inserts test data into the DB. Run with `php artisan db:seed`. |
| **Factory** | A blueprint for creating fake model instances for tests/seeding. `User::factory()->create()` makes a fake user. |
| **Model** | A PHP class that represents one DB table. Extends `Illuminate\Database\Eloquent\Model`. |
| **Eloquent** | Laravel's ORM (the library that turns DB rows into PHP objects). |
| **Relationship** | A method on a model declaring how it links to another (`hasMany`, `belongsTo`). Lets you do `$user->orders` instead of writing SQL joins. |
| **Accessor** | A method that computes a virtual property (`getFullNameAttribute()` lets you do `$user->full_name`). |
| **Cast** | Tells Eloquent to convert a column to a specific PHP type when read (e.g. JSON column → array, status column → enum). |
| **Middleware** | A class that runs before a request hits a controller — for auth, redirects, logging. |
| **Policy** | A class with methods like `view()`, `create()`, `update()` that answer authorisation questions. |
| **Notification** | A class representing a message to a user, send-able via email, SMS, Slack etc. with one line of code. |
| **Job** | A class representing a unit of background work, pushed onto a queue. |
| **Service Container** | Laravel's built-in dependency injection system — auto-wires class dependencies. |
| **Artisan** | Laravel's CLI tool. `php artisan make:model Product -mfs` creates a model, migration, factory, and seeder in one command. |

---

## 12. Build order (do these in sequence)

Each block depends on the previous one. Don't jump ahead.

**Block A — Foundation**
1. `composer create-project laravel/laravel crystalparadise`
2. Install Breeze with Blade stack → run migrations
3. Set up DB connection in `.env`
4. Install Filament 3, create first admin user
5. Commit. You now have login + an empty admin panel.

**Block B — Data model**
1. Write all enums (`UserStatus`, `BusinessType`, `OrderStatus`)
2. Write migrations in the order in section 4
3. Write models with relationships and casts
4. Write factories for User, Category, Product
5. Write a seeder that creates ~20 products across 3 categories
6. Run `php artisan migrate:fresh --seed` — DB now populated.

**Block C — Auth + approval gate**
1. Extend Breeze registration form with B2B fields
2. Custom `StoreRegistrationRequest` form request with GST validation
3. `EnsureUserIsApproved` middleware
4. Status pages (`/account/pending`, `/account/rejected`, `/account/blocked`)
5. `UserResource` in Filament with Approve/Reject actions
6. `AccountApproved` notification
7. **Write tests** for the state machine — every transition.

**Block D — Catalog with price visibility**
1. `CatalogController` + Blade views for category and product pages
2. Layer 1: `priceFor()` accessor on Product model
3. Layer 3: Blade conditional rendering
4. **Write the three price-visibility tests** — these gate further work
5. Livewire `ProductCard` component for shared rendering

**Block E — Cart + checkout**
1. Cart model + Livewire `AddToCart` button + `CartIcon`
2. Cart page + quantity editor with MOQ enforcement
3. Checkout page + address selection
4. Razorpay integration + webhook
5. Order confirmation page + email

**Block F — Admin + shipping**
1. `ProductResource`, `CategoryResource`, `OrderResource` in Filament
2. Shiprocket service + "Push to Shiprocket" action on orders
3. AWB tracking display in user order page
4. Invoice PDF (optional v1)

**Block G — Polish & launch**
1. SEO meta tags on catalog pages
2. Sitemap.xml + robots.txt
3. Error pages (404, 500) with brand styling
4. Backups configured on the server
5. SSL + production `.env` + queue workers running

---

## 13. First commands (literally Day 1)

```bash
# 1. Create the project
composer create-project laravel/laravel crystalparadise
cd crystalparadise

# 2. Set DB credentials in .env, then:
php artisan migrate

# 3. Install Breeze with Blade + Alpine
composer require laravel/breeze --dev
php artisan breeze:install blade
npm install && npm run build

# 4. Install Livewire
composer require livewire/livewire

# 5. Install Filament
composer require filament/filament:"^3.0" -W
php artisan filament:install --panels
php artisan make:filament-user   # creates your first admin

# 6. Run the dev server
php artisan serve                # http://localhost:8000
npm run dev                      # in another terminal, for Tailwind hot reload

# 7. Visit /admin → log in with the user you just created
# 8. Commit to git → you've completed Block A.
```

---

## 14. Acceptance test checklist (from spec section 10)

Every item below must pass before launch. Write each as an automated test where possible.

- [ ] Unregistered visitor cannot see prices anywhere (test all three: product card, product detail, JSON API).
- [ ] Submitting registration creates a user with `status = pending`.
- [ ] Pending user attempting to log in succeeds at auth but is redirected to `/account/pending`.
- [ ] Pending user hitting `/cart` directly via URL is redirected.
- [ ] Admin approval changes user status, sets `approved_at`, dispatches email.
- [ ] After approval, user can log in and sees prices.
- [ ] Rejected user sees rejection page on login.
- [ ] Blocked user sees blocked page on login.
- [ ] GST number required for wholesaler/retailer/other; optional for astrologer (test both).
- [ ] GST format validation rejects malformed numbers.
- [ ] MOQ enforced at cart and checkout level.
- [ ] Mobile responsive on iPhone SE width (375px) and standard Android.
- [ ] Razorpay payment success creates order with `status = paid`.
- [ ] Razorpay signature verification rejects tampered callbacks.
- [ ] Order placed by user appears in admin Filament panel.
- [ ] Order can be pushed to Shiprocket and AWB stored.

---

## 15. What's deliberately NOT in v1

- ERP sync (you said skip)
- WhatsApp notifications (email-only for v1; add MSG91/Gupshup later)
- Customer-specific pricing tiers (everyone approved sees same wholesale price)
- Downloadable catalog PDF
- Two-factor auth on admin login (add before public launch — important)
- Multi-language (English only)
- Multi-currency (INR only)

Add these as `v1.1` GitHub issues now, with a one-line description each, so they're not forgotten.

---

## How to use this spec

1. Commit this file to your repo's root.
2. At the start of every Claude Code session, say: *"Read PROJECT_SPEC.md. We're working on Block X, step Y."*
3. When something feels uncertain mid-build, the answer is usually in here. If it isn't, that's a spec gap — update this file *first*, then write code.
4. When you complete a block, mark it done at the bottom of this file with the date and commit hash.

Good builds start with a spec like this. Bad builds skip it. You now have one.
