# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

Laravel 10 site for **Yatra Card**, an NFC-based digital transport payment product for Nepal. It's two things in one app:

- A **marketing site** (home + ~20 content pages) that is purely presentational — no real payment/API integration behind any "Apply Now"/"Recharge" action.
- A **real admin CMS** at `/admin` (Breeze auth, its own guard prefix) backed by actual Eloquent models/migrations, used to manage the site's reference data (addresses, agents, transport routes, FAQs, statistics, partners, etc.) and to review card applications submitted from the public form.

A parent-level design brief (`../CLAUDE.md`, one directory up, outside this git repo) describes the original target spec this site was built from. Most of it is now implemented — treat this file as the source of truth for the site as it actually exists; fall back to the brief only for still-unbuilt pages or copy/content direction.

Some page content (careers, blog, news, notice board) is **not** database-backed — it's static data in `config/site_content.php`, looked up by slug. Note: `site_content.php` also has a leftover `agents` array from before the real `Agent` model existed — it is dead/unused; `PageController::agents()`/`home()` query the `Agent` Eloquent model, not this config key. Don't confuse the two.

## Commands

```
composer install && npm install     # install PHP + JS deps
cp .env.example .env && php artisan key:generate   # first-time setup
php artisan migrate                 # run migrations (see database/seeders/AdminUserSeeder for the one admin login)

php artisan serve                   # PHP dev server (default http://127.0.0.1:8000)
npm run dev                         # Vite dev server with HMR — run alongside artisan serve
npm run build                       # production asset build (writes public/build/, versioned in manifest.json)

php artisan test                    # run the full test suite
php artisan test --filter=TestName  # run a single test
vendor/bin/phpunit tests/Feature/ExampleTest.php   # run a single test file directly

php artisan route:list --name=admin # inspect the generated CRUD routes for an admin resource
php artisan migrate:status          # fast, non-interactive way to confirm migrations are applied
```

There is no linter/formatter configured beyond `laravel/pint` in `require-dev` (run via `vendor/bin/pint` if used). The test suite (`tests/Unit`, `tests/Feature`) is still the Laravel Breeze skeleton default (auth flows, profile) — no site-specific feature tests exist yet for the CRUD/frontend code.

## Architecture

### Routing

`routes/web.php` has two halves:

- **Frontend**: every public URL maps directly to a `Frontend\PageController` method — no per-page controllers. Adding a static page means adding a route + a `resources/views/frontend/pages/*.blade.php` view.
- **Admin** (`Route::prefix('admin')->name('admin.')`): protected by `auth`/`verified` middleware. Most resources are registered with the custom `Route::crudResource($uri, $controller)` macro (see below) instead of `Route::resource`. `card-applications` is a hand-registered `Route::resource(...)->only(['index','show'])` (read-only, nothing to create/edit), and `settings` is a hand-registered `GET`/`PUT` pair (singleton row, not a list).

`routes/auth.php` registers the Breeze auth routes under the same `admin.` prefix/name — there is no public registration; the one admin account is created via `database/seeders/AdminUserSeeder`.

### The `App\Crud` mini-framework — generic admin CRUD

This is the load-bearing abstraction in the codebase: every "reference data" admin resource (Addresses, Agents, Collection Centers, FAQ Categories, FAQs, Partners, Routes, Statistics, Yatayats) is implemented as almost-zero custom code by composing three pieces:

1. **`App\Crud\Resource`** (abstract, one subclass per model in `app/Crud/Resources/`) — declares `model()`, `fields()`, and optional config: `orderBy()`, `with()` (eager-load relations for the index), `togglable()` (which boolean column the generic AJAX publish-toggle flips, default `is_published`), `extraToggles()` (a second bespoke toggle, e.g. `Statistic::is_featured`), `softDeletes()`, `exportable()`, `rules()`/`messages()` (cross-field/business-rule escape hatch beyond what individual fields declare, evaluated against already-validated data), `resourceName()`/`label()` (auto-derived from the model name, overridable — e.g. `TransportRouteResource` overrides both so URIs/labels stay `routes`/`Route` instead of the auto-derived `transport-routes`).
2. **`App\Crud\Field`** (abstract, subclasses in `app/Crud/Fields/`) — a fluent per-field DSL: `Text::make('name')->required()->unique()`, chainable with `label()`, `nullable()`, `default()` (accepts a closure, e.g. `fn () => Model::max('display_order') + 1`), `rules()`, `hideFromIndex()`, `onlyOnForms()`/`onlyOnIndex()`, `mutate()` (post-validation transform), `component()` (escape hatch to point at any Blade component). A field's `resolveRules()`/`componentProps()`/`indexValue()` drive validation, form rendering, and index/export display respectively.
3. **`App\Crud\CrudController`** (abstract, one 3-line subclass per model in `app/Http/Controllers/Admin/`) — implements `index/create/store/show/edit/update/destroy` plus `togglePublished`, `bulkTogglePublished`, `bulkDestroy`, `export` (CSV), and (when `softDeletes()` is true) `trashed/restore/forceDelete`, all generically by reading the resource's field/config metadata. A concrete controller only implements `resource(): string` returning its `Resource` class — and, occasionally, one genuinely bespoke endpoint the generic set can't express (e.g. `StatisticController::toggleFeatured`, which duplicates `StatisticResource::rules()`'s `MAX_FEATURED` cap check for the AJAX toggle path).

Wiring: `Route::macro('crudResource', ...)` in `App\Crud\CrudServiceProvider::register()` expands `Route::crudResource('partners', PartnerController::class)` into the full route set (static-segment routes like `bulk-destroy`/`export`/`trashed` registered *before* `Route::resource()` so they aren't shadowed by its `{id}` wildcard, then the resource routes, then 3-segment routes like `{id}/restore` which never collide). Registered in `register()` rather than `boot()` since Laravel's `RouteServiceProvider` loads `routes/web.php` from its own `boot()`, and all providers' `register()` calls run before any provider's `boot()`.

Views are fully generic and shared across every resource: `resources/views/admin/crud/{index,create,edit,show,trashed}.blade.php` + `_form.blade.php` + `_field.blade.php`. `_field.blade.php` is the seam between the Field DSL and the Blade component library — it renders `<x-dynamic-component :component="$field->componentName()">` with a fixed superset of prop bindings; each component simply ignores props it doesn't declare.

**Authorization**: `CrudController` calls `$this->authorize(...)` on every action, but `AuthServiceProvider` currently has `Gate::before(fn ($user) => true)` — a deliberate placeholder since there's one admin user and no roles yet. The authorize() calls are a real seam already wired end-to-end; adding a second role means replacing that one `Gate::before` line with real policies, not touching `CrudController`.

**When adding a new admin CRUD resource**: create the model + migration, a `Resource` subclass, a `CrudController` subclass, register it with `Route::crudResource(...)`, and add a sidebar link — no new views, no new controller logic.

### Field type catalog

Implemented as `App\Crud\Fields\*` (used by the generic CRUD system above):

- **Text**, **Textarea**, **Number**, **Toggle** — plain scalar inputs.
- **Select** — static `options()` array, validated with `Rule::in`.
- **BelongsTo** — dropdown of a related model's rows, optional `publishedOnly()` gate and `relation()` override for FK-name-doesn't-match-relation-method cases (e.g. `Faq::faq_category_id` → `Faq::category()`).
- **Icon** — Font Awesome picker, validated against `Statistic::iconNames()`.
- **Image** — file upload to the `public` disk, `aspectRatio()` hint, "required" relaxed to "sometimes" on edit when a file already exists.
- **PhoneInput** — intl-tel-input widget (`forms.phone-input`, defaults to Nepal). Used by `AgentResource::contact_number` and `CollectionCenterResource::contact_person_phone` (replacing a plain `Text` field) — chain `->rules([...])` the same way for a stricter format check, same as before.
- **RichText** — Tiptap editor (`forms.rich-text`), stores a plain HTML string column. Used by `FaqResource::answer` (replacing `Textarea`) so answers can have formatting/lists/links.
- **DatePicker**, **ColorPicker**, **CurrencyInput**, **RadioGroup** — thin wrappers around the matching already-built Blade/JS widgets (flatpickr, Coloris, formatted numeric input, radio-button single-select). Not yet used by any resource (no current column needs a date/color/currency/radio field) — added so a future resource can declare one without inventing the wrapper first. `RadioGroup::options()` works exactly like `Select::options()`.

**Deliberately not wrapped as `Field` types** — `checkbox-group`, `tags-input`, `rating`, `range-slider`, `otp-input`, and multi-file `file-upload` all exist as Blade/JS components (`resources/views/components/forms/*.blade.php` + `resources/js/forms/*.js`, registered in `resources/js/forms.js`) but store/represent a *value shape* (array, many-to-many tag set, numeric score, verified-OTP state, file collection) that no current migration/column matches. Wrapping them now would mean guessing at a storage format nobody has asked for yet. When a real column needs one, follow the `Select`/`RadioGroup` pattern for array-valued fields (override `componentProps()` to supply `options`/`selected`, override `resolveRules()`/`mutateValue()` to match the real column type) rather than adding it speculatively.

The broader universe of enterprise form patterns (dynamic repeaters, nested/tree components, dependent dropdowns, workflow/approval chains, audit trail + versioning, JSON/matrix/key-value editors, import/export, digital signatures, maps, scheduling, AI-assisted fields, dynamic form builder, etc.) is **not implemented** and not planned — this app manages small curated reference-data tables (a few dozen rows each, "every table here is small curated reference data" per `Resource::paginate()`'s doc comment), not ERP/insurance/banking workflows. Don't build toward that shape speculatively; see the verification note below for the full category-by-category rationale.

### Non-CRUD backend pieces

- **`CardApplicationController`** (public, `App\Http\Controllers`) — validates and stores the public "Apply for a Card" form (`card_applications` table) using `App\Enums\{Gender,CardType,CardDeliveryMethod}` (native PHP backed enums, each with a `label()`), conditionally nulling `delivery_address`/`agent_id` based on `delivery_method`. Redirects back to `home#apply` with either validation errors or a status message (no dedicated confirmation page).
- **`Admin\CardApplicationController`** — read-only `index`/`show` over the same table, not a `CrudController` subclass (nothing to create/edit/delete here).
- **`Admin\SettingController`** — hand-written `edit`/`update` over `Setting::current()` (a singleton row, auto-created on first access) — app store URLs, contact/address info, social links. Not a `CrudController` subclass since there's no list/create/delete for a singleton.

### Layout composition

- **Frontend**: `resources/views/layouts/app.blade.php` (single shared `@component` shell) provides `<head>`/meta/OG tags, the pre-paint dark-mode + accessibility-settings script, page loader, scroll-progress bar, header/footer includes, back-to-top button, shared toast markup, and (currently commented out) floating WhatsApp button and accessibility toolbar. Pages pass `title`/`metaDescription` props. The home page (`resources/views/frontend/home.blade.php`) is a flat list of `@include('frontend.partials.home.*')` sections — reorder the page by reordering that list. Several sections (`whats-new-alert`, `promo-popup`, `journey`, `fare-calculator`, `photo-band`, `partners`, `testimonials`) are built but currently commented out of the live page — check there before assuming a section doesn't exist. Non-home pages live in `resources/views/frontend/pages/*.blade.php` and generally use `resources/views/partials/page-hero.blade.php` for their banner.
- **Admin**: `resources/views/layouts/admin.blade.php` (rendered via the `<x-admin-layout>` component, `App\View\Components\AdminLayout`) + `layouts/admin-sidebar.blade.php` (collapsible, state persisted to `localStorage` pre-paint to avoid a flash) + `layouts/admin-topbar.blade.php`. Separate Vite entry (`resources/js/admin.js` + `resources/css/{admin,admin-forms}.css`) loaded alongside the shared `app.js`/`app.css` — pulls in jQuery + DataTables (`[data-datatable]` tables get client-side search/sort/paging and a sticky header row pinned under the topbar) and SweetAlert2-based `adminNotify`/`adminConfirm` helpers (`resources/js/admin-alerts.js`, exposed on `window`).

### Styling

Tailwind CSS only, no inline styles, no Bootstrap. `tailwind.config.js` defines the brand tokens (`primary` `#0F4C81`, `secondary` `#14B8A6` — plus `secondary-a11y`/`accent-a11y` darker AA/AAA-safe reads for small text, since the base `secondary`/`accent` fail WCAG contrast on white — `accent` `#F59E0B`, `ink`/`surface`/`panel` for dark mode, `font-heading`/`font-body`, custom animation keyframes prefixed `yc-*`). Dark mode is Tailwind's `class` strategy, toggled by adding/removing `.dark` on `<html>`, persisted in `localStorage` under `yc-theme`, applied pre-paint via an inline `<head>` script to avoid a flash. `resources/css/app.css` holds global base styles, Swiper/Font Awesome imports, and hand-written CSS for effects Tailwind utilities can't express directly — organized into `/* ===== NAME ===== */` comment blocks per section.

### JavaScript

Built with Vite (`vite.config.js` — three entry points: `resources/css/app.css`, `resources/js/app.js`, `resources/js/admin.js`). Alpine.js (with `@alpinejs/collapse` and `@alpinejs/focus` plugins) for lightweight interactivity, GSAP + ScrollTrigger for heavier scroll-driven animation. `resources/js/app.js` is the frontend entry point; each imported module is self-contained and no-ops if its target DOM isn't present, so `app.js` can unconditionally call every `init*()` on every page:

- `journey.js` — the home page's pinned scrollytelling sequence (currently not included on the live home page — see Layout composition above). Falls back to a plain stacked layout below 1024px or under reduced-motion.
- `hero-journey-bridge.js` — FLIPs a fixed-position clone of the hero card across the scroll gap so it visually reads as the same card carrying into the journey section. Only active when `journey.js` is in its pinned/cinematic mode.
- `apple-card.js` — Apple Wallet–style card tilt/parallax (mouse-driven on desktop, device-orientation or ambient auto-rotation on mobile).
- `tilt-card.js` — plain hover-tilt for other card grids (e.g. Card Types), independent of `apple-card.js`.
- `how-it-works.js` — pinned 5-step scrub timeline for the "How It Works" section.
- `product-reveal.js` — one-shot keynote-style reveal, triggered by `IntersectionObserver`; motion is CSS keyframes, this just toggles classes.
- `lottie-icons.js` — progressive enhancement: elements with `data-lottie="<path>.json"` get a Font Awesome icon by default, swap to Lottie only if that JSON actually exists (checked via HEAD request).
- `accessibility.js` — drives the accessibility toolbar/settings (text size/weight/font/alignment, letter/word/line/paragraph spacing, contrast modes, color filters, highlight toggles, motion/animation toggles, reading mode, keyboard-nav improvements) persisted to `localStorage` under `yc-a11y-settings-v2` and applied pre-paint in `layouts/app.blade.php`'s inline script.
- `form-validation.js` — exposes `window.validateForm`/`setSubmitLoading` for forms with a custom Alpine `@submit.prevent` instead of the `data-toast-form` convention.
- `scrollspy.js` — highlights the active header nav link based on scroll position.
- `toast.js` — exposes `window.showToast(title, message, icon)`, auto-wires any `[data-toast-title]` element.
- `forms.js` — central `Alpine.data()` registration point for every `resources/views/components/forms/*` component's JS factory (see Field type catalog above); each factory lives in its own file under `resources/js/forms/`. Must be registered before `Alpine.start()` in `app.js` — registering only from `admin.js` (which loads after) would miss components already in the initial DOM.
- Scroll-reveal (`.reveal`/`.reveal-left`/`.reveal-right`/`.reveal-scale` → `.is-visible`), animated stat counters (`.counter[data-count]`), and Swiper slider init (testimonials, partner logos, app screens, promo popup — each with a manual pause/play toggle button for WCAG 2.2.2 compliance) are inlined directly in `app.js`.

`resources/js/admin.js` is the separate admin entry: jQuery + DataTables init, SweetAlert2 alert helpers. It imports `admin.css`/`admin-forms.css` directly (not via the `app.css` entry) since these only apply under `/admin`.

**Built assets**: `public/build/` (JS/CSS bundles + `manifest.json`) is committed and regenerated by `npm run build` — don't hand-edit files there.

**Icons**: Font Awesome (imported via `resources/css/app.css`), used as `<i class="fa-solid fa-*">`/`fa-brands`. The full solid icon-name list backing `Statistic::iconNames()` and the icon-picker component lives in `public/data/fontawesome-solid-icons.json` — regenerate from `node_modules/@fortawesome/fontawesome-free/metadata/icon-families.json` if the `fontawesome-free` version bumps.
