# Palace Properties Developer Documentation

Complete reference for theme and plugin developers building custom property listing experiences with the Palace Properties WordPress plugin.

---

## Table of Contents

1. [Template Override Hierarchy](#template-override-hierarchy)
2. [PHP API Reference](#php-api-reference)
3. [Hooks & Filters Reference](#hooks--filters-reference)
4. [REST API Reference](#rest-api-reference)
5. [Shortcodes Reference](#shortcodes-reference)
6. [Creating a Custom Template](#creating-a-custom-template)
7. [Building a Headless Frontend](#building-a-headless-frontend)
8. [Extending Property Data](#extending-property-data)
9. [CSS Architecture](#css-architecture)

---

## Template Override Hierarchy

The plugin searches for template files in this order:

1. **Theme directory**: `your-theme/palace-properties/card.php` or `single.php`
2. **Filter override**: Use `ipp_card_template` or `ipp_single_template` filters
3. **Active template**: `plugin/public/templates/{active-template}/card.php`
4. **Fallback**: Plugin default templates

### Example: Override from your theme

Create `your-theme/palace-properties/card.php`:

```php
<?php
// This file automatically overrides the plugin's card template
$prop = new IPP_Property_Helper();
?>
<div class="my-custom-card">
    <h3><?php echo esc_html( get_the_title() ); ?></h3>
    <p><?php echo esc_html( $prop->formatted_rent() ); ?></p>
    <p><?php echo esc_html( $prop->full_address() ); ?></p>
</div>
```

### Example: Override via filter

```php
add_filter( 'ipp_card_template', function( $path, $slug ) {
    return get_stylesheet_directory() . '/my-templates/property-card.php';
}, 10, 2 );

add_filter( 'ipp_single_template', function( $path, $slug ) {
    return get_stylesheet_directory() . '/my-templates/property-single.php';
}, 10, 2 );
```

---

## PHP API Reference

All functions are available globally after `plugins_loaded`. Wrap calls in your theme's `functions.php` or a custom plugin.

### ipp_get_property( $post_id = null )

Get a single property as an `IPP_Property_Helper` instance.

```php
$prop = ipp_get_property( 123 );
echo $prop->formatted_rent();      // "$680 / week"
echo $prop->bedrooms();            // "3"
echo $prop->full_address();        // "123 Main St, Auckland"
echo $prop->agent_name();          // "John Smith"
```

**Returns**: `IPP_Property_Helper|null`

### ipp_get_properties( $args = [] )

Get multiple properties with filtering.

```php
$properties = ipp_get_properties([
    'limit'        => 6,
    'status'       => 'active',
    'type'         => 'house',
    'region'       => 'auckland',
    'bedrooms_min' => 2,
    'price_max'    => 800,
    'sort_by'      => 'price_asc',
]);

foreach ( $properties as $prop ) {
    echo $prop->formatted_rent() . ' - ' . $prop->full_address();
}
```

**Supported $args keys**:

| Key | Type | Description |
|-----|------|-------------|
| `limit` | int | Number of results (default 10) |
| `offset` | int | Skip N results |
| `status` | string | Taxonomy slug (e.g., "active") |
| `type` | string | Taxonomy slug (e.g., "house") |
| `region` | string | Taxonomy slug (e.g., "auckland") |
| `bedrooms_min` | int | Minimum bedrooms |
| `bedrooms_max` | int | Maximum bedrooms |
| `bathrooms_min` | int | Minimum bathrooms |
| `bathrooms_max` | int | Maximum bathrooms |
| `price_min` | float | Minimum rent amount |
| `price_max` | float | Maximum rent amount |
| `sort_by` | string | "date", "price_asc", "price_desc", "bedrooms", "title" |
| `order` | string | "ASC" or "DESC" |
| `exclude_hidden` | bool | Exclude archived (default true) |
| `search` | string | Text search |

**Returns**: `IPP_Property_Helper[]`

### ipp_get_field( $post_id, $field )

Get a property meta field value directly.

```php
echo ipp_get_field( 123, 'rent' );          // "680"
echo ipp_get_field( 123, 'bedrooms' );      // "3"
echo ipp_get_field( 123, 'property_code' ); // "RBPR001441"
```

**Returns**: `string`

### ipp_is_field_visible( $field_key )

Check if a display field is enabled in plugin settings.

```php
if ( ipp_is_field_visible( 'pets_allowed' ) ) {
    echo 'Pets: ' . ( $prop->pets_allowed() ? 'Yes' : 'No' );
}
```

**Returns**: `bool`

### ipp_get_display_fields()

Get all display-enabled field keys.

```php
$fields = ipp_get_display_fields();
// ['rent', 'rental_period', 'date_available', 'address', ...]
```

**Returns**: `array`

### ipp_get_template()

Get the active template slug.

```php
$slug = ipp_get_template(); // "palace-property", "modern", "minimal", etc.
```

**Returns**: `string`

### ipp_render_card( $post_id )

Render a property card using the active template. Returns HTML string.

```php
echo ipp_render_card( 123 );
```

**Returns**: `string` (HTML)

### ipp_render_gallery( $post_id, $size = 'large' )

Render a property's gallery HTML.

```php
echo ipp_render_gallery( 123, 'medium' );
```

**Returns**: `string` (HTML)

### ipp_property_to_array( $post_id )

Get property as an associative array (ideal for JSON/API use).

```php
$data = ipp_property_to_array( 123 );
echo $data['financial']['formatted_rent'];  // "$680 / week"
echo $data['features']['bedrooms'];         // 3
echo $data['address']['city'];              // "Auckland"
echo $data['agent']['name'];                // "John Smith"
```

**Returns**: `array|null` — Full structure documented in the REST API section.

### ipp_query_properties( $args = [] )

Query properties, returns a `WP_Query` object for full control.

```php
$query = ipp_query_properties([
    'limit'   => 12,
    'sort_by' => 'price_desc',
]);

if ( $query->have_posts() ) {
    while ( $query->have_posts() ) {
        $query->the_post();
        // Your custom loop
    }
    wp_reset_postdata();
}
```

**Returns**: `WP_Query`

### IPP_Property_Helper Methods

When you have a helper instance, these methods are available:

| Method | Returns | Description |
|--------|---------|-------------|
| `property_code()` | string | Palace property code |
| `status()` | string | Property status |
| `name()` | string | Property name |
| `address1()` | string | Street number |
| `address2()` | string | Street name |
| `address3()` | string | City |
| `address4()` | string | Address line 4 |
| `unit()` | string | Unit number |
| `postcode()` | string | Postcode |
| `suburb()` | string | Suburb |
| `region()` | string | Region |
| `full_address()` | string | Formatted full address |
| `short_address()` | string | Short address |
| `rent()` | string | Raw rent amount |
| `rental_period()` | string | Rental period (Week, Month, etc.) |
| `formatted_rent()` | string | "$680 / week" |
| `market_value()` | string | Market value |
| `date_available()` | string | Raw date available |
| `formatted_date_available()` | string | Formatted or "Available Now" |
| `bedrooms()` | string | Bedroom count |
| `bathrooms()` | string | Bathroom count |
| `ensuites()` | string | Ensuite count |
| `cars()` | string | Car space count |
| `parking()` | string | Parking type |
| `property_class()` | string | Property class (House, Unit, etc.) |
| `year_built()` | string | Year built |
| `stories()` | string | Number of stories |
| `furnishings()` | string | Furnishings |
| `floor_area()` | string | Floor area |
| `land_area_sqm()` | string | Land area in sqm |
| `land_area_hectares()` | string | Land area in hectares |
| `pets_allowed()` | bool | Pets allowed |
| `smokers_allowed()` | bool | Smokers allowed |
| `virtual_tour_url()` | string | Virtual tour URL |
| `web_link_url()` | string | Web link URL |
| `geographic_location()` | string | "lat:lng" |
| `has_map_coords()` | bool | Has map coordinates |
| `map_lat()` | float | Latitude |
| `map_lng()` | float | Longitude |
| `map_embed_url()` | string | Google Maps embed URL |
| `agent_code()` | string | Agent code |
| `agent_name()` | string | Agent full name |
| `agent_title()` | string | Agent title |
| `agent_email()` | string | Agent email |
| `agent_phone_mobile()` | string | Agent mobile |
| `agent_phone_work()` | string | Agent work phone |
| `gallery()` | array | Gallery attachment IDs |
| `gallery_images($size)` | array | Gallery images with URLs |
| `featured_image($size)` | string | Featured image URL |
| `custom_fields()` | array | Custom field key/value pairs |
| `schema_json_ld()` | string | Schema.org JSON-LD script tag |
| `show($field_key)` | bool | Check if field is display-enabled |
| `meta($key, $legacy_key)` | string | Get raw meta value |

---


#### Publishing and policy helpers (3.0.0)

| Method | Returns |
|---|---|
| `address_hidden()` | `true` when the street address must not be shown. `address1()`, `address2()`, `unit()` and `geographic_location()` return empty strings for these listings, and `full_address()` is suburb-only. |
| `show_map()` | Whether to show a map. Hidden-address listings get a suburb-level map. Use it instead of `has_map_coords()` when deciding whether to render a map. |
| `is_rented()` | `true` when Palace shows a current tenancy. Rented properties are drafts, out of public view. |
| `rented_since()` / `rented_until()` | Tenancy start date and tenancy or lease end date (`Y-m-d`), or an empty string. Meta keys: `ipp_rental_status`, `ipp_rented_since`, `ipp_rented_until`. |
| `pets_label()` / `smokers_label()` | Display text: `Yes`, `No`, or Palace's wording such as `By consent`. `pets_allowed()` and `smokers_allowed()` stay strict booleans. |

## Hooks & Filters Reference

### Lifecycle Actions

These fire during property sync — both the scheduled poll of the Palace API and the legacy webhook route.

#### ipp_before_property_save

Fires before a property is created/updated during a sync.

```php
add_action( 'ipp_before_property_save', function( $post_id, $property_data ) {
    // $post_id is 0 for new properties
    error_log( 'Saving property: ' . $property_data['PropertyCode'] );
}, 10, 2 );
```

#### ipp_after_property_save

Fires after a property is saved and all meta is written.

```php
add_action( 'ipp_after_property_save', function( $post_id, $property_data ) {
    // Send notification, update external system, etc.
    wp_mail( 'admin@example.com', 'Property Updated', 'Post #' . $post_id );
}, 10, 2 );
```

#### ipp_before_property_delete

Fires before a property is deleted during a sync.

```php
add_action( 'ipp_before_property_delete', function( $post_id, $property_code ) {
    // Archive data before deletion
}, 10, 2 );
```

#### ipp_after_property_delete

Fires after a property is deleted during a sync.

```php
add_action( 'ipp_after_property_delete', function( $post_id, $property_code ) {
    // Clean up external references
}, 10, 2 );
```

#### ipp_sync_started

Fires when a sync cycle begins.

```php
add_action( 'ipp_sync_started', function( $queued ) {
    update_option( 'ipp_last_sync_start', current_time( 'mysql' ) );
} );
```

#### ipp_sync_completed

Fires when a sync cycle ends.

```php
add_action( 'ipp_sync_completed', function( $imported, $deleted ) {
    error_log( "Palace sync: {$imported} updated, {$deleted} removed" );
}, 10, 2 );
```

#### ipp_gallery_updated

Fires after gallery images are synced for a property.

```php
add_action( 'ipp_gallery_updated', function( $post_id, $image_ids ) {
    // Regenerate thumbnails, update CDN, etc.
}, 10, 2 );
```

### Rendering Actions

These fire during template rendering.

#### ipp_before_archive / ipp_after_archive

```php
add_action( 'ipp_before_archive', function( $template_slug ) {
    echo '<div class="my-archive-wrapper">';
} );

add_action( 'ipp_after_archive', function( $template_slug ) {
    echo '</div>';
} );
```

#### ipp_before_single / ipp_after_single

```php
add_action( 'ipp_before_single', function( $post_id, $prop ) {
    // Add breadcrumbs, etc.
    echo '<nav class="breadcrumb">' . esc_html( $prop->region() ) . ' > ' . esc_html( $prop->suburb() ) . '</nav>';
}, 10, 2 );
```

#### ipp_before_card / ipp_after_card

```php
add_action( 'ipp_after_card', function( $post_id, $prop ) {
    // Add a "Compare" button after each card
    echo '<button class="compare-btn" data-id="' . esc_attr( $post_id ) . '">Compare</button>';
}, 10, 2 );
```

#### ipp_before_gallery / ipp_after_gallery

```php
add_action( 'ipp_before_gallery', function( $post_id, $images ) {
    echo '<p>' . count( $images ) . ' photos</p>';
}, 10, 2 );
```

#### ipp_before_agent_card / ipp_after_agent_card

```php
add_action( 'ipp_after_agent_card', function( $post_id, $prop ) {
    echo '<a href="/contact?agent=' . esc_attr( $prop->agent_code() ) . '">Contact Agent</a>';
}, 10, 2 );
```

#### ipp_before_search_bar / ipp_after_search_bar

```php
add_action( 'ipp_before_search_bar', function() {
    echo '<h2>Find Your Perfect Property</h2>';
} );
```

### Data Filters

#### ipp_property_data

Filter the full property data array (used by `ipp_property_to_array()` and the REST API).

```php
add_filter( 'ipp_property_data', function( $data, $post_id ) {
    // Add a custom field
    $data['custom']['walk_score'] = get_post_meta( $post_id, 'walk_score', true );
    return $data;
}, 10, 2 );
```

#### ipp_property_title

Filter property titles.

```php
add_filter( 'ipp_property_title', function( $title, $post_id ) {
    $prop = new IPP_Property_Helper( $post_id );
    return $prop->formatted_rent() . ' - ' . $title;
}, 10, 2 );
```

#### ipp_property_rent

Filter formatted rent display.

```php
add_filter( 'ipp_property_rent', function( $formatted, $raw, $period, $post_id ) {
    return 'NZ' . $formatted;
}, 10, 4 );
```

#### ipp_property_address

Filter property address display.

```php
add_filter( 'ipp_property_address', function( $address, $post_id ) {
    return strtoupper( $address );
}, 10, 2 );
```

#### ipp_gallery_images

Filter gallery images array before display.

```php
add_filter( 'ipp_gallery_images', function( $images, $post_id ) {
    // Limit to 10 images
    return array_slice( $images, 0, 10 );
}, 10, 2 );
```

#### ipp_property_schema

Filter Schema.org JSON-LD output.

```php
add_filter( 'ipp_property_schema', function( $schema_html, $post_id ) {
    // Modify or replace schema markup
    return $schema_html;
}, 10, 2 );
```

#### ipp_card_classes / ipp_single_classes

Filter CSS classes on card/single wrapper elements.

```php
add_filter( 'ipp_card_classes', function( $classes, $post_id ) {
    $prop = new IPP_Property_Helper( $post_id );
    if ( $prop->pets_allowed() ) {
        $classes[] = 'pet-friendly';
    }
    return $classes;
}, 10, 2 );
```

### Query Filters

#### ipp_archive_query_args

Filter the WP_Query args for archive pages and API calls.

```php
add_filter( 'ipp_archive_query_args', function( $args ) {
    // Only show properties from last 30 days
    $args['date_query'] = [
        [ 'after' => '30 days ago' ]
    ];
    return $args;
} );
```

#### ipp_properties_per_page

Filter properties per page.

```php
add_filter( 'ipp_properties_per_page', function( $per_page ) {
    return 12;
} );
```

#### ipp_default_sort

Filter default sort order.

```php
add_filter( 'ipp_default_sort', function( $sort_by ) {
    return 'price_asc';
} );
```

### Template Filters

#### ipp_template_path

Override any template file path.

```php
add_filter( 'ipp_template_path', function( $path, $type, $slug ) {
    if ( $type === 'card' ) {
        return get_stylesheet_directory() . '/partials/property-card.php';
    }
    return $path;
}, 10, 3 );
```

#### ipp_card_template

Override card template specifically.

```php
add_filter( 'ipp_card_template', function( $path, $slug ) {
    return get_stylesheet_directory() . '/palace-properties/my-card.php';
}, 10, 2 );
```

#### ipp_single_template

Override single template specifically.

```php
add_filter( 'ipp_single_template', function( $path, $slug ) {
    return get_stylesheet_directory() . '/palace-properties/my-single.php';
}, 10, 2 );
```

#### ipp_search_template

Override search bar template.

```php
add_filter( 'ipp_search_template', function( $path, $slug ) {
    return get_stylesheet_directory() . '/palace-properties/my-search.php';
}, 10, 2 );
```

### Display Config Filters

#### ipp_display_fields

Override which fields are display-enabled.

```php
add_filter( 'ipp_display_fields', function( $fields ) {
    // Always show pets_allowed regardless of admin setting
    if ( ! in_array( 'pets_allowed', $fields ) ) {
        $fields[] = 'pets_allowed';
    }
    return $fields;
} );
```

#### ipp_show_field

Override field visibility per property.

```php
add_filter( 'ipp_show_field', function( $show, $field_key, $post_id ) {
    // Hide rent for draft properties
    if ( $field_key === 'rent' && get_post_status( $post_id ) !== 'publish' ) {
        return false;
    }
    return $show;
}, 10, 3 );
```

### Sync Filters

#### ipp_sync_fetched_properties

Filter the raw property list returned by the Palace API, before the plugin works out what changed. This is the supported way to control which properties are imported — for example, to sync only one status.

Anything you remove here is treated as "no longer offered by Palace", so it will also be removed from the site (after the two-run confirmation described below).

```php
add_filter( 'ipp_sync_fetched_properties', function( $properties ) {
    return array_values( array_filter( $properties, function( $property ) {
        return isset( $property['PropertyStatus'] )
            && strtolower( $property['PropertyStatus'] ) === 'active';
    } ) );
} );
```

#### ipp_sync_batch_size

How many properties are imported per cron tick. Defaults to `10`, clamped to 1–100. Raise it on a fast server, lower it on constrained shared hosting.

```php
add_filter( 'ipp_sync_batch_size', function( $size ) {
    return 25;
} );
```

#### ipp_sync_max_delete_ratio

The largest share of the property library a single sync is allowed to delete. Defaults to `0.5`. When a run exceeds this, no deletions are made and the sync is reported as needing attention — this is the guard against a truncated Palace response emptying the site.

A property must also be absent from **two consecutive** syncs before it is deleted at all, so lowering this is rarely necessary.

```php
add_filter( 'ipp_sync_max_delete_ratio', function( $ratio ) {
    return 0.25; // never remove more than a quarter in one run
} );
```

#### ipp_sync_min_missing_seconds

How long a property must stay missing from the Palace feed before a sync may remove it. Defaults to half the sync interval, and never less than ten minutes. A property must be absent from two syncs at least this far apart, so back-to-back runs cannot confirm a removal from the same brief outage.

```php
add_filter( 'ipp_sync_min_missing_seconds', function( $seconds ) {
    return 2 * HOUR_IN_SECONDS;
} );
```

#### ipp_sync_tick_seconds

How long one cron tick keeps importing before yielding to the next. Defaults to `20`, clamped to 1–120. Keep it comfortably below your host's PHP request timeout.

```php
add_filter( 'ipp_sync_tick_seconds', function( $seconds ) {
    return 45; // a server with generous limits
} );
```

#### ipp_property_post_status

The status a synced property is saved with. Defaults to `publish`, or `draft` when Palace's "publish entry" setting is off for that listing. A property with a current tenancy is saved as `draft` before this filter runs, so it is not shown publicly unless a filter deliberately overrides that.

```php
// Publish every available property, ignoring Palace's publish setting.
add_filter( 'ipp_property_post_status', function( $status, $property ) {
    return 'publish';
}, 10, 2 );
```

#### ipp_address_hidden

Whether a property's street address, unit and exact coordinates are hidden from visitors. Defaults to `true` when Palace's "publish address" setting is off. It applies everywhere the address is output: templates, maps, the REST API, shortcodes and structured data.

```php
add_filter( 'ipp_address_hidden', function( $hidden, $post_id ) {
    return $hidden;
}, 10, 2 );
```

#### ipp_rented_retention_days

How many days a rented property stays in the Recently Rented list, counted from the date its tenancy started, before it is moved to the trash. Defaults to `90`.

```php
add_filter( 'ipp_rented_retention_days', function( $days ) {
    return 30;
} );
```

#### ipp_gallery_tick_seconds

How long one WP-Cron request keeps processing image downloads before deferring the rest to the next run. Defaults to `20`, minimum 5. WP-CLI runs are never deferred.

```php
add_filter( 'ipp_gallery_tick_seconds', function( $seconds ) {
    return 45;
} );
```

### WP-CLI

```bash
wp palace-sync run            # check Palace and import changes, to completion, in this process
wp palace-sync run --force    # re-import every property, ignoring change codes
wp palace-sync status         # last result, and when the next scheduled check is due
```

`wp palace-sync run` has no time limit and does not depend on site traffic, which makes it a good fit for a server cron job. Image fetches are still queued on WP-Cron; run `wp cron event run update_integrate_palace_gallery` to process them immediately.

---

## REST API Reference

All endpoints are public (no authentication required). Base URL:

```
/wp-json/palace-properties/v1/
```

### GET /properties

List properties with pagination and filtering.

**Query Parameters**:

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | int | 1 | Page number |
| `per_page` | int | 10 | Results per page (max 100) |
| `search` | string | | Text search |
| `status` | string | | Status taxonomy slug |
| `type` | string | | Type taxonomy slug |
| `region` | string | | Region taxonomy slug |
| `bedrooms_min` | int | | Minimum bedrooms |
| `bedrooms_max` | int | | Maximum bedrooms |
| `bathrooms_min` | int | | Minimum bathrooms |
| `bathrooms_max` | int | | Maximum bathrooms |
| `price_min` | float | | Minimum rent |
| `price_max` | float | | Maximum rent |
| `sort` | string | date | date, price_asc, price_desc, bedrooms, title |
| `exclude_hidden` | bool | true | Exclude archived properties |

**Example**:

```bash
curl "https://example.com/wp-json/palace-properties/v1/properties?per_page=5&region=auckland&sort=price_asc"
```

**Response Headers**:
- `X-WP-Total`: Total number of results
- `X-WP-TotalPages`: Total number of pages

**Response Body** (array of property objects):

```json
[
  {
    "id": 123,
    "title": "Modern 3BR House in Auckland",
    "slug": "modern-3br-house-auckland",
    "url": "https://example.com/properties/modern-3br-house-auckland/",
    "content": "Beautiful modern home...",
    "status": "Active",
    "property_code": "RBPR001441",
    "address": {
      "line1": "123",
      "line2": "Main Street",
      "city": "Auckland",
      "region": "Auckland",
      "suburb": "Ponsonby",
      "postcode": "1011",
      "full": "123, Main Street, Auckland",
      "short": "Auckland, 123 Main Street"
    },
    "financial": {
      "rent": 680,
      "rental_period": "Week",
      "formatted_rent": "$680 / week",
      "market_value": ""
    },
    "features": {
      "bedrooms": 3,
      "bathrooms": 2,
      "ensuites": 0,
      "cars": 1,
      "parking": "Single Garage",
      "class": "House",
      "year_built": "2018",
      "stories": "",
      "furnishings": "Unfurnished",
      "new_construction": "",
      "amenities": "",
      "floor_area": "120",
      "land_area_sqm": "450",
      "land_area_hectares": "",
      "pets_allowed": false,
      "smokers_allowed": false
    },
    "agent": {
      "code": "AG001",
      "name": "John Smith",
      "title": "Property Manager",
      "email": "john@example.com",
      "email2": "",
      "phone_mobile": "021-555-1234",
      "phone_work": "09-555-0000"
    },
    "gallery": [
      {
        "id": 456,
        "url": "https://example.com/wp-content/uploads/photo.jpg",
        "thumbnail": "https://example.com/wp-content/uploads/photo-150x150.jpg",
        "full": "https://example.com/wp-content/uploads/photo.jpg",
        "alt": "Living room"
      }
    ],
    "featured_image": "https://example.com/wp-content/uploads/featured.jpg",
    "map": {
      "lat": -36.8485,
      "lng": 174.7633,
      "has_coords": true,
      "embed_url": "https://maps.google.com/maps?q=-36.8485,174.7633&z=15&output=embed"
    },
    "links": {
      "virtual_tour": "https://tour.example.com/123",
      "web_link": ""
    },
    "taxonomies": {
      "type": ["House"],
      "status": ["Active"],
      "region": ["Auckland"],
      "management": ["Residential"]
    },
    "text": {
      "header": "Modern Family Home",
      "advert_text": "Beautiful modern home in the heart of Ponsonby...",
      "feature_details": ""
    },
    "custom_fields": {},
    "dates": {
      "available": "2026-04-01",
      "formatted_available": "1 April 2026",
      "start": "",
      "created": "2026-03-15 10:30:00",
      "modified": "2026-03-18 14:22:00"
    },
    "meta": {
      "management_type": "Residential",
      "archived": "",
      "name": ""
    }
  }
]
```

### GET /properties/{id}

Get a single property with all data.

```bash
curl "https://example.com/wp-json/palace-properties/v1/properties/123"
```

**Response**: Single property object (same structure as above).

### GET /properties/{id}/gallery

Get just the gallery images for a property.

```bash
curl "https://example.com/wp-json/palace-properties/v1/properties/123/gallery"
```

**Response**:

```json
[
  {
    "id": 456,
    "url": "https://example.com/wp-content/uploads/photo.jpg",
    "thumbnail": "https://example.com/wp-content/uploads/photo-150x150.jpg",
    "full": "https://example.com/wp-content/uploads/photo.jpg",
    "alt": "Living room"
  }
]
```

### GET /search

Search properties. Accepts the same parameters as `/properties`.

```bash
curl "https://example.com/wp-json/palace-properties/v1/search?search=ponsonby&bedrooms_min=2"
```

### GET /taxonomies

Get all available taxonomy terms with counts.

```bash
curl "https://example.com/wp-json/palace-properties/v1/taxonomies"
```

**Response**:

```json
{
  "types": [
    { "id": 1, "name": "House", "slug": "house", "count": 45 },
    { "id": 2, "name": "Apartment", "slug": "apartment", "count": 23 }
  ],
  "statuses": [
    { "id": 3, "name": "Active", "slug": "active", "count": 60 },
    { "id": 4, "name": "Inactive", "slug": "inactive", "count": 8 }
  ],
  "regions": [
    { "id": 5, "name": "Auckland", "slug": "auckland", "count": 42 },
    { "id": 6, "name": "Northland", "slug": "northland", "count": 18 }
  ],
  "management": [
    { "id": 7, "name": "Residential", "slug": "residential", "count": 55 }
  ]
}
```

### GET /stats

Get public property statistics.

```bash
curl "https://example.com/wp-json/palace-properties/v1/stats"
```

**Response**:

```json
{
  "total_published": 68,
  "total_draft": 2,
  "total_all": 70,
  "by_status": {
    "active": 60,
    "inactive": 8
  },
  "by_type": {
    "house": 45,
    "apartment": 23
  },
  "by_region": {
    "auckland": 42,
    "northland": 18
  }
}
```

---

## Shortcodes Reference

### [palace_properties]

Renders a property grid.

| Attribute | Default | Description |
|-----------|---------|-------------|
| `limit` | 9 | Number of properties |
| `columns` | 3 | Grid columns (2, 3, or 4) |
| `template` | (active) | Template slug override |
| `status` | | Filter by status slug |
| `type` | | Filter by type slug |
| `region` | | Filter by region slug |
| `sort` | date | date, price_asc, price_desc, bedrooms, title |
| `show_search` | no | Show search bar (yes/no) |
| `show_pagination` | yes | Show pagination (yes/no) |
| `class` | | Additional CSS class |

```
[palace_properties limit="6" columns="3" region="auckland" sort="price_asc"]
[palace_properties type="house" show_search="yes" class="featured-grid"]
```

### [palace_property]

Renders a single property card.

| Attribute | Default | Description |
|-----------|---------|-------------|
| `id` | (current post) | Property post ID |
| `template` | (active) | Template slug override |
| `class` | | Additional CSS class |

```
[palace_property id="123"]
[palace_property id="456" template="modern" class="highlight"]
```

### [palace_search]

Renders the search/filter bar.

| Attribute | Default | Description |
|-----------|---------|-------------|
| `class` | | Additional CSS class |

```
[palace_search]
[palace_search class="hero-search"]
```

### [palace_property_count]

Outputs a property count number.

| Attribute | Default | Description |
|-----------|---------|-------------|
| `status` | | Filter by status slug |
| `type` | | Filter by type slug |
| `region` | | Filter by region slug |

```
We manage [palace_property_count] properties across New Zealand.
[palace_property_count status="active" region="auckland"] available in Auckland.
```

### [palace_property_field]

Outputs a single field value.

| Attribute | Default | Description |
|-----------|---------|-------------|
| `field` | (required) | Field name |
| `id` | (current post) | Property post ID |
| `format` | formatted | "raw" or "formatted" |

```
Rent: [palace_property_field field="rent" id="123"]
Address: [palace_property_field field="address" id="123"]
Raw rent: [palace_property_field field="rent" id="123" format="raw"]
```

### [palace_agent]

Renders an agent card for a property.

| Attribute | Default | Description |
|-----------|---------|-------------|
| `id` | (current post) | Property post ID |
| `class` | | Additional CSS class |

```
[palace_agent id="123"]
```

---

## Creating a Custom Template

### Step 1: Create template directory

```
your-theme/
  palace-properties/
    card.php      -- Archive card
    single.php    -- Single property page
    search.php    -- Search bar (optional)
```

### Step 2: Build card.php

```php
<?php
// your-theme/palace-properties/card.php
$prop = new IPP_Property_Helper();
$permalink = get_permalink();
?>
<article class="my-property-card">
    <?php if ( $prop->featured_image() ) : ?>
        <img src="<?php echo esc_url( $prop->featured_image( 'medium_large' ) ); ?>"
             alt="<?php echo esc_attr( get_the_title() ); ?>" />
    <?php endif; ?>

    <div class="my-property-card__content">
        <h3><a href="<?php echo esc_url( $permalink ); ?>"><?php the_title(); ?></a></h3>

        <?php if ( $prop->show( 'rent' ) && $prop->formatted_rent() ) : ?>
            <p class="price"><?php echo esc_html( $prop->formatted_rent() ); ?></p>
        <?php endif; ?>

        <?php if ( $prop->show( 'address' ) ) : ?>
            <p class="address"><?php echo esc_html( $prop->full_address() ); ?></p>
        <?php endif; ?>

        <ul class="features">
            <?php if ( $prop->show( 'bedrooms' ) && $prop->bedrooms() ) : ?>
                <li><?php echo esc_html( $prop->bedrooms() ); ?> Beds</li>
            <?php endif; ?>
            <?php if ( $prop->show( 'bathrooms' ) && $prop->bathrooms() ) : ?>
                <li><?php echo esc_html( $prop->bathrooms() ); ?> Baths</li>
            <?php endif; ?>
            <?php if ( $prop->show( 'cars' ) && $prop->cars() ) : ?>
                <li><?php echo esc_html( $prop->cars() ); ?> Cars</li>
            <?php endif; ?>
        </ul>
    </div>
</article>
```

### Step 3: Build single.php

```php
<?php
// your-theme/palace-properties/single.php
// $prop and $post_id are already available from the wrapper
?>
<div class="my-property-detail">
    <h1><?php the_title(); ?></h1>

    <?php echo ipp_render_gallery( $post_id ); ?>

    <div class="details-grid">
        <div class="main-info">
            <p class="price"><?php echo esc_html( $prop->formatted_rent() ); ?></p>
            <p class="address"><?php echo esc_html( $prop->full_address() ); ?></p>
            <p class="available"><?php echo esc_html( $prop->formatted_date_available() ); ?></p>

            <div class="description">
                <?php the_content(); ?>
            </div>
        </div>

        <aside class="sidebar">
            <?php echo do_shortcode( '[palace_agent id="' . $post_id . '"]' ); ?>

            <?php if ( $prop->has_map_coords() ) : ?>
                <iframe src="<?php echo esc_url( $prop->map_embed_url() ); ?>"
                        width="100%" height="300" frameborder="0"></iframe>
            <?php endif; ?>
        </aside>
    </div>
</div>
```

---

## Building a Headless Frontend

Use the REST API to build a fully custom frontend with React, Vue, Next.js, or any framework.

### JavaScript Example (Fetch API)

```javascript
const API_BASE = 'https://example.com/wp-json/palace-properties/v1';

// List properties
async function getProperties(params = {}) {
    const query = new URLSearchParams(params).toString();
    const response = await fetch(`${API_BASE}/properties?${query}`);
    const properties = await response.json();
    const total = response.headers.get('X-WP-Total');
    const totalPages = response.headers.get('X-WP-TotalPages');
    return { properties, total, totalPages };
}

// Single property
async function getProperty(id) {
    const response = await fetch(`${API_BASE}/properties/${id}`);
    return response.json();
}

// Search
async function searchProperties(query) {
    const { properties } = await getProperties({
        search: query,
        per_page: 20,
        sort: 'price_asc'
    });
    return properties;
}

// Get filter options
async function getTaxonomies() {
    const response = await fetch(`${API_BASE}/taxonomies`);
    return response.json();
}

// Usage
const { properties, total } = await getProperties({
    region: 'auckland',
    bedrooms_min: 2,
    price_max: 800,
    per_page: 12,
    page: 1,
});

console.log(`Found ${total} properties`);
properties.forEach(p => {
    console.log(`${p.title} - ${p.financial.formatted_rent}`);
});
```

### React Example

```jsx
import { useState, useEffect } from 'react';

const API = '/wp-json/palace-properties/v1';

function PropertyList() {
    const [properties, setProperties] = useState([]);
    const [filters, setFilters] = useState({ page: 1, per_page: 12 });

    useEffect(() => {
        const query = new URLSearchParams(filters).toString();
        fetch(`${API}/properties?${query}`)
            .then(res => res.json())
            .then(data => setProperties(data));
    }, [filters]);

    return (
        <div className="property-grid">
            {properties.map(p => (
                <div key={p.id} className="property-card">
                    {p.featured_image && <img src={p.featured_image} alt={p.title} />}
                    <h3>{p.title}</h3>
                    <p>{p.financial.formatted_rent}</p>
                    <p>{p.address.full}</p>
                    <div className="features">
                        {p.features.bedrooms && <span>{p.features.bedrooms} Beds</span>}
                        {p.features.bathrooms && <span>{p.features.bathrooms} Baths</span>}
                    </div>
                </div>
            ))}
        </div>
    );
}
```

---

## Extending Property Data

### Add custom data via hooks

```php
// Add custom data to the property array and REST API
add_filter( 'ipp_property_data', function( $data, $post_id ) {
    // Add walk score from a third-party service
    $data['extensions']['walk_score'] = get_post_meta( $post_id, 'walk_score', true );

    // Add nearby schools
    $data['extensions']['schools'] = get_post_meta( $post_id, 'nearby_schools', true );

    return $data;
}, 10, 2 );
```

### Lock a property from sync updates

Set the `ipp_lock_updates` meta to `1` to stop a sync from overwriting local edits:

```php
update_post_meta( $post_id, 'ipp_lock_updates', '1' );
```

### Store additional data on property save

```php
add_action( 'ipp_after_property_save', function( $post_id, $property_data ) {
    // Calculate and store a derived field
    $rent = get_post_meta( $post_id, 'ipp_rent_amount', true );
    $period = get_post_meta( $post_id, 'ipp_rental_period', true );

    $monthly = $period === 'Week' ? $rent * 52 / 12 : $rent;
    update_post_meta( $post_id, 'ipp_monthly_rent', round( $monthly, 2 ) );
}, 10, 2 );
```

---

## CSS Architecture

The plugin uses BEM (Block Element Modifier) naming conventions.

### Block Classes

| Class | Description |
|-------|-------------|
| `.ipp-archive-page` | Archive page wrapper |
| `.ipp-single-page` | Single property page wrapper |
| `.ipp-grid` | Property grid container |
| `.ipp-card` | Property card |
| `.ipp-search` | Search bar |
| `.ipp-gallery` | Gallery container |
| `.ipp-pagination` | Pagination wrapper |
| `.ipp-agent-card` | Agent card |
| `.ipp-container` | Max-width container |
| `.ipp-no-results` | No results message |

### Card Elements

| Class | Description |
|-------|-------------|
| `.ipp-card__image` | Card image link wrapper |
| `.ipp-card__badge` | Status badge overlay |
| `.ipp-card__price` | Price overlay |
| `.ipp-card__body` | Card content area |
| `.ipp-card__title` | Property title |
| `.ipp-card__address` | Address line |
| `.ipp-card__meta` | Meta features row |
| `.ipp-card__meta-item` | Individual feature (bed/bath/car) |
| `.ipp-card__footer` | Card footer (date + button) |

### Search Elements

| Class | Description |
|-------|-------------|
| `.ipp-search__form` | Search form |
| `.ipp-search__row` | Form row |
| `.ipp-search__group` | Input group |
| `.ipp-search__input` | Text/number input |
| `.ipp-search__select` | Dropdown select |
| `.ipp-search__actions` | Button group |

### Modifiers

| Class | Description |
|-------|-------------|
| `.ipp-badge--active` | Active status badge (green) |
| `.ipp-badge--inactive` | Inactive status badge |
| `.ipp-btn--primary` | Primary button |
| `.ipp-btn--outline` | Outline button |
| `.ipp-btn--sm` | Small button |
| `.ipp-btn--lg` | Large button |
| `.ipp-grid--cols-2` | 2-column grid |
| `.ipp-grid--cols-3` | 3-column grid |
| `.ipp-grid--cols-4` | 4-column grid |

### Gallery Elements

| Class | Description |
|-------|-------------|
| `.ipp-gallery` | Gallery wrapper |
| `.ipp-gallery__item` | Individual image wrapper |

### Agent Card Elements

| Class | Description |
|-------|-------------|
| `.ipp-agent-card__body` | Agent content area |
| `.ipp-agent-card__name` | Agent name |
| `.ipp-agent-card__title` | Agent job title |
| `.ipp-agent-card__contact` | Contact links container |
| `.ipp-agent-card__email` | Email link |
| `.ipp-agent-card__phone` | Phone link |

### Shortcode Wrapper Classes

| Class | Description |
|-------|-------------|
| `.ipp-shortcode-grid` | Shortcode grid wrapper |
| `.ipp-shortcode-single` | Shortcode single card wrapper |
| `.ipp-shortcode-search` | Shortcode search wrapper |
| `.ipp-shortcode-agent` | Shortcode agent card wrapper |
| `.ipp-shortcode-pagination` | Shortcode pagination |
| `.ipp-property-count` | Property count span |
| `.ipp-field` | Field value span |
| `.ipp-field--{name}` | Field-specific class (e.g., `.ipp-field--rent`) |

### CSS Custom Properties

The plugin respects these CSS custom properties if defined in your theme:

```css
:root {
    --ipp-color-primary: #2563eb;
    --ipp-color-gray-100: #f3f4f6;
    --ipp-color-gray-900: #111827;
    --ipp-font-family: inherit;
    --ipp-border-radius: 8px;
    --ipp-card-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
```

### Overriding Styles

```css
/* Make cards rounded */
.ipp-card {
    border-radius: 16px;
    overflow: hidden;
}

/* Custom grid gap */
.ipp-grid {
    gap: 2rem;
}

/* Full-width on mobile */
@media (max-width: 768px) {
    .ipp-grid {
        grid-template-columns: 1fr;
    }
}
```
