/** * URBAN RENDER LIBRARY v1.2 * * The frontend twin of the Urban Studio Framework. The engine made ADMIN * screens universal — one engine, one config per site. Templates stayed * per-site forks, and forking templates is where this project's worst bugs * were born: Raymond's taxonomy inside Hiranandani's hub, a CSS class whose * rules existed in only one of the two stacks. This library exists to make * forking frontend code extinct the same way. * * ── THE THREE LAWS ───────────────────────────────────────────────────── * * 1. COMPONENTS OWN THEIR CSS. * A component emits its own styles, once per request. There is no "which * style block does this rule belong in?" question to get wrong, and a * component copied to another site cannot arrive unstyled. * * 2. COMPONENTS SPEAK ONLY IN TOKENS — with one stated exception. * * THIRD-PARTY BRAND COLOURS STAY LITERAL. YouTube red, the Instagram * gradient and Google Maps green are not part of any site's palette and no * site may change them: a green YouTube button is wrong on every site that * could ever use this library. Tokenising them would invite exactly that. * They are hard-coded deliberately, and they are the ONLY hex literals * permitted in a component. * * (The first draft tokenised the Maps green while leaving YouTube red * literal — an inconsistency caught by the audit, not by review.) * * No hex literal may appear in a component. Every colour is a --up-* * custom property supplied by the site's theme. This makes the Contrast * Sweep structural: a palette is validated once per site rather than per * selector, so white-on-cream cannot be introduced by a new component. * * 3. SAME BY DEFAULT, DIVERGENT ONLY WHEN DECLARED. * A site renders the default component. It may differ only by naming a * variant in its theme config. A named variant must consume the IDENTICAL * data contract as the default and honour the same empty-hides behaviour — * it may differ in markup and styling only. The moment a variant needs its * own field, it is not a variant, it is a second component and gets its own * name. Presentation may diverge; the contract may not. * * ── WHAT IS NOT HERE ─────────────────────────────────────────────────── * Header, footer, sticky bar and floating elements stay with each site. * They are the most site-personal things on the page and absorbing them * would make the library a theme rather than a component set. * * ── SCOPE OF v1.0 ────────────────────────────────────────────────────── * The seven components the facet page needs. The library is born with new * code, not by extracting the live project and variant templates: the facet * page is new, so nothing can break while the library proves itself. The * existing templates migrate onto these components afterwards, one at a * time, each with its own before/after check. */ if ( ! defined( 'ABSPATH' ) ) { exit; } /* ═══════════════════════════════════════════════════════════════════════ * THEME LAYER — a site declares its palette; components consume names only * ═══════════════════════════════════════════════════════════════════════ */ if ( ! isset( $GLOBALS['up_theme'] ) ) { $GLOBALS['up_theme'] = array(); } if ( ! function_exists( 'up_theme_register' ) ) { function up_theme_register( array $tokens, array $opts = array() ): void { $GLOBALS['up_theme'] = array( 'tokens' => $tokens, 'opts' => $opts ); } } /** The site's chosen variant for a component, or '' for the default. */ if ( ! function_exists( 'up_variant_of' ) ) { function up_variant_of( string $component ): string { return (string) ( $GLOBALS['up_theme']['opts']['components'][ $component ] ?? '' ); } } /** * Emit the palette once. Every component depends on this having run, so each * one calls it; the static guard makes the repetition free. */ if ( ! function_exists( 'up_theme_css' ) ) { function up_theme_css(): void { static $done = false; if ( $done ) { return; } $done = true; $defaults = array( 'primary' => '#7B1E2E', 'primary-deep' => '#5A1220', 'accent' => '#C9A45C', 'accent-soft' => '#E7D3A8', 'ink' => '#3E4A5E', 'mute' => '#8A8378', 'line' => '#EBD9C0', 'dark' => '#0B0B0D', 'surface' => '#FFFFFF', 'surface-warm' => '#FBF3EC', 'surface-alt' => '#F6E7D4', 'on-primary' => '#FFFFFF', // text ON a primary-coloured surface /* Added for the listing components. Every one has a real use; a token * that exists for tidiness rather than need is a token that drifts. */ 'accent' => '#C9A45C', // aliased below for sites that call it that 'accent-ink' => '#7A4E00', // accent, dark enough to read as text 'on-accent' => '#FFFFFF', 'soft-accent' => '#FDF1E1', 'soft-green' => '#E1F7E8', 'verified' => '#1D9E75', 'verified-ink' => '#0E6B50', 'whatsapp' => '#25D366', 'line-soft' => '#F0EDDF', 'radius-sm' => '8px', 'radius-md' => '12px', 'radius-lg' => '16px', 'radius-pill' => '999px', 'shadow-sm' => '0 4px 12px rgba(0,0,0,.06)', 'shadow-md' => '0 12px 30px rgba(0,0,0,.08)', 'font-display' => "'Playfair Display',serif", 'font-body' => "'Inter',sans-serif", 'container' => '1160px', ); $tokens = array_merge( $defaults, (array) ( $GLOBALS['up_theme']['tokens'] ?? array() ) ); echo ''; } } /** * Emit a component's CSS exactly once per request. * * This is Law 1 made mechanical: a component cannot be rendered without its * styles, and rendering it fifty times cannot emit them fifty times. */ if ( ! function_exists( 'up_css' ) ) { function up_css( string $id, string $css ): void { static $seen = array(); if ( isset( $seen[ $id ] ) ) { return; } $seen[ $id ] = true; up_theme_css(); echo ''; } } /** Shared container rule, needed by nearly every component. */ if ( ! function_exists( 'up_wrap_css' ) ) { function up_wrap_css(): void { up_css( 'wrap', '.up-wrap{max-width:var(--up-container);margin:0 auto;padding:0 24px}' . '.up-eyebrow{font-size:.7rem;letter-spacing:.32em;text-transform:uppercase;color:var(--up-accent);font-weight:600}' . '.up-sec-head{text-align:center;margin-bottom:40px}' . '.up-sec-head h2{font-family:var(--up-font-display);font-weight:600;color:var(--up-primary);font-size:clamp(1.6rem,2.6vw,2.2rem);margin:12px 0 0}' . '.up-sec-head h2 em{font-style:italic;color:var(--up-accent)}' ); } } /* ═══════════════════════════════════════════════════════════════════════ * COMPONENT 1 — BREADCRUMB * data: [ ['name'=>string, 'url'=>string|''], … ] last item renders plain * ═══════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_breadcrumb' ) ) { function up_render_breadcrumb( array $crumbs ): void { $crumbs = array_values( array_filter( $crumbs, function ( $c ) { return '' !== trim( (string) ( $c['name'] ?? '' ) ); } ) ); if ( ! $crumbs ) { return; } // empty hides up_wrap_css(); up_css( 'crumb', '.up-crumb{font-size:.72rem;letter-spacing:.14em;text-transform:uppercase;color:var(--up-mute);margin-bottom:22px}' . '.up-crumb a{color:var(--up-mute);text-decoration:none}' . '.up-crumb a:hover{color:var(--up-primary)}' . '.up-crumb span{color:var(--up-primary)}' ); echo ''; } } /* ═══════════════════════════════════════════════════════════════════════ * COMPONENT 2 — PAGE HERO (authored) * data: eyebrow, heading (may contain ), intro (plain text, \n\n = para) * ═══════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_page_hero' ) ) { function up_render_page_hero( array $d ): void { $heading = trim( (string) ( $d['heading'] ?? '' ) ); $intro = trim( (string) ( $d['intro'] ?? '' ) ); $eyebrow = trim( (string) ( $d['eyebrow'] ?? '' ) ); if ( '' === $heading && '' === $intro ) { return; } // empty hides up_wrap_css(); up_css( 'hero', '.up-hero{background:var(--up-surface-warm);padding:56px 0 48px;border-bottom:1px solid var(--up-accent-soft)}' . '.up-hero h1{font-family:var(--up-font-display);font-weight:600;color:var(--up-primary);' . 'font-size:clamp(2rem,4vw,3rem);line-height:1.15;margin:14px 0 18px}' . '.up-hero h1 em{font-style:italic;color:var(--up-accent)}' . '.up-hero-intro{max-width:720px;color:var(--up-ink);font-size:1rem;line-height:1.7}' . '.up-hero-intro p{margin:0 0 12px}' . '.up-hero-intro p:last-child{margin-bottom:0}' ); echo '
'; if ( ! empty( $d['crumbs'] ) ) { up_render_breadcrumb( (array) $d['crumbs'] ); } if ( '' !== $eyebrow ) { echo '' . esc_html( $eyebrow ) . ''; } if ( '' !== $heading ) { echo '

' . wp_kses( $heading, array( 'em' => array() ) ) . '

'; } if ( '' !== $intro ) { echo '
'; foreach ( preg_split( '/\n\s*\n/', $intro ) as $para ) { $para = trim( $para ); if ( '' !== $para ) { echo '

' . esc_html( $para ) . '

'; } } echo '
'; } echo '
'; } } /* ═══════════════════════════════════════════════════════════════════════ * COMPONENT 3 — FACTS BAR (computed) * data: [ ['value'=>string,'label'=>string], … ] * ═══════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_facts_bar' ) ) { function up_render_facts_bar( array $cells ): void { $cells = array_values( array_filter( $cells, function ( $c ) { return '' !== trim( (string) ( $c['value'] ?? '' ) ); } ) ); if ( ! $cells ) { return; } // empty hides up_wrap_css(); up_css( 'facts', '.up-facts{background:var(--up-dark);padding:26px 0}' . '.up-facts-grid{display:grid;gap:12px;text-align:center}' . '.up-facts b{display:block;font-family:var(--up-font-display);font-weight:600;' . 'color:var(--up-accent-soft);font-size:1.5rem;line-height:1.2}' . '.up-facts span{font-size:.66rem;letter-spacing:.2em;text-transform:uppercase;color:var(--up-mute)}' . '@media(max-width:900px){.up-facts-grid{grid-template-columns:repeat(2,1fr)!important;gap:20px}}' ); echo '
'; foreach ( $cells as $c ) { echo '
' . esc_html( (string) $c['value'] ) . '' . esc_html( (string) ( $c['label'] ?? '' ) ) . '
'; } echo '
'; } } /* ═══════════════════════════════════════════════════════════════════════ * COMPONENT 4 — PROJECT CARDS * data: [ ['title','url','image','kicker','meta','price','cta'], … ] * ═══════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_project_cards' ) ) { function up_render_project_cards( array $cards, array $head = array() ): void { $cards = array_values( array_filter( $cards, function ( $c ) { return '' !== trim( (string) ( $c['title'] ?? '' ) ); } ) ); if ( ! $cards ) { return; } // empty hides up_wrap_css(); up_css( 'cards', '.up-sec{padding:72px 0}' . '.up-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:22px}' . '.up-card{border:1px solid var(--up-line);background:var(--up-surface);display:flex;flex-direction:column;' . 'transition:transform .25s ease,box-shadow .25s ease}' . '.up-card:hover{transform:translateY(-5px);box-shadow:0 16px 40px rgba(0,0,0,.12)}' . '.up-card-img{aspect-ratio:4/3;background:linear-gradient(135deg,var(--up-primary),var(--up-primary-deep));' . 'background-size:cover;background-position:center;position:relative}' . '.up-card-img i{position:absolute;right:10px;bottom:8px;font-style:normal;font-size:.58rem;' . 'letter-spacing:.14em;text-transform:uppercase;color:rgba(255,255,255,.55)}' . '.up-card-body{padding:22px;background:var(--up-surface-alt);flex:1;display:flex;flex-direction:column}' . '.up-card-kicker{font-size:.66rem;letter-spacing:.2em;text-transform:uppercase;color:var(--up-accent);font-weight:600}' . '.up-card h3{font-family:var(--up-font-display);font-weight:600;color:var(--up-primary);font-size:1.25rem;margin:8px 0 12px}' . '.up-card-meta{font-size:.86rem;color:var(--up-ink);border-top:1px solid var(--up-line);padding-top:12px;margin-top:auto}' . '.up-card-price{font-family:var(--up-font-display);font-style:italic;color:var(--up-primary);font-size:1.05rem;margin-top:6px}' . '.up-card-go{margin-top:16px;font-size:.72rem;letter-spacing:.18em;text-transform:uppercase;color:var(--up-primary);' . 'font-weight:600;text-decoration:none;border-bottom:1px solid var(--up-accent);padding-bottom:3px;align-self:flex-start}' . '@media(max-width:900px){.up-cards{grid-template-columns:1fr}.up-sec{padding:52px 0}}' ); echo '
'; if ( ! empty( $head['heading'] ) || ! empty( $head['eyebrow'] ) ) { echo '
'; if ( ! empty( $head['eyebrow'] ) ) { echo '' . esc_html( (string) $head['eyebrow'] ) . ''; } if ( ! empty( $head['heading'] ) ) { echo '

' . wp_kses( (string) $head['heading'], array( 'em' => array() ) ) . '

'; } echo '
'; } echo '
'; foreach ( $cards as $c ) { $img = (string) ( $c['image'] ?? '' ); echo '
'; echo '
'; if ( ! empty( $c['image_note'] ) ) { echo '' . esc_html( (string) $c['image_note'] ) . ''; } echo '
'; if ( ! empty( $c['kicker'] ) ) { echo '' . esc_html( (string) $c['kicker'] ) . ''; } echo '

' . esc_html( (string) $c['title'] ) . '

'; echo '
' . esc_html( (string) ( $c['meta'] ?? '' ) ); if ( ! empty( $c['price'] ) ) { echo '
' . esc_html( (string) $c['price'] ) . '
'; } echo '
'; if ( ! empty( $c['url'] ) ) { echo '' . esc_html( (string) ( $c['cta'] ?? 'Discover the project' ) ) . ' →'; } echo '
'; } echo '
'; } } /* ═══════════════════════════════════════════════════════════════════════ * COMPONENT 5 — CROSS LINKS * data: [ ['title'=>string, 'links'=>[ ['name','url'], … ] ], … ] * What turns isolated facet pages into a network rather than orphans. * ═══════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_cross_links' ) ) { function up_render_cross_links( array $cols ): void { $cols = array_values( array_filter( $cols, function ( $c ) { return ! empty( $c['links'] ); } ) ); if ( ! $cols ) { return; } // empty hides up_wrap_css(); up_css( 'xlinks', '.up-xlinks{background:var(--up-surface-warm);padding:56px 0}' . '.up-xgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:34px}' . '.up-xcol h4{font-family:var(--up-font-display);color:var(--up-primary);font-size:1rem;margin:0 0 12px}' . '.up-xcol a{display:block;color:var(--up-ink);text-decoration:none;font-size:.9rem;padding:6px 0;' . 'border-bottom:1px solid var(--up-line)}' . '.up-xcol a:hover{color:var(--up-primary)}' . '@media(max-width:900px){.up-xgrid{grid-template-columns:1fr;gap:26px}}' ); echo ''; } } /* ═══════════════════════════════════════════════════════════════════════ * COMPONENT 6 — FAQ * data: [ ['q'=>string,'a'=>string], … ] * Renders the accordion AND returns the FAQPage schema nodes, so the two can * never disagree — a question shown but not marked up, or the reverse, is a * bug that only appears months later in Search Console. * ═══════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_faq' ) ) { function up_render_faq( array $faqs, array $head = array() ): array { $clean = array(); foreach ( $faqs as $f ) { $q = trim( (string) ( $f['q'] ?? '' ) ); $a = trim( (string) ( $f['a'] ?? '' ) ); if ( '' !== $q && '' !== $a ) { $clean[] = array( 'q' => $q, 'a' => $a ); } } if ( ! $clean ) { return array(); } // empty hides, and emits no schema up_wrap_css(); up_css( 'faq', '.up-faq{padding:72px 0}' . '.up-faq details{border-bottom:1px solid var(--up-line);padding:16px 0}' . '.up-faq summary{cursor:pointer;font-family:var(--up-font-display);color:var(--up-primary);font-size:1.05rem;' . 'list-style:none;display:flex;justify-content:space-between;align-items:center;gap:16px;' . '-webkit-user-select:none;user-select:none}' . '.up-faq summary::-webkit-details-marker{display:none}' . '.up-faq summary::after{content:"+";color:var(--up-accent);font-size:1.3rem;font-family:var(--up-font-body)}' . '.up-faq details[open] summary::after{content:"–"}' . '.up-faq p{margin:12px 0 0;color:var(--up-ink);font-size:.94rem;max-width:820px;line-height:1.7}' . '@media(max-width:900px){.up-faq{padding:52px 0}}' ); echo '
'; if ( ! empty( $head['heading'] ) || ! empty( $head['eyebrow'] ) ) { echo '
'; if ( ! empty( $head['eyebrow'] ) ) { echo '' . esc_html( (string) $head['eyebrow'] ) . ''; } if ( ! empty( $head['heading'] ) ) { echo '

' . wp_kses( (string) $head['heading'], array( 'em' => array() ) ) . '

'; } echo '
'; } $schema = array(); foreach ( $clean as $i => $f ) { echo ''; echo '' . esc_html( $f['q'] ) . ''; echo '

' . esc_html( $f['a'] ) . '

'; echo ''; $schema[] = array( '@type' => 'Question', 'name' => $f['q'], 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => $f['a'] ), ); } echo '
'; return $schema; } } /* ═══════════════════════════════════════════════════════════════════════ * COMPONENT 7 — ENQUIRY FORM * * The library owns the MECHANICS: markup, styling, the hidden fields, the * return-URL field, the honeypot. The site supplies where a lead LANDS via * 'action' and its own hidden fields — because the lead post type differs per * site and the library must never learn a post type name. * ═══════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_enquiry' ) ) { function up_render_enquiry( array $d ): void { $action = (string) ( $d['action'] ?? '' ); if ( '' === $action ) { return; } // no handler declared → no form up_wrap_css(); up_css( 'enq', '.up-enq{background:var(--up-dark);padding:64px 0;color:var(--up-accent-soft)}' . '.up-enq-grid{display:grid;grid-template-columns:1fr 1fr;gap:48px;align-items:center}' . '.up-enq h2{font-family:var(--up-font-display);color:var(--up-accent-soft);font-size:1.8rem;margin:12px 0 14px}' . '.up-enq h2 em{font-style:italic;color:var(--up-accent)}' . '.up-enq p{color:var(--up-mute);font-size:.92rem;line-height:1.7}' . '.up-enq input,.up-enq select{width:100%;padding:13px 2px;border:0;border-bottom:1px solid var(--up-mute);' . 'background:transparent;color:var(--up-accent-soft);margin-bottom:16px;font-family:var(--up-font-body);font-size:.94rem}' . '.up-enq input::placeholder{color:var(--up-mute)}' . '.up-enq button{background:var(--up-primary);color:var(--up-on-primary);border:1px solid var(--up-accent);padding:14px 30px;' . 'font-size:.74rem;letter-spacing:.2em;text-transform:uppercase;font-weight:600;cursor:pointer;' . '-webkit-user-select:none;user-select:none;transition:background .2s ease}' . '.up-enq button:hover{background:var(--up-primary-deep)}' . '.up-enq-hp{position:absolute;left:-10000px;opacity:0;pointer-events:none}' . '@media(max-width:900px){.up-enq-grid{grid-template-columns:1fr;gap:28px}}' ); echo '
'; echo '
'; if ( ! empty( $d['eyebrow'] ) ) { echo '' . esc_html( (string) $d['eyebrow'] ) . ''; } if ( ! empty( $d['heading'] ) ) { echo '

' . wp_kses( (string) $d['heading'], array( 'em' => array() ) ) . '

'; } if ( ! empty( $d['blurb'] ) ) { echo '

' . esc_html( (string) $d['blurb'] ) . '

'; } echo '
'; echo '
'; echo ''; foreach ( (array) ( $d['hidden'] ?? array() ) as $k => $v ) { echo ''; } if ( ! empty( $d['nonce_action'] ) && ! empty( $d['nonce_name'] ) ) { wp_nonce_field( (string) $d['nonce_action'], (string) $d['nonce_name'] ); } /* Honeypot: off-screen, never visible, never focusable. * * The NAME is supplied by the site, because the site's handler is what * checks it. A library that hardcoded "website" while a handler looked for * "uhp2_web" would render a honeypot that can never fire — spam protection * that exists in the markup and nowhere else. Field names are a contract * between form and handler, so the contract holder names them. */ $hp = (string) ( $d['honeypot'] ?? 'website' ); echo ''; $pre = (array) ( $d['prefill'] ?? array() ); echo ''; echo ''; if ( ! empty( $d['intents'] ) ) { echo ''; } echo ''; echo '
'; echo '
'; } } /* ═══════════════════════════════════════════════════════════════════════ * COMPONENT REGISTRY — so the estate's divergence is inspectable rather * than folkloric. A component with no registered variants has exactly one * implementation, by definition. * ═══════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_components' ) ) { function up_components(): array { return array( 'breadcrumb' => array( 'fn' => 'up_render_breadcrumb', 'variants' => array() ), 'page_hero' => array( 'fn' => 'up_render_page_hero', 'variants' => array() ), 'facts_bar' => array( 'fn' => 'up_render_facts_bar', 'variants' => array() ), 'project_cards' => array( 'fn' => 'up_render_project_cards', 'variants' => array() ), 'cross_links' => array( 'fn' => 'up_render_cross_links', 'variants' => array() ), 'faq' => array( 'fn' => 'up_render_faq', 'variants' => array() ), 'enquiry' => array( 'fn' => 'up_render_enquiry', 'variants' => array() ), /* listing components — added Sep 3 2026 */ 'gallery' => array( 'fn' => 'up_render_gallery', 'variants' => array() ), 'record' => array( 'fn' => 'up_render_record', 'variants' => array() ), 'section_nav' => array( 'fn' => 'up_render_section_nav', 'variants' => array() ), 'spec_tables' => array( 'fn' => 'up_render_spec_tables', 'variants' => array() ), 'chips' => array( 'fn' => 'up_render_chips', 'variants' => array() ), 'address' => array( 'fn' => 'up_render_address', 'variants' => array() ), 'media_links' => array( 'fn' => 'up_render_media_links', 'variants' => array() ), 'gate' => array( 'fn' => 'up_render_gate', 'variants' => array() ), 'listing_cards' => array( 'fn' => 'up_render_listing_cards', 'variants' => array() ), 'prose' => array( 'fn' => 'up_render_prose', 'variants' => array() ), ); } } /* ═══════════════════════════════════════════════════════════════════════ * LISTING COMPONENTS (added Sep 3 2026, for the main-site property page) * * Built from the approved mockup, NOT extracted from Hiranandani's 5a. * 5a's markup is burgundy and shaped for project and variant pages; the * listing page is a different design — cream page, sticky record card, * different spec grouping. Lifting 5a would have produced Hiranandani's page * in orange rather than the page that was signed off. * * Hiranandani migrates ONTO these later, in the other direction: replacing * its inline markup with calls, once these are proven here. * * Every component obeys the three laws: owns its CSS, speaks only in tokens, * and hides itself when it has nothing to show. * ═══════════════════════════════════════════════════════════════════════ */ /* ═══ 8. GALLERY ══════════════════════════════════════════════════════ * data: array of attachment IDs. * Desktop a 2:1 split — one large image, two stacked. Mobile a full-bleed * swipe strip: photographs are the product on a resale and a phone is where * people actually look at them. * ═════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_gallery' ) ) { function up_render_gallery( array $ids, array $opts = array() ) { $ids = array_values( array_filter( array_map( 'intval', $ids ) ) ); if ( ! $ids ) { return; } // empty hides up_wrap_css(); up_css( 'gallery', '.up-gal{display:grid;grid-template-columns:2fr 1fr;grid-template-rows:158px 158px;gap:10px}' . '.up-gal figure{margin:0;border-radius:var(--up-radius-md);overflow:hidden;position:relative;' . 'box-shadow:var(--up-shadow-sm);background:var(--up-surface-warm)}' . '.up-gal figure:first-child{grid-row:1/3;border-radius:var(--up-radius-lg)}' . '.up-gal img{width:100%;height:100%;object-fit:cover;display:block}' . '.up-gal-more{position:absolute;right:12px;bottom:12px;background:rgba(0,0,0,.78);color:#fff;' . 'font-size:12.5px;padding:7px 13px;border-radius:var(--up-radius-pill)}' . '.up-gal-tag{position:absolute;left:12px;top:12px;font-size:12px;font-weight:600;padding:6px 12px;' . 'border-radius:var(--up-radius-pill);background:var(--up-soft-green);color:var(--up-verified)}' /* Mobile: one full-bleed swipe strip. A 2:1 grid on a 390px screen * makes the two small images unreadable. */ . '@media(max-width:900px){' . '.up-gal{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;gap:0;' . 'grid-template-columns:none;grid-template-rows:none;scrollbar-width:none;' . 'margin:0 -16px;border-radius:0}' . '.up-gal::-webkit-scrollbar{display:none}' . '.up-gal figure{flex:none;width:100vw;height:270px;border-radius:0!important;' . 'scroll-snap-align:start;box-shadow:none}' . '}' ); $tag = (string) ( $opts['tag'] ?? '' ); $total = count( $ids ); $show = array_slice( $ids, 0, 3 ); echo '
'; foreach ( $show as $i => $id ) { $size = ( 0 === $i ) ? 'large' : 'medium_large'; $url = wp_get_attachment_image_url( $id, $size ); if ( ! $url ) { continue; } echo '
'; echo '' . esc_attr( get_post_meta( $id, '_wp_attachment_image_alt', true ) ) . ''; if ( 0 === $i && '' !== $tag ) { echo '' . esc_html( $tag ) . ''; } if ( 0 === $i && $total > 1 ) { echo '1 / ' . (int) $total . ' photos'; } echo '
'; } echo '
'; } } /* ═══ 9. RECORD CARD ══════════════════════════════════════════════════ * The signature element. Price, the facts that matter, verification, and * one clear action — sticky beside the gallery through the whole page, so a * buyer comparing tabs always has the numbers in view. * ═════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_record' ) ) { function up_render_record( array $d ) { if ( '' === trim( (string) ( $d['title'] ?? '' ) ) ) { return; } up_wrap_css(); up_css( 'record', '.up-rec{background:var(--up-surface);border-radius:var(--up-radius-lg);' . 'box-shadow:var(--up-shadow-md);position:sticky;top:96px;overflow:hidden}' . '.up-rec-h{padding:22px 24px 18px}' . '.up-rec h1{font-family:var(--up-font-display);font-size:20px;line-height:1.35;margin:0;' . 'font-weight:600;color:var(--up-ink)}' . '.up-rec-loc{font-size:14px;color:var(--up-mute);margin-top:6px}' . '.up-rec-price{padding:0 24px 6px}' . '.up-rec-amt{font-family:var(--up-font-display);font-size:34px;font-weight:700;' . 'letter-spacing:-.02em;line-height:1.1;color:var(--up-ink)}' . '.up-rec-per{font-size:13.5px;color:var(--up-mute);margin-top:4px}' . '.up-rec-neg{display:inline-block;font-size:12.5px;color:var(--up-accent-ink);' . 'background:var(--up-soft-accent);padding:4px 12px;border-radius:var(--up-radius-pill);margin-top:12px}' . '.up-rec-facts{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:20px 24px 6px}' . '.up-rec-facts div{background:var(--up-surface-warm);border-radius:var(--up-radius-sm);padding:12px 14px}' . '.up-rec-facts b{display:block;font-size:16px;font-weight:600}' . '.up-rec-facts span{font-size:12px;color:var(--up-mute)}' . '.up-rec-verify{margin:16px 24px 0;background:var(--up-soft-green);' . 'border-radius:var(--up-radius-sm);padding:12px 14px;font-size:12.5px;' . 'color:var(--up-verified-ink);display:flex;gap:9px;align-items:flex-start;line-height:1.55}' . '.up-rec-cta{padding:16px 24px 10px;display:grid;gap:10px}' . '.up-btn{border:0;cursor:pointer;border-radius:var(--up-radius-pill);' . 'font:600 15px var(--up-font-body);padding:14px;text-align:center;text-decoration:none;' . 'display:flex;align-items:center;justify-content:center;gap:9px;transition:background .2s ease}' . '.up-btn-primary{background:var(--up-accent);color:var(--up-on-accent)}' . '.up-btn-wa{background:var(--up-whatsapp);color:#fff}' . '.up-btn-ghost{background:var(--up-soft-accent);border:1.5px solid var(--up-accent);' . 'color:var(--up-accent-ink);font-weight:600}' . '.up-rec-by{margin:6px 24px 22px;border-top:1px solid var(--up-line);padding-top:16px;' . 'display:flex;gap:12px;align-items:flex-start}' . '.up-rec-av{width:40px;height:40px;border-radius:var(--up-radius-pill);background:var(--up-accent);' . 'color:var(--up-on-accent);display:grid;place-items:center;font-weight:700;flex:none}' . '.up-rec-by b{font-size:14px;display:block}' . '.up-rec-by span{font-size:12.5px;color:var(--up-mute);line-height:1.5}' . '.up-rec-ver{color:var(--up-verified);font-weight:600}' . '@media(max-width:1000px){.up-rec{position:static}}' ); echo ''; } } /* ═══ 10. SECTION NAV ═════════════════════════════════════════════════ * Every button always visible; a section with content is a link, one without * is dimmed and inert. It lights up on its own as a listing is filled in — * which tells the person entering it what is still missing, for free. * Stores nothing: derived entirely from which sections produced output. * ═════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_section_nav' ) ) { function up_render_section_nav( array $items ) { if ( ! $items ) { return; } up_wrap_css(); up_css( 'secnav', '.up-snav{background:var(--up-surface);border-top:1px solid var(--up-line);' . 'border-bottom:1px solid var(--up-line);position:sticky;top:72px;z-index:30}' . '.up-snav-in{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;' . 'max-width:var(--up-container);margin:0 auto;padding:0 24px}' . '.up-snav-in::-webkit-scrollbar{display:none}' . '.up-snav a{padding:15px 18px;font-size:14px;color:var(--up-mute);text-decoration:none;' . 'white-space:nowrap;border-bottom:3px solid transparent}' . '.up-snav a:hover{color:var(--up-ink)}' . '.up-snav a.on{color:var(--up-accent-ink);border-bottom-color:var(--up-accent);font-weight:600}' . '.up-snav span{padding:15px 18px;font-size:14px;color:var(--up-line);white-space:nowrap;' . 'cursor:default;-webkit-user-select:none;user-select:none}' ); echo ''; } } /* ═══ 11. SPEC TABLES ═════════════════════════════════════════════════ * data: array of groups, each [ 'title' => string, 'rows' => [label=>value] ] * * Hairline rows, not cards. Chopping data into identical rounded boxes is the * generic default and it makes comparison HARDER — which is the one job a * spec table has. Empty hides at BOTH levels: a row with no value does not * render, and a group left with no rows does not render either. That is why a * commercial group never appears on a flat without any conditional here. * ═════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_spec_tables' ) ) { function up_render_spec_tables( array $groups ) { $clean = array(); foreach ( $groups as $g ) { $rows = array(); foreach ( (array) ( $g['rows'] ?? array() ) as $label => $value ) { if ( '' === trim( (string) $value ) ) { continue; } $rows[ $label ] = $value; } if ( $rows ) { $clean[] = array( 'title' => $g['title'] ?? '', 'rows' => $rows ); } } if ( ! $clean ) { return; } up_wrap_css(); up_css( 'specs', '.up-specs{display:grid;grid-template-columns:1fr 1fr;gap:0 56px}' . '.up-spec-g{margin-bottom:26px;break-inside:avoid}' . '.up-spec-g h3{font-size:13px;font-weight:600;color:var(--up-mute);margin:0 0 8px;' . 'font-family:var(--up-font-body)}' . '.up-spec-r{display:flex;justify-content:space-between;gap:20px;padding:10px 0;' . 'border-bottom:1px solid var(--up-line-soft)}' . '.up-spec-r dt{color:var(--up-mute)}' . '.up-spec-r dd{margin:0;font-weight:600;text-align:right}' . '.up-specs dl{margin:0}' . '@media(max-width:900px){.up-specs{grid-template-columns:1fr;gap:0}}' ); echo '
'; $half = (int) ceil( count( $clean ) / 2 ); foreach ( array( array_slice( $clean, 0, $half ), array_slice( $clean, $half ) ) as $col ) { echo '
'; foreach ( $col as $g ) { echo '
'; if ( '' !== $g['title'] ) { echo '

' . esc_html( $g['title'] ) . '

'; } echo '
'; foreach ( $g['rows'] as $label => $value ) { echo '
' . esc_html( $label ) . '
' . '
' . esc_html( $value ) . '
'; } echo '
'; } echo '
'; } echo '
'; } } /* ═══ 12. AMENITY CHIPS ═══════════════════════════════════════════════ * data: array of slug => label, plus an optional emoji map. * The map is GENERATED from the preset's real keys by the caller, never typed * from its labels — 9 of 28 emoji rendered as a diamond on Hiranandani for * exactly that reason. (Key Provenance Rule.) * ═════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_chips' ) ) { function up_render_chips( array $items, array $emoji = array() ) { $items = array_filter( $items, function ( $v ) { return '' !== trim( (string) $v ); } ); if ( ! $items ) { return; } up_wrap_css(); up_css( 'chips', '.up-chips{display:flex;flex-wrap:wrap;gap:9px}' . '.up-chip{background:var(--up-surface-warm);border-radius:var(--up-radius-pill);' . 'padding:9px 16px;font-size:13.5px;color:var(--up-ink);display:flex;gap:8px;align-items:center}' . '.up-chip i{font-style:normal;font-size:15px;line-height:1}' . '.up-chip b{font-weight:600}' ); echo '
'; foreach ( $items as $slug => $label ) { echo ''; if ( isset( $emoji[ $slug ] ) && '' !== $emoji[ $slug ] ) { echo '' . esc_html( $emoji[ $slug ] ) . ''; } echo esc_html( $label ) . ''; } echo '
'; } } /* ═══ 13. ADDRESS BLOCK ═══════════════════════════════════════════════ * The one genuine extraction from Hiranandani — urban_hirp_addr_block() is * already a function and the pattern is identical: a category accordion * beside a static uploaded map with a click-placed pin. * * NO MAP INTEGRATION. No API, no key, no billing, no third-party script. The * pin is two percentages on an image, and the button hands off to Google Maps * — which opens the app on a phone, exactly as the YouTube link does. * ═════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_address' ) ) { function up_render_address( array $d ) { $groups = array_values( array_filter( (array) ( $d['groups'] ?? array() ), function ( $g ) { return ! empty( $g['places'] ); } ) ); $map_id = (int) ( $d['map_id'] ?? 0 ); $maps_url = (string) ( $d['maps_url'] ?? '' ); if ( ! $groups && ! $map_id ) { return; } up_wrap_css(); up_css( 'address', '.up-addr{display:grid;grid-template-columns:1fr 1.15fr;gap:32px;align-items:start}' . '.up-acc details{border-bottom:1px solid var(--up-line-soft)}' . '.up-acc summary{cursor:pointer;padding:14px 0;font-weight:600;list-style:none;' . 'display:flex;justify-content:space-between;align-items:center;font-size:14.5px}' . '.up-acc summary::-webkit-details-marker{display:none}' . '.up-acc summary::after{content:"+";color:var(--up-accent);font-size:18px;font-weight:400}' . '.up-acc details[open] summary::after{content:"–"}' . '.up-acc ul{margin:0 0 14px;padding:0;list-style:none}' . '.up-acc li{display:flex;justify-content:space-between;gap:16px;padding:7px 0;' . 'color:var(--up-mute);font-size:14px}' . '.up-acc li span{color:var(--up-mute);opacity:.75}' . '.up-acc li em{font-style:normal;color:var(--up-verified);font-weight:600}' . '.up-map{border-radius:var(--up-radius-md);overflow:hidden;box-shadow:var(--up-shadow-sm);' . 'background:var(--up-surface)}' . '.up-map-img{position:relative;line-height:0}' . '.up-map-img img{width:100%;height:auto;display:block}' . '.up-pin{position:absolute;width:16px;height:16px;border-radius:50%;background:var(--up-accent);' . 'border:3.5px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.35);transform:translate(-50%,-50%)}' . '.up-maplink{display:flex;align-items:center;justify-content:center;gap:10px;padding:14px 18px;' . 'text-decoration:none;font-size:14.5px;font-weight:600;background:#34A853;color:#fff}' . '.up-maplink:hover{filter:brightness(.94)}' . '@media(max-width:900px){.up-addr{grid-template-columns:1fr;gap:22px}}' ); echo '
'; echo '
'; foreach ( $groups as $i => $g ) { echo ''; echo '' . esc_html( $g['title'] ?? '' ) . '
    '; foreach ( (array) $g['places'] as $p ) { $name = trim( (string) ( $p['name'] ?? '' ) ); if ( '' === $name ) { continue; } echo '
  • ' . esc_html( $name ); if ( ! empty( $p['within'] ) ) { echo 'Within the development'; } elseif ( ! empty( $p['distance'] ) ) { echo '' . esc_html( $p['distance'] ) . ''; } echo '
  • '; } echo '
'; } echo '
'; echo '
'; if ( $map_id ) { $url = wp_get_attachment_image_url( $map_id, 'large' ); if ( $url ) { echo '
Locality map'; $pin = (array) ( $d['pin'] ?? array() ); if ( isset( $pin['x'], $pin['y'] ) && '' !== $pin['x'] ) { echo ''; } echo '
'; } } if ( '' !== $maps_url ) { echo '' . '' . '' . 'Open in Google Maps'; } echo '
'; echo '
'; } } /* ═══ 14. MEDIA LINKS ═════════════════════════════════════════════════ * YouTube and Instagram, in their own brand colours so they read as the * platforms they are. No video is ever hosted here — these hand off, and on * a phone the OS opens the app. Absent URL, absent button. * ═════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_media_links' ) ) { function up_render_media_links( $youtube = '', $instagram = '' ) { $youtube = trim( (string) $youtube ); $instagram = trim( (string) $instagram ); if ( '' === $youtube && '' === $instagram ) { return; } up_wrap_css(); up_css( 'medialinks', '.up-media{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px}' . '.up-media a{display:flex;align-items:center;justify-content:center;gap:9px;padding:13px;' . 'border-radius:var(--up-radius-pill);text-decoration:none;font-weight:600;font-size:14.5px;color:#fff}' . '.up-media .yt{background:#FF0000}' . '.up-media .ig{background:linear-gradient(45deg,#F09433,#E6683C 25%,#DC2743 50%,#CC2366 75%,#BC1888)}' . '.up-media a:hover{filter:brightness(1.07)}' ); echo '
'; if ( '' !== $youtube ) { echo '' . '' . 'Watch the video tour'; } if ( '' !== $instagram ) { echo '' . '' . '' . '' . 'See the reel'; } echo '
'; } } /* ═══ 15. GATE ════════════════════════════════════════════════════════ * A blurred asset behind an enquiry. The library owns the MECHANICS — * blur, veil, lock chip, the data-intent hook. Whether the visitor is * unlocked, and what happens when they ask, belongs to the site: the lead * post type differs per site and the library must never learn one. * ═════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_gate' ) ) { function up_render_gate( array $d ) { $image_id = (int) ( $d['image_id'] ?? 0 ); if ( ! $image_id ) { return; } up_wrap_css(); up_css( 'gate', '.up-gate{position:relative;border-radius:var(--up-radius-md);overflow:hidden;' . 'background:var(--up-surface-warm)}' . '.up-gate img{width:100%;height:auto;display:block}' . '.up-gate.locked img{filter:blur(9px);transform:scale(1.07)}' . '.up-gate-veil{position:absolute;inset:0;display:grid;place-items:center;' . 'background:rgba(250,247,242,.55)}' . '.up-gate-chip{background:var(--up-accent);color:var(--up-on-accent);border:0;cursor:pointer;' . 'border-radius:var(--up-radius-pill);padding:13px 26px;font:600 15px var(--up-font-body);' . 'display:flex;align-items:center;gap:9px;box-shadow:var(--up-shadow-md)}' . '.up-gate-chip:hover{filter:brightness(.95)}' ); $locked = empty( $d['unlocked'] ); $url = wp_get_attachment_image_url( $image_id, 'large' ); if ( ! $url ) { return; } echo '
'; echo '' . esc_attr( $d['alt'] ?? '' ) . ''; if ( $locked ) { echo '
'; } echo '
'; } } /* ═══ 16. LISTING CARDS ═══════════════════════════════════════════════ * Related listings and archive results. Distinct from up_render_project_cards: * a project card carries possession and "onwards" pricing, a listing card * carries a firm price, carpet and floor. Different purchase, different facts. * ═════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_listing_cards' ) ) { function up_render_listing_cards( array $cards, array $head = array() ) { $cards = array_values( array_filter( $cards, function ( $c ) { return '' !== trim( (string) ( $c['title'] ?? '' ) ); } ) ); if ( ! $cards ) { return; } up_wrap_css(); up_css( 'listcards', '.up-lcards{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}' . '.up-lcard{background:var(--up-surface);border-radius:var(--up-radius-md);overflow:hidden;' . 'box-shadow:var(--up-shadow-sm);text-decoration:none;display:block;' . 'transition:transform .2s ease,box-shadow .2s ease}' . '.up-lcard:hover{box-shadow:var(--up-shadow-md);transform:translateY(-3px)}' . '.up-lcard-im{position:relative}' . '.up-lcard img{width:100%;height:160px;object-fit:cover;display:block}' . '.up-lcard-price{position:absolute;left:11px;bottom:11px;background:var(--up-surface);' . 'border-radius:var(--up-radius-sm);padding:6px 12px;font-weight:700;font-size:15.5px;' . 'box-shadow:var(--up-shadow-sm)}' . '.up-lcard-tag{position:absolute;left:11px;top:11px;font-size:11.5px;font-weight:600;' . 'padding:5px 11px;border-radius:var(--up-radius-pill);' . 'background:var(--up-soft-green);color:var(--up-verified)}' . '.up-lcard-b{padding:14px 16px}' . '.up-lcard-b h3{font-family:var(--up-font-display);font-size:16.5px;font-weight:600;' . 'margin:0;line-height:1.4;color:var(--up-ink)}' . '.up-lcard-loc{font-size:13.5px;color:var(--up-mute);margin:3px 0 8px}' . '.up-lcard-meta{font-size:13px;color:var(--up-mute);opacity:.85}' . '@media(max-width:900px){.up-lcards{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;' . 'scrollbar-width:none;margin:0 -16px;padding:0 16px 4px}' . '.up-lcards::-webkit-scrollbar{display:none}' . '.up-lcard{flex:none;width:250px;scroll-snap-align:start}}' ); if ( ! empty( $head['heading'] ) ) { echo '
'; if ( ! empty( $head['eyebrow'] ) ) { echo '' . esc_html( $head['eyebrow'] ) . ''; } echo '

' . wp_kses( $head['heading'], array( 'em' => array() ) ) . '

'; } echo '
'; foreach ( $cards as $c ) { echo ''; echo '
'; if ( ! empty( $c['image'] ) ) { echo ''; } if ( ! empty( $c['tag'] ) ) { echo '' . esc_html( $c['tag'] ) . ''; } if ( ! empty( $c['price'] ) ) { echo '' . esc_html( $c['price'] ) . ''; } echo '
'; echo '

' . esc_html( $c['title'] ) . '

'; if ( ! empty( $c['location'] ) ) { echo '
' . esc_html( $c['location'] ) . '
'; } if ( ! empty( $c['meta'] ) ) { echo '
' . esc_html( $c['meta'] ) . '
'; } echo '
'; } echo '
'; } } /* ═══ 17. PROSE ═══════════════════════════════════════════════════════ * A description. Paragraph breaks on blank lines, escaped throughout — * submitters type this and it must never carry markup onto the page. * ═════════════════════════════════════════════════════════════════════ */ if ( ! function_exists( 'up_render_prose' ) ) { function up_render_prose( $text, $max_ch = 70 ) { $text = trim( (string) $text ); if ( '' === $text ) { return; } up_wrap_css(); up_css( 'prose', '.up-prose{color:var(--up-mute);font-size:16px;line-height:1.7}' . '.up-prose p{margin:0 0 13px}' . '.up-prose p:last-child{margin-bottom:0}' ); echo '
'; foreach ( preg_split( '/\n\s*\n/', $text ) as $para ) { $para = trim( $para ); if ( '' !== $para ) { echo '

' . nl2br( esc_html( $para ) ) . '

'; } } echo '
'; } } https://hiranandani.urbanproperties.co.in/sitemap-core.xml 2026-09-02T11:32:04+00:00 https://hiranandani.urbanproperties.co.in/sitemap-cities.xml 2026-09-02T11:32:04+00:00 https://hiranandani.urbanproperties.co.in/sitemap-townships.xml 2026-09-02T11:32:04+00:00 https://hiranandani.urbanproperties.co.in/sitemap-projects.xml 2026-08-31T08:50:10+00:00 https://hiranandani.urbanproperties.co.in/sitemap-variants.xml 2026-08-31T08:50:10+00:00