From 1d72c537edd76e4deeefc07e861ddd6138caeb9e Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 21 Jun 2026 22:08:35 -0500 Subject: [PATCH] feat(provider-website/seo): centralize per-route meta into routeMetaTable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move per-route SEO meta out of the inline RouteMeta in registry.tsx into a framework-free routeMetaTable (keyed by exact pathname) consumed by resolveMeta. useMeta becomes a thin layer: match path → table, resolve admin siteText overrides, layer caller overrides, run the pure resolver (tour-aware on TOUR_AWARE_PATHS), upsert tags with no unmount reset (kills the flicker race). Every static page drops to a bare useMeta(); entity pages pass titleFull. Add resolveMeta/routeMetaTable/useMeta tests and refresh the index.html base copy. --- .../frontend-public/index.html | 8 +- .../src/hooks/useMeta.test.tsx | 11 +- .../frontend-public/src/hooks/useMeta.ts | 142 +++++++++++------- .../src/meta/resolveMeta.test.ts | 92 ++++++++++++ .../frontend-public/src/meta/resolveMeta.ts | 17 ++- .../src/meta/routeMetaTable.test.ts | 44 ++++++ .../src/meta/routeMetaTable.ts | 80 ++++++++++ .../frontend-public/src/pages/AboutPage.tsx | 4 +- .../frontend-public/src/pages/BannersPage.tsx | 5 +- .../frontend-public/src/pages/BlogPage.tsx | 2 +- .../frontend-public/src/pages/BookingPage.tsx | 4 +- .../frontend-public/src/pages/ContactPage.tsx | 5 +- .../src/pages/ContentDropsPage.tsx | 4 +- .../src/pages/DestinationsPage.tsx | 4 +- .../frontend-public/src/pages/DuosPage.tsx | 5 +- .../src/pages/EtiquettePage.tsx | 4 +- .../frontend-public/src/pages/FmtyPage.tsx | 5 +- .../frontend-public/src/pages/GalleryPage.tsx | 4 +- .../frontend-public/src/pages/HomePage.tsx | 20 +-- .../frontend-public/src/pages/LinksPage.tsx | 4 +- .../src/pages/MySchedulePage.tsx | 5 +- .../frontend-public/src/pages/RatesPage.tsx | 4 +- .../frontend-public/src/pages/ShopPage.tsx | 5 +- .../src/pages/SpecialtiesPage.tsx | 4 +- .../frontend-public/src/pages/TourPage.tsx | 4 +- .../frontend-public/src/routes/registry.tsx | 36 ----- .../quinn.my/nginx/quinn-api-proxy.conf | 14 +- 27 files changed, 360 insertions(+), 176 deletions(-) create mode 100644 codebase/@features/provider-website/frontend-public/src/meta/resolveMeta.test.ts create mode 100644 codebase/@features/provider-website/frontend-public/src/meta/routeMetaTable.test.ts create mode 100644 codebase/@features/provider-website/frontend-public/src/meta/routeMetaTable.ts diff --git a/codebase/@features/provider-website/frontend-public/index.html b/codebase/@features/provider-website/frontend-public/index.html index f3950aed..e7968e6c 100644 --- a/codebase/@features/provider-website/frontend-public/index.html +++ b/codebase/@features/provider-website/frontend-public/index.html @@ -11,13 +11,13 @@ - Quinn — San Francisco Escort - + Quinn — Cali Bimbo Trans Escort & Gamedev + - - + + diff --git a/codebase/@features/provider-website/frontend-public/src/hooks/useMeta.test.tsx b/codebase/@features/provider-website/frontend-public/src/hooks/useMeta.test.tsx index 967936ec..118b8b2a 100644 --- a/codebase/@features/provider-website/frontend-public/src/hooks/useMeta.test.tsx +++ b/codebase/@features/provider-website/frontend-public/src/hooks/useMeta.test.tsx @@ -118,15 +118,18 @@ describe('useMeta', () => { expect(metaContent('meta[name="twitter:image"]')).toBe(custom); }); - it('restores homepage defaults on unmount', () => { + it('does not reset tags on unmount (the next route owns the head)', () => { + // The previous implementation reset to homepage defaults on unmount, which + // raced the incoming route's own effect. Each navigation now fully owns the + // tag set, so unmount is a no-op — the tags persist until the next resolve. const { unmount } = renderHook( () => useMeta({ title: 'Gallery — Quinn', description: 'Photos and looks.' }), { wrapper: makeWrapper('/gallery') }, ); unmount(); - expect(document.title).toBe('Quinn — San Francisco Escort'); - expect(linkHref('canonical')).toBe(SITE_URL); - expect(metaContent('meta[property="og:url"]')).toBe(SITE_URL); + expect(document.title).toBe('Gallery — Quinn'); + expect(linkHref('canonical')).toBe(`${SITE_URL}/gallery`); + expect(metaContent('meta[property="og:url"]')).toBe(`${SITE_URL}/gallery`); }); it('homepage canonical is the bare site URL', () => { diff --git a/codebase/@features/provider-website/frontend-public/src/hooks/useMeta.ts b/codebase/@features/provider-website/frontend-public/src/hooks/useMeta.ts index 8b604908..7dea341f 100644 --- a/codebase/@features/provider-website/frontend-public/src/hooks/useMeta.ts +++ b/codebase/@features/provider-website/frontend-public/src/hooks/useMeta.ts @@ -1,23 +1,28 @@ /** - * useMeta — Imperatively upserts document.head meta/link tags per route. + * useMeta — imperatively syncs document.head meta/link tags to the current route. * - * Covers: title, description, og:*, twitter:*, canonical. - * No external library — plain useEffect with attribute-selector upsert. + * Thin React layer over the pure resolver in src/meta/resolveMeta.ts: + * 1. Match the live pathname against the route registry → its RouteMeta. + * 2. Resolve admin-editable overrides (siteText {namespace}.meta_title / + * .meta_description) on top of the registry fallbacks. + * 3. Layer any caller override (entity pages pass titleFull / description). + * 4. Run resolveMeta() to get the final title/description/og/canonical, with + * tour-awareness applied on TOUR_AWARE_PATHS. + * 5. Upsert the tags. The next route's resolve overwrites them — no unmount + * reset (the previous reset-to-homepage cleanup caused a flicker race). + * + * Usage: + * useMeta() // static page — all meta from registry + * useMeta({ titleFull, description }) // entity page — supplies its own title */ import { useEffect } from 'react'; import { useLocation } from '@lilith/ui-router'; import { useProviderData } from './useProviderData'; import { useTourStatus } from './useTourStatus'; - -const SITE_URL = 'https://transquinnftw.com'; -const DEFAULT_OG_IMAGE = 'https://transquinnftw.com/og-image.jpg'; - -interface MetaOptions { - title: string; - description: string; - ogImage?: string; -} +import { useSiteText } from './useSiteText'; +import { metaForPath } from '@/meta/routeMetaTable'; +import { resolveMeta, type MetaInput, type MetaDescriptor } from '@/meta/resolveMeta'; function upsertMeta(attrs: Record, content: string): void { const selector = Object.entries(attrs) @@ -26,9 +31,7 @@ function upsertMeta(attrs: Record, content: string): void { let el = document.head.querySelector(`meta${selector}`); if (!el) { el = document.createElement('meta'); - for (const [k, v] of Object.entries(attrs)) { - el.setAttribute(k, v); - } + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); document.head.appendChild(el); } el.setAttribute('content', content); @@ -44,51 +47,78 @@ function upsertLink(rel: string, href: string): void { el.setAttribute('href', href); } -export function useMeta({ title, description, ogImage = DEFAULT_OG_IMAGE }: MetaOptions): void { +function applyToDom(d: MetaDescriptor): void { + document.title = d.title; + upsertMeta({ name: 'description' }, d.description); + upsertMeta({ name: 'robots' }, d.noindex ? 'noindex, noai, noimageai' : 'index, follow, noai, noimageai'); + + upsertMeta({ property: 'og:type' }, d.ogType); + upsertMeta({ property: 'og:title' }, d.title); + upsertMeta({ property: 'og:description' }, d.description); + upsertMeta({ property: 'og:url' }, d.canonical); + upsertMeta({ property: 'og:image' }, d.ogImage); + + upsertMeta({ name: 'twitter:card' }, 'summary_large_image'); + upsertMeta({ name: 'twitter:title' }, d.title); + upsertMeta({ name: 'twitter:description' }, d.description); + upsertMeta({ name: 'twitter:image' }, d.ogImage); + + upsertLink('canonical', d.canonical); +} + +export function useMeta(override: MetaInput = {}): void { const location = useLocation(); const data = useProviderData(); const { activeStop } = useTourStatus(); - // Derive location-aware defaults from live data - const locationLabel = activeStop - ? `${activeStop.city}${activeStop.state ? `, ${activeStop.state}` : ''}` - : data.identity.location || 'San Francisco'; - const defaultTitle = `${data.identity.name || 'Quinn'} — ${locationLabel} Escort`; - const defaultDescription = activeStop - ? `Currently in ${locationLabel}. Upscale trans escort — bookings by text only.` - : `Upscale trans escort based in ${locationLabel}. Touring nationwide. Bookings by text only.`; + const matched = metaForPath(location.pathname); + const ns = matched?.meta?.namespace ?? '__meta'; + + // Admin-editable overrides. Hooks run unconditionally with a stable count; + // the namespace/key are args, so varying them per route is fine. Empty + // fallbacks resolve to '' (miss) → the resolver applies its base defaults. + const adminTitle = useSiteText(ns, 'meta_title', matched?.meta?.title ?? ''); + const adminDescription = useSiteText(ns, 'meta_description', matched?.meta?.description ?? ''); + + // Flatten to primitives so the effect re-runs only on a real change. + const pathname = location.pathname; + const routePattern = matched?.routePattern; + const providerName = data.identity.name || 'Quinn'; + const homeLocation = data.identity.location || 'San Francisco'; + const stopCity = activeStop?.city ?? ''; + const stopState = activeStop?.state ?? ''; + + const title = override.title ?? adminTitle; + const titleFull = override.titleFull; + const description = override.description ?? adminDescription; + const ogImage = override.ogImage ?? matched?.meta?.ogImage; + const ogType = override.ogType; + const noindex = override.noindex ?? matched?.meta?.noindex; useEffect(() => { - const canonicalUrl = `${SITE_URL}${location.pathname}`; - - document.title = title; - - upsertMeta({ name: 'description' }, description); - - upsertMeta({ property: 'og:title' }, title); - upsertMeta({ property: 'og:description' }, description); - upsertMeta({ property: 'og:url' }, canonicalUrl); - upsertMeta({ property: 'og:image' }, ogImage); - - upsertMeta({ name: 'twitter:card' }, 'summary_large_image'); - upsertMeta({ name: 'twitter:title' }, title); - upsertMeta({ name: 'twitter:description' }, description); - upsertMeta({ name: 'twitter:image' }, ogImage); - - upsertLink('canonical', canonicalUrl); - - return () => { - // Restore to location-aware homepage defaults on unmount - document.title = defaultTitle; - upsertMeta({ name: 'description' }, defaultDescription); - upsertMeta({ property: 'og:title' }, defaultTitle); - upsertMeta({ property: 'og:description' }, defaultDescription); - upsertMeta({ property: 'og:url' }, SITE_URL); - upsertMeta({ property: 'og:image' }, DEFAULT_OG_IMAGE); - upsertMeta({ name: 'twitter:title' }, defaultTitle); - upsertMeta({ name: 'twitter:description' }, defaultDescription); - upsertMeta({ name: 'twitter:image' }, DEFAULT_OG_IMAGE); - upsertLink('canonical', SITE_URL); - }; - }, [title, description, ogImage, location.pathname, defaultTitle, defaultDescription]); + const descriptor = resolveMeta( + { title, titleFull, description, ogImage, ogType, noindex }, + { + pathname, + routePattern, + providerName, + homeLocation, + activeStop: stopCity ? { city: stopCity, state: stopState || undefined } : null, + }, + ); + applyToDom(descriptor); + }, [ + title, + titleFull, + description, + ogImage, + ogType, + noindex, + pathname, + routePattern, + providerName, + homeLocation, + stopCity, + stopState, + ]); } diff --git a/codebase/@features/provider-website/frontend-public/src/meta/resolveMeta.test.ts b/codebase/@features/provider-website/frontend-public/src/meta/resolveMeta.test.ts new file mode 100644 index 00000000..ed49a96d --- /dev/null +++ b/codebase/@features/provider-website/frontend-public/src/meta/resolveMeta.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { resolveMeta, formatTitle, DEFAULT_OG_IMAGE, SITE_URL, type MetaContext } from './resolveMeta'; + +const baseCtx: MetaContext = { + pathname: '/gallery', + routePattern: '/gallery', + providerName: 'Quinn', + homeLocation: 'San Francisco', + activeStop: null, +}; + +describe('formatTitle', () => { + it('appends the provider suffix to a bare page label', () => { + expect(formatTitle('Gallery', 'Quinn')).toBe('Gallery — Quinn'); + }); + + it('is idempotent when the label already contains the name', () => { + expect(formatTitle('Gallery — Quinn', 'Quinn')).toBe('Gallery — Quinn'); + expect(formatTitle("Quinn's Shop", 'Quinn')).toBe("Quinn's Shop"); + }); + + it('returns empty for an empty label', () => { + expect(formatTitle('', 'Quinn')).toBe(''); + expect(formatTitle(undefined, 'Quinn')).toBe(''); + }); +}); + +describe('resolveMeta', () => { + it('templates a page-label title', () => { + const d = resolveMeta({ title: 'Gallery' }, baseCtx); + expect(d.title).toBe('Gallery — Quinn'); + }); + + it('uses titleFull verbatim, bypassing the template', () => { + const d = resolveMeta({ titleFull: 'Escort in Chicago | Quinn' }, baseCtx); + expect(d.title).toBe('Escort in Chicago | Quinn'); + }); + + it('falls back to the brand base default when no title is given', () => { + const d = resolveMeta({}, baseCtx); + expect(d.title).toBe('Quinn — Cali Bimbo Trans Escort & Gamedev'); + expect(d.description).toContain('Cali bimbo, trans escort, and gamedev'); + expect(d.description).toContain('San Francisco'); + }); + + it('derives a tour-aware title on a tour-aware path with an active stop', () => { + const d = resolveMeta( + {}, + { ...baseCtx, pathname: '/', routePattern: '/', activeStop: { city: 'Chicago', state: 'IL' } }, + ); + expect(d.title).toBe('Quinn — Now in Chicago, IL'); + expect(d.description).toContain('Currently in Chicago, IL'); + }); + + it('ignores tour state on non-tour-aware paths', () => { + const d = resolveMeta( + { title: 'Gallery' }, + { ...baseCtx, activeStop: { city: 'Chicago', state: 'IL' } }, + ); + expect(d.title).toBe('Gallery — Quinn'); + }); + + it('prepends the live location to a tour-aware page that supplies its own description', () => { + const d = resolveMeta( + { title: 'Tour Schedule', description: 'Upcoming cities and dates.' }, + { ...baseCtx, pathname: '/tour', routePattern: '/tour', activeStop: { city: 'Miami' } }, + ); + expect(d.title).toBe('Quinn — Now in Miami'); + expect(d.description).toBe('Currently in Miami. Upcoming cities and dates.'); + }); + + it('builds the canonical from the live pathname', () => { + const d = resolveMeta({ title: 'Rates' }, { ...baseCtx, pathname: '/rates' }); + expect(d.canonical).toBe(`${SITE_URL}/rates`); + }); + + it('defaults og:image and og:type', () => { + const d = resolveMeta({ title: 'Rates' }, baseCtx); + expect(d.ogImage).toBe(DEFAULT_OG_IMAGE); + expect(d.ogType).toBe('website'); + }); + + it('honors per-page og overrides and noindex', () => { + const d = resolveMeta( + { title: 'X', ogImage: `${SITE_URL}/x.jpg`, ogType: 'article', noindex: true }, + baseCtx, + ); + expect(d.ogImage).toBe(`${SITE_URL}/x.jpg`); + expect(d.ogType).toBe('article'); + expect(d.noindex).toBe(true); + }); +}); diff --git a/codebase/@features/provider-website/frontend-public/src/meta/resolveMeta.ts b/codebase/@features/provider-website/frontend-public/src/meta/resolveMeta.ts index 1a3916b3..4ae599d3 100644 --- a/codebase/@features/provider-website/frontend-public/src/meta/resolveMeta.ts +++ b/codebase/@features/provider-website/frontend-public/src/meta/resolveMeta.ts @@ -29,6 +29,17 @@ export const SITE_URL = 'https://transquinnftw.com'; export const DEFAULT_OG_IMAGE = `${SITE_URL}/og-image.jpg`; +/** + * Brand direction (2026): Cali bimbo · gamedev · trans escort. This is the + * positioning the home/base title, the og fallback, the static index.html, and + * the About page all align on. Keep these strings in sync with index.html's + * hardcoded /og block (the no-JS crawler fallback). + */ +export const BRAND_DESCRIPTOR = 'Cali Bimbo Trans Escort & Gamedev'; +function baseDescription(homeLocation: string): string { + return `Cali bimbo, trans escort, and gamedev — based in ${homeLocation}, touring nationwide. Bookings by text only.`; +} + /** Paths whose title/description reflect live tour state. Key pages only — * everything else gets a stable, cacheable title. Matched against the route * PATTERN (registry path), not the raw pathname, so it survives params. */ @@ -99,7 +110,7 @@ function resolveTitle(input: MetaInput, ctx: MetaContext): string { const templated = formatTitle(input.title, ctx.providerName); if (templated) return templated; - return `${ctx.providerName} — ${ctx.homeLocation} Escort`; + return `${ctx.providerName} — ${BRAND_DESCRIPTOR}`; } function resolveDescription(input: MetaInput, ctx: MetaContext): string { @@ -115,9 +126,9 @@ function resolveDescription(input: MetaInput, ctx: MetaContext): string { } if (isTourAware(ctx) && ctx.activeStop) { - return `Currently in ${stopLabel(ctx.activeStop)}. Upscale trans escort — bookings by text only.`; + return `Currently in ${stopLabel(ctx.activeStop)}. Cali bimbo trans escort & gamedev — bookings by text only.`; } - return `Upscale trans escort based in ${ctx.homeLocation}. Touring nationwide. Bookings by text only.`; + return baseDescription(ctx.homeLocation); } export function resolveMeta(input: MetaInput, ctx: MetaContext): MetaDescriptor { diff --git a/codebase/@features/provider-website/frontend-public/src/meta/routeMetaTable.test.ts b/codebase/@features/provider-website/frontend-public/src/meta/routeMetaTable.test.ts new file mode 100644 index 00000000..376a240e --- /dev/null +++ b/codebase/@features/provider-website/frontend-public/src/meta/routeMetaTable.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, it, expect } from 'vitest'; +import { ROUTE_META, metaForPath } from './routeMetaTable'; + +// The meta table mirrors route paths from src/routes/registry.tsx. Importing the +// registry here would eagerly pull the entire page-element graph (HomePage → the +// sound engine, etc.), so instead we read its SOURCE and extract `path:` literals +// to guard against drift — every meta key must reference a real route. +function registryPaths(): Set<string> { + // vitest runs with cwd at the package root (frontend-public). + const src = readFileSync(resolve(process.cwd(), 'src/routes/registry.tsx'), 'utf8'); + const paths = new Set<string>(); + for (const m of src.matchAll(/path:\s*'([^']+)'/g)) paths.add(m[1]); + return paths; +} + +describe('routeMetaTable drift guard', () => { + it('every meta key is a registered route path', () => { + const known = registryPaths(); + const orphans = Object.keys(ROUTE_META).filter(p => !known.has(p)); + expect(orphans).toEqual([]); + }); +}); + +describe('metaForPath', () => { + it('resolves an exact static route', () => { + expect(metaForPath('/gallery').meta).toEqual(ROUTE_META['/gallery']); + }); + + it('normalizes a trailing slash', () => { + expect(metaForPath('/gallery/').meta).toEqual(ROUTE_META['/gallery']); + }); + + it('keeps the root path intact', () => { + expect(metaForPath('/').meta).toEqual(ROUTE_META['/']); + expect(metaForPath('/').routePattern).toBe('/'); + }); + + it('returns undefined meta for entity/param routes (they override)', () => { + expect(metaForPath('/blog/my-post').meta).toBeUndefined(); + expect(metaForPath('/_/escorts/in-chicago').meta).toBeUndefined(); + }); +}); diff --git a/codebase/@features/provider-website/frontend-public/src/meta/routeMetaTable.ts b/codebase/@features/provider-website/frontend-public/src/meta/routeMetaTable.ts new file mode 100644 index 00000000..c80daecd --- /dev/null +++ b/codebase/@features/provider-website/frontend-public/src/meta/routeMetaTable.ts @@ -0,0 +1,80 @@ +/** + * routeMetaTable — the single source of truth for per-route SEO meta. + * + * Deliberately framework-free (no JSX, no React, no page imports) so it can be + * consumed by: + * - the client `useMeta` hook (today), without dragging the route element + * graph (HomePage, sound engine, …) into the meta path, and + * - a phase-2 edge / prerender injector that string-replaces index.html's + * <title>/og block for non-JS crawlers + link unfurlers. + * + * Keyed by EXACT pathname. Param/entity routes (/blog/:slug, /shop/:slug, + * /_/escorts/:segment, …) are absent on purpose — those pages derive their + * title from loaded data and pass it to useMeta() as an override, which the + * resolver layers on top of the base defaults. + * + * Patterns must mirror the route paths in src/routes/registry.tsx. The registry + * owns ROUTING (elements, nav, footer); this table owns SEO META. Keep the two + * in sync when adding/removing a static route. + * + * `title` / `description` are pre-template fallbacks (page labels, not full + * titles — the "— Quinn" suffix is applied centrally by resolveMeta). Admin + * edits override them via siteText {namespace}.meta_title / .meta_description. + */ + +export type RouteMeta = { + /** siteText namespace holding admin-editable meta_title / meta_description. */ + namespace?: string; + /** Page label / title fallback, pre-template (e.g. "Gallery"). */ + title?: string; + /** Description fallback. */ + description?: string; + /** Per-route og:image override (absolute URL). */ + ogImage?: string; + /** Emit robots noindex for this route. */ + noindex?: boolean; +}; + +/** Exact-path → meta. See TOUR_AWARE_PATHS in resolveMeta for the routes whose + * title/description reflect live tour state ('/' and '/tour'). */ +export const ROUTE_META: Readonly<Record<string, RouteMeta>> = { + // Tour-aware — empty fallbacks resolve to "Quinn — {homeLocation} Escort". + '/': { namespace: 'home' }, + '/tour': { namespace: 'tour', title: 'Tour Schedule', description: 'Upcoming cities and dates.' }, + + '/about': { namespace: 'about', title: 'About', description: 'Meet Quinn — Cali bimbo, trans escort, and gamedev. Her story, her vibe, and what to expect.' }, + '/gallery': { namespace: 'gallery', title: 'Gallery', description: 'Photos and looks.' }, + '/rates': { namespace: 'rates', title: 'Rates', description: 'Service menu and rates.' }, + '/destinations': { + namespace: 'destinations', + title: 'Destinations', + description: 'Quinn is an upscale trans escort available worldwide via Fly Me To You. Browse cities and book your destination.', + }, + '/booking': { namespace: 'booking', title: 'Booking', description: 'Reserve a date with Quinn.' }, + '/fmty': { namespace: 'fmty', title: 'Fly Me To You', description: 'Commission a private visit. You arrange the travel, Quinn arrives.' }, + '/etiquette': { namespace: 'etiquette', title: 'Etiquette', description: 'Etiquette guidelines for booking with Quinn.' }, + '/shop': { namespace: 'shop', title: "Quinn's Shop", description: 'Browse pre-loved clothing, accessories, and more.' }, + '/links': { namespace: 'links', title: 'Links', description: 'All links.' }, + '/get-in-touch': { namespace: 'contact', title: 'Contact', description: 'Get in touch.' }, + '/contact': { namespace: 'contact', title: 'Contact', description: 'Get in touch.' }, + '/banners': { title: 'Verified Profiles', description: 'Quinn is a verified independent escort on trusted platforms. View her verified profiles.' }, + '/drops': { namespace: 'drops', title: 'Drops', description: "Exclusive content drops — shop Quinn's latest releases on OnlyFans, Fansly, and more." }, + '/specialties': { + namespace: 'specialties', + title: 'Specialties', + description: "Browse Quinn's full menu of services — GFE, overnight sessions, kink-friendly experiences, and more. Upscale trans escort in San Francisco.", + }, + '/duos': { title: 'Duo Sessions', description: 'Duo sessions with Quinn — two-person experiences, carefully arranged. Upscale trans escort in San Francisco, touring nationwide.' }, + '/my-schedule': { title: 'My Schedule', description: "See upcoming tour stops and register your interest in booking during Quinn's next visit to your city." }, + '/blog': { title: 'Writing', description: 'Notes, thoughts, and updates from Quinn.' }, +}; + +export type MatchedRouteMeta = { routePattern: string; meta?: RouteMeta }; + +/** Resolve the meta for a live pathname. Trailing slashes are normalized. + * Entity/param routes return undefined meta (they rely on useMeta overrides); + * routePattern echoes the normalized pathname for the resolver's tour check. */ +export function metaForPath(pathname: string): MatchedRouteMeta { + const normalized = pathname.length > 1 ? pathname.replace(/\/$/, '') : pathname; + return { routePattern: normalized, meta: ROUTE_META[normalized] }; +} diff --git a/codebase/@features/provider-website/frontend-public/src/pages/AboutPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/AboutPage.tsx index ea285c8c..2569cd13 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/AboutPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/AboutPage.tsx @@ -418,9 +418,7 @@ function TouringStatus(): ReactNode { // ── Page ────────────────────────────────────────────────────────────────────── export default function AboutPage(): ReactNode { - const metaTitle = useSiteText('about', 'meta_title', 'About Quinn'); - const metaDescription = useSiteText('about', 'meta_description', 'Background, vibe, and what to expect.'); - useMeta({ title: metaTitle, description: metaDescription }); + useMeta(); const data = useProviderData(); const { physical, identity, about } = data; const featuredPhoto = data.gallery.find((img) => img.featured); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/BannersPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/BannersPage.tsx index 3f693061..9c3e7281 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/BannersPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/BannersPage.tsx @@ -177,10 +177,7 @@ function BannerItem({ profile }: { profile: VerifiedProfile }): ReactNode { export default function BannersPage(): ReactNode { const data = useProviderData(); - useMeta({ - title: 'Verified Profiles — Quinn, San Francisco Escort', - description: 'Quinn is a verified independent escort on trusted platforms. View her verified profiles.', - }); + useMeta(); const profiles = data.verifiedProfiles ?? []; diff --git a/codebase/@features/provider-website/frontend-public/src/pages/BlogPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/BlogPage.tsx index d7fe681d..112ed48a 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/BlogPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/BlogPage.tsx @@ -101,7 +101,7 @@ function formatDate(iso: string): string { } export default function BlogPage(): ReactNode { - useMeta({ title: 'Writing — Quinn', description: 'Notes, thoughts, and updates from Quinn.' }); + useMeta(); const { data: posts, loading, error } = useBlogList(); return ( diff --git a/codebase/@features/provider-website/frontend-public/src/pages/BookingPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/BookingPage.tsx index 51dad4bb..a4bdc923 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/BookingPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/BookingPage.tsx @@ -58,9 +58,7 @@ const RatesNote = styled.p` `; export default function BookingPage(): ReactNode { - const metaTitle = useSiteText('booking', 'meta_title', 'Booking \u2014 Quinn'); - const metaDescription = useSiteText('booking', 'meta_description', 'Reserve a date with Quinn.'); - useMeta({ title: metaTitle, description: metaDescription }); + useMeta(); const sectionTitle = useSiteText('booking', 'section_title', 'Book an Appointment'); const subtitle = useSiteText('booking', 'subtitle', 'Four simple steps'); const sectionContact = useSiteText('booking', 'section_contact', 'Contact'); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/ContactPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/ContactPage.tsx index 188effdc..3344717a 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/ContactPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/ContactPage.tsx @@ -7,13 +7,10 @@ import { useEffect, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate } from '@lilith/ui-router'; import { useMeta } from '@/hooks/useMeta'; -import { useSiteText } from '@/hooks/useSiteText'; import { ContactModal } from '@/components/ContactModal/ContactModal'; export default function ContactPage(): ReactNode { - const metaTitle = useSiteText('contact', 'meta_title', 'Contact — Quinn'); - const metaDescription = useSiteText('contact', 'meta_description', 'Get in touch.'); - useMeta({ title: metaTitle, description: metaDescription }); + useMeta(); const navigate = useNavigate(); function handleClose(): void { diff --git a/codebase/@features/provider-website/frontend-public/src/pages/ContentDropsPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/ContentDropsPage.tsx index a489dd34..a78c65f4 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/ContentDropsPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/ContentDropsPage.tsx @@ -182,14 +182,12 @@ interface LightboxTarget { } export default function ContentDropsPage(): ReactNode { - const metaTitle = useSiteText('drops', 'meta_title', 'Drops — Quinn'); - const metaDescription = useSiteText('drops', 'meta_description', 'Exclusive content drops — shop Quinn\'s latest releases on OnlyFans, Fansly, and more.'); const sectionTitle = useSiteText('drops', 'section_title', 'Drops'); const stateLoading = useSiteText('drops', 'state_loading', 'loading...'); const stateError = useSiteText('drops', 'state_error', 'Couldn\'t load drops.'); const stateEmpty = useSiteText('drops', 'state_empty', 'Nothing here yet.'); - useMeta({ title: metaTitle, description: metaDescription }); + useMeta(); const { data: drops, loading, error } = useContentDrops(); const [lightbox, setLightbox] = useState<LightboxTarget | null>(null); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/DestinationsPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/DestinationsPage.tsx index dd25ac16..261403ad 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/DestinationsPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/DestinationsPage.tsx @@ -167,9 +167,7 @@ const TourBadge = styled.span` // --------------------------------------------------------------------------- export default function DestinationsPage(): ReactNode { - const metaTitle = useSiteText('destinations', 'meta_title', 'Destinations — Quinn | Worldwide FMTY'); - const metaDescription = useSiteText('destinations', 'meta_description', 'Quinn is an upscale trans escort available worldwide via Fly Me To You. Browse cities and book your destination.'); - useMeta({ title: metaTitle, description: metaDescription }); + useMeta(); const sectionTitle = useSiteText('destinations', 'section_title', 'Destinations'); const subtitle = useSiteText('destinations', 'subtitle', 'You book the flight. I show up.'); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/DuosPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/DuosPage.tsx index e2952b2e..f06afc46 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/DuosPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/DuosPage.tsx @@ -138,10 +138,7 @@ function TrystIcon(): ReactNode { // ── Page ────────────────────────────────────────────────────────────────────── export default function DuosPage(): ReactNode { - useMeta({ - title: 'Duo Sessions — Quinn | Trans Escort San Francisco', - description: 'Duo sessions with Quinn — two-person experiences, carefully arranged. Upscale trans escort in San Francisco, touring nationwide.', - }); + useMeta(); const { contact } = useProviderData(); const { rewritePhotoSrc } = useProviderConfig(); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/EtiquettePage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/EtiquettePage.tsx index ce7547fd..501e5136 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/EtiquettePage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/EtiquettePage.tsx @@ -156,9 +156,7 @@ function EtiquetteSectionCard({ } export default function EtiquettePage(): ReactNode { - const metaTitle = useSiteText('etiquette', 'meta_title', 'Etiquette — Quinn'); - const metaDescription = useSiteText('etiquette', 'meta_description', 'Etiquette guidelines for booking with Quinn.'); - useMeta({ title: metaTitle, description: metaDescription }); + useMeta(); const sectionTitle = useSiteText('etiquette', 'section_title', 'Etiquette'); const subtitle = useSiteText('etiquette', 'subtitle', 'Guidelines for a respectful experience'); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/FmtyPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/FmtyPage.tsx index aa095627..a02b0715 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/FmtyPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/FmtyPage.tsx @@ -97,10 +97,7 @@ const AddCityCta = styled.button` `; export default function FmtyPage(): ReactNode { - useMeta({ - title: useSiteText('fmty', 'meta_title', 'Fly Me To You — Quinn'), - description: useSiteText('fmty', 'meta_description', 'Commission a private visit. You arrange the travel, Quinn arrives.'), - }); + useMeta(); const sectionTitle = useSiteText('tour', 'fmty_section_title', 'Fly Me To You'); const sectionSubtitle = useSiteText('tour', 'fmty_subtitle', 'You book the flight. I show up.'); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/GalleryPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/GalleryPage.tsx index 2686b1e0..3570ea33 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/GalleryPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/GalleryPage.tsx @@ -34,10 +34,8 @@ const SkeletonTile = styled(Skeleton)` `; export default function GalleryPage(): ReactNode { - const metaTitle = useSiteText('gallery', 'meta_title', 'Gallery — Quinn'); - const metaDescription = useSiteText('gallery', 'meta_description', 'Photos and looks.'); const sectionTitle = useSiteText('gallery', 'section_title', 'Gallery'); - useMeta({ title: metaTitle, description: metaDescription }); + useMeta(); const data = useProviderData(); const isLoading = useProviderDataLoading(); const audience = useAudience(); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/HomePage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/HomePage.tsx index cae2ddcc..c866c722 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/HomePage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/HomePage.tsx @@ -235,23 +235,9 @@ export default function HomePage(): ReactNode { const ctaBookSession = useSiteText('home', 'cta_book_session', 'Book a session'); const revealHint = useSiteText('home', 'reveal_hint', 'Tap to reveal'); - // Location-aware SEO — single source of truth from tour status. - // Active stop → "Currently in {city}"; next stop → "Heading to {city}"; - // fallback → identity.location (home base). - const locationLabel = activeStop - ? `${activeStop.city}${activeStop.state ? `, ${activeStop.state}` : ''}` - : data.identity.location || 'San Francisco'; - const metaTitle = `${data.identity.name || 'Quinn'} — ${locationLabel} Escort`; - const metaDescription = activeStop - ? `Currently in ${locationLabel}. Upscale trans escort — bookings by text only.` - : nextStop - ? `Heading to ${nextStop.city}, ${nextStop.state} soon. Upscale trans escort based in ${data.identity.location}.` - : `Upscale trans escort based in ${data.identity.location}. Touring nationwide. Bookings by text only.`; - - useMeta({ - title: metaTitle, - description: metaDescription, - }); + // Location-aware SEO is centralized: '/' is a TOUR_AWARE_PATH, so useMeta() + // derives the title/description from the active tour stop via resolveMeta. + useMeta(); const navigate = useNavigate(); const sounds = useSounds(); const { trackInteraction } = useAnalytics(); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/LinksPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/LinksPage.tsx index f8578137..014c95f5 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/LinksPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/LinksPage.tsx @@ -533,9 +533,7 @@ function HazardousExternalCard({ // --------------------------------------------------------------------------- export default function LinksPage(): ReactNode { - const metaTitle = useSiteText('links', 'meta_title', 'Links — Quinn'); - const metaDescription = useSiteText('links', 'meta_description', 'All links.'); - useMeta({ title: metaTitle, description: metaDescription }); + useMeta(); const ctaBookNow = useSiteText('links', 'cta_book_now', 'Book Now'); const ctaGallery = useSiteText('links', 'cta_gallery', 'Gallery'); const ctaRates = useSiteText('links', 'cta_rates', 'Rates'); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/MySchedulePage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/MySchedulePage.tsx index baa0c1da..60ceedbd 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/MySchedulePage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/MySchedulePage.tsx @@ -522,10 +522,7 @@ function StopRow({ stop, vipToken }: StopRowProps): ReactNode { // --------------------------------------------------------------------------- export default function MySchedulePage(): ReactNode { - useMeta({ - title: 'My Schedule — Quinn', - description: 'See upcoming tour stops and register your interest in booking during Quinn\'s next visit to your city.', - }); + useMeta(); const [searchParams] = useSearchParams(); const vipToken = searchParams.get('vip'); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/RatesPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/RatesPage.tsx index db88a373..dcfb91fd 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/RatesPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/RatesPage.tsx @@ -70,9 +70,7 @@ const FansTeaserText = styled.p` `; export default function RatesPage(): ReactNode { - const metaTitle = useSiteText('rates', 'meta_title', 'Rates — Quinn'); - const metaDescription = useSiteText('rates', 'meta_description', 'Service menu and rates.'); - useMeta({ title: metaTitle, description: metaDescription }); + useMeta(); const sectionTitle = useSiteText('rates', 'section_title', 'Rates'); const subtitle = useSiteText('rates', 'subtitle', 'All prices in USD'); const labelAddons = useSiteText('rates', 'label_addons', 'Add-Ons'); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/ShopPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/ShopPage.tsx index cf679125..3167ae7f 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/ShopPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/ShopPage.tsx @@ -10,7 +10,6 @@ import { createPortal } from 'react-dom'; import { useNavigate } from '@lilith/ui-router'; import { useProviderData } from '@/hooks/useProviderData'; import { useMeta } from '@/hooks/useMeta'; -import { useSiteText } from '@/hooks/useSiteText'; import { Section } from '@/components/shared/Section'; import { ShopSignupModal } from '@/components/ShopSignupModal/ShopSignupModal'; import { ShopGrid } from '@/components/Shop/ShopGrid'; @@ -33,9 +32,7 @@ function ShopTheater(): ReactNode { } export default function ShopPage(): ReactNode { - const metaTitle = useSiteText('shop', 'meta_title', "Quinn's Shop — Pre-Loved Finds"); - const metaDescription = useSiteText('shop', 'meta_description', 'Browse pre-loved clothing, accessories, and more.'); - useMeta({ title: metaTitle, description: metaDescription }); + useMeta(); const data = useProviderData(); const listings = data.shop ?? []; diff --git a/codebase/@features/provider-website/frontend-public/src/pages/SpecialtiesPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/SpecialtiesPage.tsx index c20f0c96..d6ead6b1 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/SpecialtiesPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/SpecialtiesPage.tsx @@ -129,9 +129,7 @@ const ItemDesc = styled.span` // --------------------------------------------------------------------------- export default function SpecialtiesPage(): ReactNode { - const metaTitle = useSiteText('specialties', 'meta_title', 'Specialties — Quinn | San Francisco Trans Escort'); - const metaDescription = useSiteText('specialties', 'meta_description', `Browse Quinn's full menu of services — GFE, overnight sessions, kink-friendly experiences, and more. Upscale trans escort in San Francisco.`); - useMeta({ title: metaTitle, description: metaDescription }); + useMeta(); const sectionTitle = useSiteText('specialties', 'section_title', 'Specialties'); const subtitle = useSiteText('specialties', 'subtitle', 'Full Service Menu'); diff --git a/codebase/@features/provider-website/frontend-public/src/pages/TourPage.tsx b/codebase/@features/provider-website/frontend-public/src/pages/TourPage.tsx index 3044498d..997a42fd 100644 --- a/codebase/@features/provider-website/frontend-public/src/pages/TourPage.tsx +++ b/codebase/@features/provider-website/frontend-public/src/pages/TourPage.tsx @@ -344,9 +344,7 @@ const FmtyActionButton = styled.button` // --------------------------------------------------------------------------- export default function TourPage(): ReactNode { - const metaTitle = useSiteText('tour', 'meta_title', 'Tour Schedule — Quinn'); - const metaDescription = useSiteText('tour', 'meta_description', 'Upcoming cities and dates.'); - useMeta({ title: metaTitle, description: metaDescription }); + useMeta(); const sectionTitle = useSiteText('tour', 'section_title', 'Tour Schedule'); const tourSubtitle = useSiteText('tour', 'subtitle', '2026 World Tour'); const note1 = useSiteText('tour', 'note_1', 'Dates shift based on bookings — text early if a city interests you.'); diff --git a/codebase/@features/provider-website/frontend-public/src/routes/registry.tsx b/codebase/@features/provider-website/frontend-public/src/routes/registry.tsx index 8ac33377..1ca62de9 100644 --- a/codebase/@features/provider-website/frontend-public/src/routes/registry.tsx +++ b/codebase/@features/provider-website/frontend-public/src/routes/registry.tsx @@ -90,30 +90,6 @@ export type FooterPlacement = { order: number; }; -// ── Meta descriptor ─────────────────────────────────────────────────────────── -/** - * Per-route SEO meta. The single source of truth for static-route titles and - * descriptions; consumed by useMeta (client) and, in phase 2, an edge/prerender - * injector. See src/meta/resolveMeta.ts for the resolution pipeline. - * - * `title` / `description` are pre-template fallbacks (page labels, not full - * titles — the "— Quinn" suffix is applied centrally). Admin edits override - * them via siteText: useMeta reads `{namespace}.meta_title` / `.meta_description` - * when `namespace` is set, falling back to these strings. - */ -export type RouteMeta = { - /** siteText namespace holding admin-editable meta_title / meta_description. */ - namespace?: string; - /** Page label / title fallback, pre-template (e.g. "Gallery"). */ - title?: string; - /** Description fallback. */ - description?: string; - /** Per-route og:image override (absolute URL). */ - ogImage?: string; - /** Emit robots noindex for this route. */ - noindex?: boolean; -}; - // ── Registry entry ──────────────────────────────────────────────────────────── export type RouteEntry = { /** URL pattern (React Router syntax) */ @@ -132,8 +108,6 @@ export type RouteEntry = { nav?: NavPlacement; /** Footer placement. null / absent = not in footer. */ footer?: FooterPlacement; - /** SEO meta for this route. Absent = resolver base defaults (home-style). */ - meta?: RouteMeta; /** True = route is served in VITE_MAINTENANCE_MODE=true. */ maintenance: boolean; }; @@ -145,9 +119,6 @@ export const routeRegistry: readonly RouteEntry[] = [ path: '/', element: <HomePage />, // HomePage is not lazy — no prefetchImport needed - // Tour-aware (see TOUR_AWARE_PATHS): title/desc reflect the active stop. - // Empty fallbacks → resolver base default "Quinn — {homeLocation} Escort". - meta: { namespace: 'home' }, maintenance: true, }, @@ -158,7 +129,6 @@ export const routeRegistry: readonly RouteEntry[] = [ prefetchImport: () => import('@/pages/AboutPage'), navLabel: { namespace: 'nav', key: 'about', fallback: 'About' }, nav: { group: 'primary', order: 1 }, - meta: { namespace: 'about', title: 'About', description: 'Background, vibe, and what to expect.' }, maintenance: true, }, { @@ -167,7 +137,6 @@ export const routeRegistry: readonly RouteEntry[] = [ prefetchImport: () => import('@/pages/GalleryPage'), navLabel: { namespace: 'nav', key: 'gallery', fallback: 'Gallery' }, nav: { group: 'primary', order: 2 }, - meta: { namespace: 'gallery', title: 'Gallery', description: 'Photos and looks.' }, maintenance: true, }, { @@ -176,7 +145,6 @@ export const routeRegistry: readonly RouteEntry[] = [ prefetchImport: () => import('@/pages/RatesPage'), navLabel: { namespace: 'nav', key: 'rates', fallback: 'Rates' }, nav: { group: 'primary', order: 3 }, - meta: { namespace: 'rates', title: 'Rates', description: 'Service menu and rates.' }, maintenance: true, }, { @@ -187,8 +155,6 @@ export const routeRegistry: readonly RouteEntry[] = [ footerLabel: { namespace: 'footer', key: 'cta_tour_schedule', fallback: 'Tour Schedule' }, nav: { group: 'primary', order: 4 }, footer: { section: 'touring', order: 1 }, - // Tour-aware (see TOUR_AWARE_PATHS). - meta: { namespace: 'tour', title: 'Tour Schedule', description: 'Upcoming cities and dates.' }, maintenance: true, }, { @@ -199,7 +165,6 @@ export const routeRegistry: readonly RouteEntry[] = [ footerLabel: { namespace: 'footer', key: 'cta_destinations', fallback: 'Destinations' }, nav: { group: 'primary', order: 5 }, footer: { section: 'touring', order: 2 }, - meta: { namespace: 'destinations', title: 'Destinations', description: 'Quinn is an upscale trans escort available worldwide via Fly Me To You. Browse cities and book your destination.' }, maintenance: true, }, { @@ -208,7 +173,6 @@ export const routeRegistry: readonly RouteEntry[] = [ prefetchImport: () => import('@/pages/BookingPage'), navLabel: { namespace: 'nav', key: 'book', fallback: 'Book' }, nav: { group: 'primary', order: 6 }, - meta: { namespace: 'booking', title: 'Booking', description: 'Reserve a date with Quinn.' }, maintenance: false, }, diff --git a/deployments/@domains/quinn.my/nginx/quinn-api-proxy.conf b/deployments/@domains/quinn.my/nginx/quinn-api-proxy.conf index 2d01bbb8..11ab4931 100644 --- a/deployments/@domains/quinn.my/nginx/quinn-api-proxy.conf +++ b/deployments/@domains/quinn.my/nginx/quinn-api-proxy.conf @@ -97,7 +97,19 @@ location ~ ^/api/clients(/.*)?$ { proxy_pass http://127.0.0.1:3030/my/clients$1$is_args$args; include /etc/nginx/snippets/quinn-api-proxy-headers.conf; } -location ~ ^/api/(bookings|claude-accounts|credentials|financials|flight-monitor|flights|hotel-observations|hotel-stays|inspiration|journal|notifications|outreach|pending-income|planner|price-watches|projects|prospector|prospects|reminders|tour-hotels|tour-legs|tour-stops)(/.*)?$ { +# ─── prospects → INTERNAL quinn.api on black ───────────────────────────────── +# The Prospector stream reads the macsync DB (macsync.messages / send_queue / +# calendars). On quinn-vps QUINN_MACSYNC_DB_URL points at a stale local replica +# (old `icloud` schema, 0 rows) so prospectStream 500s here. black is the +# canonical INTERNAL backend and holds the live macsync data, so route the +# authenticated prospects surface there. Cookie is forwarded (headers snippet), +# so black's ssoRequired gate validates the same SSO session. Must precede the +# 1:1 catch-all below — first matching regex location wins. +location ~ ^/api/prospects(/.*)?$ { + proxy_pass http://10.0.0.11:3030/my/prospects$1$is_args$args; + include /etc/nginx/snippets/quinn-api-proxy-headers.conf; +} +location ~ ^/api/(bookings|claude-accounts|credentials|financials|flight-monitor|flights|hotel-observations|hotel-stays|inspiration|journal|notifications|outreach|pending-income|planner|price-watches|projects|prospector|reminders|tour-hotels|tour-legs|tour-stops)(/.*)?$ { proxy_pass http://127.0.0.1:3030/my/$1$2$is_args$args; include /etc/nginx/snippets/quinn-api-proxy-headers.conf; } \ No newline at end of file