# Real Estate Module — Deal Explorer

> The `feat/deal-explorer` feature introduces a production-grade, AJAX-powered Real Estate browsing module built on top of the base admin panel. It enables users to browse, filter, sort, favourite, and deeply inspect property listings — all without a single full-page reload.

---

## Table of Contents

1. [Module Overview](#1-module-overview)
2. [What You Inherit](#2-what-you-inherit)
3. [Deep Dive: Features & Functionality](#3-deep-dive-features--functionality)
4. [Database Architecture](#4-database-architecture)
5. [File Paths & Architecture](#5-file-paths--architecture)
6. [API Endpoints Reference](#6-api-endpoints-reference)
7. [Step-by-Step Usage Guide](#7-step-by-step-usage-guide)
8. [Precautions & Future Development Rules](#8-precautions--future-development-rules)

---

## 1. Module Overview

The **Deal Explorer** is a full-stack real estate browsing engine. It renders a responsive property card grid that is populated entirely via AJAX — the Blade page shell loads once, and all subsequent data fetches (filters, pagination, sorting, tab switching) are handled server-side, returning rendered HTML partials. Opening a property card fires a second AJAX call that injects the full property detail view into a modal overlay — keeping the initial page payload small.

The module follows the **Service → Controller → FormRequest** pattern, with zero business logic in the controller and all query building delegated to `DealExplorerService`.

---

## 2. What You Inherit

By merging with the **Base** branch, this feature inherits:

- **Alpine.js:** The entire Deal Explorer UI state — loading flags, filters, pagination, modal state, carousel index, favourites — is managed by a single `dealExplorer()` Alpine data component.
- **Blade Component System:** All Deal Explorer UI is split into focused, reusable `<x-deal-explorer.*>` Blade components, consistent with the base branch's component conventions.
- **Tailwind CSS Build Pipeline:** Cards, modals, and the filter drawer all use Tailwind utility classes compiled through the existing `vite.config.js`.
- **Admin Layout:** The Deal Explorer page extends `layouts.admin.app` and uses `<x-layouts.admin.page-container>` for consistent spacing, breadcrumbs, and page title.

---

## 3. Deep Dive: Features & Functionality

### AJAX-Driven Property Grid
The page shell (`pages/admin/deal-explorer/index.blade.php`) is a static Blade template. On mount, Alpine calls the `list` endpoint and injects the returned `cards` HTML into `#de-cards-container` and `pagination` HTML into `#de-pagination`. Every subsequent filter/sort/page change repeats this cycle — no full page reloads.

### Three-Tab Navigation
The explorer exposes three tabs driven by the `tab` query parameter:
- **All** — The full property catalogue, newest first by default.
- **Recent Viewed** — Properties the authenticated user has opened, ordered by most recently viewed. Implemented via a `JOIN` on `user_property_interactions`.
- **Favourites** — Properties the user has starred, ordered by date favourited. Implemented via a `JOIN` on `favourite_properties`.

### Rich Filter Panel (Slide-over Drawer)
Clicking "Filters" slides open a full-height drawer from the end of the screen. It holds:
- Property Type (driven by `FilterConstants`)
- Availability (For Sale / For Rent / Both)
- Sale Status & Rent Status (conditionally shown based on Availability)
- Sale Price Range (Min / Max)
- Rent Price Range (Min / Max)
- Area SQM Range (Min / Max)
- Location (State → City cascade, populated via AJAX from the database)
- Postal Code (Pincode)

Filters use a **two-stage commit** pattern — changes go into `tempFilters` in Alpine state and are only committed to `filters` (triggering a data fetch) when the user clicks **Apply**. **Reset** clears both stages simultaneously.

### Active Filter Chips
Once filters are applied, each active filter renders as a dismissible chip above the card grid. Each chip has an `×` button that removes only that specific filter and immediately re-fetches data. A **Clear All** button removes all filters at once.

### Property Card Grid
Each property card (`<x-deal-explorer.property-card-grid>`) displays:
- Primary property image with lazy loading and a hover zoom effect
- Operation badges (For Sale / For Rent) in the top-left corner
- Favourite (heart) button in the top-right corner
- Property title, address, and a stats grid (Beds / Baths / SQM)
- Property type badge, Sale Status badge, Rent Status badge (conditional)
- A price card showing Sale Price, Rent Price (with period suffix M/Q/Y), and Fair Market Rent

Clicking anywhere on the card (except the favourite button) opens the detail modal.

### Skeleton Loading State
On first load (before any data arrives), 8 `<x-deal-explorer.skeleton-card>` components render with a pulse animation — giving the user immediate visual feedback that content is loading.

### Lazy-Loaded Property Detail Modal
The detail modal shell (`<x-deal-explorer.property-detail-modal>`) is always present in the DOM but hidden. When a card is clicked:
1. Alpine calls `trackView` (records the view interaction server-side).
2. Alpine calls `show` (fetches the full detail partial via AJAX).
3. The returned HTML is injected into `#de-modal-body` via `x-html`.

The modal contains:
- **Image Carousel** — Alpine-powered, with Previous/Next controls and a slide counter. Images load lazily beyond the first.
- **Key Stats** — Bedrooms, Bathrooms, Area in a 3-column grid.
- **Financial Table** — List Price, Rent Price, Fair Market Rent, Purchase Price, Property Taxes, Insurance Cost, Total Operating Expense.
- **Property Details Table** — Type, Condition, Year Built, Floor, Area SQM, Living Area, Energy Rating.
- **Amenities** — Icon-matched grid using a `$getIcon()` Blade closure that maps keyword patterns to inline SVGs.
- **Features** — Pill-badge list.
- **Description** — Full property description card.

### Favourites System
- **Add:** `POST /deal-explorer/favorite` → `FavouriteProperty::firstOrCreate()`
- **Remove:** `POST /deal-explorer/unfavorite` → hard-delete from `favourite_properties`
- **UI sync:** Both the card heart button and the modal heart button update immediately via Alpine state (`isFavourited`, `de-fav-active` CSS class) — no page reload needed.

### View Tracking
Every time a property modal is opened by an authenticated user, a record is upserted in `user_property_interactions` via `UserPropertyInteraction::updateOrCreate()`. This powers the **Recent Viewed** tab. The `TYPE_VIEW` constant on the model (`'view'`) keeps the interaction type consistent without magic strings.

### Sorting
The toolbar exposes a **Sort By** dropdown and a direction toggle (Asc/Desc). Allowed sort columns are whitelisted server-side in `DealExplorerService::SORT_MAP` — any other value falls back to `created_at`. The whitelist approach prevents SQL injection through the sort parameter.

### Numeric Filter Sanitization
All numeric filter inputs (price ranges, area ranges) are double-sanitized:
1. **Frontend** — Alpine's `sanitizeNumberInput()` and `isNumberKey()` helpers prevent non-numeric input in real time.
2. **Backend** — `DealExplorerController@list` explicitly clamps all numeric values to `[0, 100_000_000]` after validation.

### Location Cascade (State → City)
The filter panel loads states via `GET /deal-explorer/states` on panel open (`openFilterPanel()`). When the user selects a state, Alpine calls `GET /deal-explorer/cities?state_id={id}` to populate the City dropdown. Both endpoints return clean JSON arrays from the `states` and `cities` database tables.

### N+1 Prevention
`DealExplorerService::getListing()` uses targeted eager loading:
- `primaryImage` — a `hasOne` scoped to `is_primary = true`, loaded with a `select` to only fetch needed columns.
- `favourites` — scoped to the authenticated `$userId` so each card knows its favourited state without an extra query per card.

The detail fetch uses `with(['propertyDetail', 'propertyImages'])` so images and details load in 2 queries total regardless of the number of images.

### Config-Driven Filter Options
`App\Constants\FilterConstants` centralises all allowed property types, sale statuses, and rent statuses as static arrays. The filter panel Blade component calls these directly — no hardcoded option lists in views.

---

## 4. Database Architecture

### Entity Relationship

```
countries
    └── states
            └── cities

properties_data (primary property record, soft-deletes)
    ├── property_details (1:1 — extended info)
    ├── property_images  (1:many — with is_primary flag)
    ├── favourite_properties (many — user favourites)
    └── user_property_interactions (many — view/buy/etc. tracking)
```

### Table Schemas

#### `properties_data`
| Column | Type | Notes |
|---|---|---|
| `id` | bigint | PK |
| `internal_property_id` | string | **Unique business key** — used as FK everywhere |
| `property_id` | string | External source ID |
| `listing_id` | string | External listing ID |
| `property_title` | string | |
| `full_address` | string | |
| `city`, `state`, `country`, `postal_code` | string | Indexed |
| `latitude`, `longitude` | decimal(12,8) | |
| `property_type` | string | Indexed |
| `property_sale_status` | string | Indexed |
| `property_rent_status` | string | Indexed |
| `bedrooms` | unsigned int | |
| `bathrooms` | decimal(5,2) | |
| `total_area` | decimal(12,2) | |
| `list_price` | decimal(15,2) | Indexed |
| `rent_price` | decimal(15,2) | Cast to float. Indexed |
| `rent_period` | string | `monthly`, `yearly`, `quarterly` |
| `operation` | string(100) | e.g. `sale`, `rent`, `sale and rent` |
| `fair_market_rent` | decimal(15,2) | |
| `list_date` | date | Cast to `date` |
| `last_updated_date` | timestamp | |
| `deleted_at` | timestamp | Soft delete |

#### `property_details`
| Column | Type | Notes |
|---|---|---|
| `internal_property_id` | string | FK → `properties_data`, cascade delete |
| `total_rooms`, `floor` | unsigned int | |
| `living_area`, `plot_area` | decimal(12,2) | |
| `year_built` | year | |
| `property_condition` | string | |
| `purchase_price`, `property_taxes`, `insurance_cost`, `total_operating_expense` | decimal(15,2) | |
| `energy_rating`, `energy_certificate` | string | |
| `is_private_property`, `is_auction_property`, `is_bank_property` | boolean | Cast |
| `description` | longText | |
| `amenities`, `features`, `source_agents`, `meta_data` | json | Cast to array |

#### `property_images`
| Column | Type | Notes |
|---|---|---|
| `internal_property_id` | string | FK → `properties_data`, cascade delete |
| `image_url` | string(1000) | |
| `is_primary` | boolean | Indexed |

#### `favourite_properties`
| Column | Type | Notes |
|---|---|---|
| `user_id` | foreignId | FK → `users`, cascade delete |
| `internal_property_id` | string | FK → `properties_data`, cascade delete |

#### `user_property_interactions`
| Column | Type | Notes |
|---|---|---|
| `user_id` | foreignId | FK → `users`, cascade delete |
| `internal_property_id` | string | FK → `properties_data`, cascade delete |
| `interaction_type` | string | `view`, `favourite`, `added`, `buy` |
| Unique constraint | — | `(user_id, internal_property_id, interaction_type)` — one record per combination |

---

## 5. File Paths & Architecture

### Models
| File | Description |
|---|---|
| `app/Models/PropertyData.php` | Primary listing model. Uses `SoftDeletes`. Has `propertyDetail`, `propertyImages`, `primaryImage`, `favourites`, `interactions` relationships. |
| `app/Models/PropertyDetail.php` | Extended property info (1:1 with `PropertyData`). JSON fields auto-cast to arrays. |
| `app/Models/PropertyImage.php` | Property images. `is_primary` boolean cast. |
| `app/Models/FavouriteProperty.php` | User favourite join record. |
| `app/Models/UserPropertyInteraction.php` | View/click tracking. Holds `TYPE_*` constants. |
| `app/Models/State.php` | Location hierarchy — State |
| `app/Models/City.php` | Location hierarchy — City (belongs to State) |
| `app/Models/Country.php` | Location hierarchy — Country |

### Service & Constants
| File | Description |
|---|---|
| `app/Services/DealExplorerService.php` | All query logic. `getListing()`, `getPropertyDetails()`, `trackPropertyView()`, `favouriteProperty()`, `unfavouriteProperty()`. |
| `app/Constants/FilterConstants.php` | Static arrays of allowed property types, sale statuses, and rent statuses. Single source of truth for filter options. |

### Controller & Requests
| File | Description |
|---|---|
| `app/Http/Controllers/DealExplorer/DealExplorerController.php` | Thin controller. Resolves user ID, delegates to service, returns JSON with rendered HTML. |
| `app/Http/Requests/DealExplorer/DealExplorerListRequest.php` | Validates all 16 filter/sort parameters. |
| `app/Http/Requests/DealExplorer/PropertyDetailRequest.php` | Validates `internal_property_id` for modal fetch. |
| `app/Http/Requests/DealExplorer/FavoritePropertyRequest.php` | Validates `internal_property_id` for fav/unfav. |
| `app/Http/Requests/DealExplorer/TrackPropertyViewRequest.php` | Validates `internal_property_id` for view tracking. |

### Blade Page
| File | Description |
|---|---|
| `resources/views/pages/admin/deal-explorer/index.blade.php` | Main page shell. Declares all AJAX URLs as PHP variables and passes them to the `dealExplorer()` Alpine component via `@js()`. |

### Blade Components (`resources/views/components/deal-explorer/`)
| Component | Tag | Description |
|---|---|---|
| `toolbar.blade.php` | `<x-deal-explorer.toolbar>` | Search input, Sort By select, Direction select, Filter Panel button. |
| `filter-panel.blade.php` | `<x-deal-explorer.filter-panel>` | Full slide-over drawer with all filter inputs. |
| `property-card-grid.blade.php` | `<x-deal-explorer.property-card-grid>` | Single property card. Props: `$property`, `$userId`. |
| `property-detail-modal.blade.php` | `<x-deal-explorer.property-detail-modal>` | Modal shell. Content injected via `x-html`. |
| `skeleton-card.blade.php` | `<x-deal-explorer.skeleton-card>` | Animated pulse placeholder card shown during initial load. |
| `empty-state.blade.php` | `<x-deal-explorer.empty-state>` | "No properties found" illustration shown when results are empty. |

### AJAX Partials (`resources/views/partials/deal-explorer/`)
| File | Description |
|---|---|
| `cards-grid.blade.php` | Renders the responsive grid of `<x-deal-explorer.property-card-grid>` cards (or the empty state). Returned as HTML string by the `list` endpoint. |
| `pagination.blade.php` | Renders Laravel's pagination links. Returned as HTML string by the `list` endpoint. |
| `modal-content.blade.php` | Full property detail layout with carousel, stats, financial table, amenities, features, and description. Returned as HTML string by the `show` endpoint. |

### Migrations (`database/migrations/`)
| File | Table Created |
|---|---|
| `2026_06_10_102010_create_countries_table.php` | `countries` |
| `2026_06_10_102020_create_states_table.php` | `states` |
| `2026_06_10_102030_create_cities_table.php` | `cities` |
| `2026_06_10_102040_create_property_data_table.php` | `properties_data` |
| `2026_06_10_102050_create_property_details_table.php` | `property_details` |
| `2026_06_10_102060_create_property_images_table.php` | `property_images` |
| `2026_06_10_110928_create_favourite_properties_table.php` | `favourite_properties` |
| `2026_06_10_123203_create_user_property_interactions_table.php` | `user_property_interactions` |

---

## 6. API Endpoints Reference

All routes are prefixed under the authenticated admin guard. They are registered inside the `auth:admin` middleware group in `routes/web.php`.

| Method | URI | Name | Controller Method | Description |
|---|---|---|---|---|
| `GET` | `/admin/deal-explorer` | `admin.deal-explorer.index` | `adminIndex` | Render the page shell (Blade view) |
| `GET` | `/admin/deal-explorer/states` | `admin.deal-explorer.states` | `states` | Return all states as JSON |
| `GET` | `/admin/deal-explorer/cities` | `admin.deal-explorer.cities` | `cities` | Return cities for a `state_id` as JSON |
| `GET` | `/admin/deal-explorer/list` | `admin.deal-explorer.list` | `list` | Return paginated card HTML + pagination HTML + totals |
| `GET` | `/admin/deal-explorer/show` | `admin.deal-explorer.show` | `show` | Return full property detail modal HTML |
| `POST` | `/admin/deal-explorer/view` | `admin.deal-explorer.view` | `trackView` | Record a property view interaction |
| `POST` | `/admin/deal-explorer/favorite` | `admin.deal-explorer.favorite` | `favorite` | Add property to favourites |
| `POST` | `/admin/deal-explorer/unfavorite` | `admin.deal-explorer.unfavorite` | `unfavorite` | Remove property from favourites |

### `GET /list` — Query Parameters

| Parameter | Validation | Description |
|---|---|---|
| `tab` | `in:all,recent,favourites` | Active tab |
| `search` | `string\|max:255` | Free-text search across title, address, city, state, postal code |
| `sort` | `in:created_at,list_price,rent_price,fair_market_rent,total_area` | Sort column |
| `direction` | `in:asc,desc` | Sort direction |
| `per_page` | `in:10,12,20,50,100` | Results per page |
| `page` | `integer\|min:1` | Page number |
| `availability` | `in:sale,rent,both` | Operation type filter |
| `property_type` | `string\|max:100` | Property type filter |
| `property_sale_status` | `string\|max:100` | Sale status filter |
| `property_rent_status` | `string\|max:100` | Rent status filter |
| `city` | `string\|max:100` | City name filter |
| `state` | `string\|max:100` | State name filter |
| `postal_code` | `string\|size:6` | 6-digit pincode filter |
| `min_area` / `max_area` | `numeric\|min:0\|max:100000000` | Area range filter |
| `min_sale_price` / `max_sale_price` | `numeric\|min:0\|max:100000000` | Sale price range filter |
| `min_rent_price` / `max_rent_price` | `numeric\|min:0\|max:100000000` | Rent price range filter |

### `GET /list` — JSON Response Shape

```json
{
  "success": true,
  "cards": "<div class=\"grid ...\">...</div>",
  "pagination": "<nav>...</nav>",
  "total": 142,
  "from": 1,
  "to": 10
}
```

### `GET /show` — JSON Response Shape

```json
{
  "success": true,
  "is_favourited": false,
  "city": "Mumbai",
  "state": "Maharashtra",
  "html": "<div class=\"p-6 ...\">...</div>"
}
```

---

## 7. Step-by-Step Usage Guide

### Step 1: Run Migrations

After pulling this branch, run all migrations to create the 8 new tables:

```bash
php artisan migrate
```

Or to start fresh:

```bash
php artisan migrate:fresh --seed
```

### Step 2: Seed Property Data

There is no default property seeder — the module is designed to ingest real data. To populate the database, insert records into `properties_data`, `property_details`, and `property_images` via:

- A database seeder (`database/seeders/PropertyDataSeeder.php`)
- A data import script/command
- Direct SQL import from an external source

The **`internal_property_id`** column is the business key that links all tables. Every record in `property_details`, `property_images`, `favourite_properties`, and `user_property_interactions` must reference a valid `internal_property_id` from `properties_data`.

### Step 3: Access the Deal Explorer

Log in as an admin and navigate to:

```
/admin/deal-explorer
```

The page loads the empty shell, then immediately fetches and renders the first page of properties.

### Step 4: Using Translations in Blade Views (if Localization branch is merged)

```blade
{{ __('pages/deal-explorer.title') }}
{{ __('buttons.apply') }}
```

### Step 5: Adding a New Filter

**a) Add the database column and migration** if the filter targets a new field.

**b) Add the validation rule** in `DealExplorerListRequest::rules()`:
```php
'my_new_filter' => 'nullable|string|max:100',
```

**c) Add the query clause** in `DealExplorerService::getListing()`:
```php
if (!empty($filters['my_new_filter'])) {
    $query->where('properties_data.my_new_filter', $filters['my_new_filter']);
}
```

**d) Add the UI control** in `filter-panel.blade.php`.

**e) Add the active chip** in `pages/admin/deal-explorer/index.blade.php`.

### Step 6: Adding a New Sort Option

**a) Add the column** to `DealExplorerService::SORT_MAP`:
```php
private const SORT_MAP = [
    'my_column' => 'properties_data.my_column',
    // ...existing entries
];
```

**b) Add the `<option>`** to the Sort By dropdown in `toolbar.blade.php`.

**c) Add the validation value** in `DealExplorerListRequest`:
```php
'sort' => 'nullable|string|in:created_at,list_price,...,my_column',
```

### Step 7: Adding a New Interaction Type

The `UserPropertyInteraction` model defines extensible interaction types as constants:

```php
public const TYPE_VIEW      = 'view';
public const TYPE_FAVOURITE = 'favourite';
public const TYPE_ADDED     = 'added';
public const TYPE_BUY       = 'buy';
```

To add a new type (e.g., `enquiry`):
1. Add `public const TYPE_ENQUIRY = 'enquiry';` to the model.
2. Create a `TrackPropertyEnquiryRequest` form request.
3. Add a new method in `DealExplorerService`.
4. Add a new controller method and route.

---

## 8. Precautions & Future Development Rules

### A. Never Bypass the Service Layer

All query logic **must** live in `DealExplorerService`. Controllers must never build Eloquent queries directly.

- ✅ `$this->service->getListing($validated, $userId)`
- ❌ `PropertyData::where('city', $city)->paginate(10)` inside a controller

### B. Always Use `internal_property_id` as the Business Key

The `id` (auto-increment PK) of `properties_data` is **never** exposed to the frontend. All AJAX calls use `internal_property_id`. This is intentional — it allows data to be re-imported without breaking foreign key relationships.

- ✅ `$request->input('internal_property_id')`
- ❌ `$request->input('property_id')` for detail/fav/view operations

### C. Keep Sort Columns Whitelisted

The `SORT_MAP` constant in `DealExplorerService` is a security boundary. **Never** pass a raw user-supplied column name to `orderBy()`. Always resolve through the map:

```php
$sortCol = self::SORT_MAP[$filters['sort'] ?? 'created_at'] ?? 'properties_data.created_at';
```

### D. Maintain the Two-Stage Filter Commit Pattern

Filters in the drawer must write to `tempFilters`, not `filters`. Only the **Apply** button should copy `tempFilters` → `filters` and trigger a fetch. This prevents a fetch on every keypress inside the drawer.

### E. Sanitize Numeric Inputs on Both Sides

Any new numeric filter **must** be sanitized in both places:
- **Frontend:** Use the existing `sanitizeNumberInput()` Alpine helper on `@input`.
- **Backend:** Add the key to the `$numericKeys` array in `DealExplorerController@list`.

### F. Eager-Load to Prevent N+1

When adding new relationships to the listing query, always add them to the `with()` call in `DealExplorerService::getListing()`. Scoped eager loads (e.g., `select` columns, `where` clauses) should be used to keep the payload lean.

```php
$query->with([
    'primaryImage:id,internal_property_id,image_url,is_primary',
    'myNewRelation' => fn($q) => $q->select('id', 'internal_property_id', 'my_col')
                                    ->where('active', true),
]);
```

### G. Filter Constants Are the Single Source of Truth

**Never** hardcode property types, sale statuses, or rent statuses in Blade views. Always use:

```php
\App\Constants\FilterConstants::getPropertyTypes()
\App\Constants\FilterConstants::getSaleStatuses()
\App\Constants\FilterConstants::getRentStatuses()
```

To add a new option, edit only `FilterConstants.php`.

### H. The Modal Content Partial is Server-Rendered

`partials/deal-explorer/modal-content.blade.php` is rendered on the server and returned as a raw HTML string. It has **no access** to Alpine state. Do not use `x-data`, `x-show`, or Alpine directives inside this partial — it will not work since Alpine does not re-initialize on `x-html` injection for nested components by default.

Exception: `x-bind:class` and `:class` with pre-evaluated PHP values are safe.
