feat(provider-website/seo): centralize per-route meta into routeMetaTable

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.
This commit is contained in:
Natalie 2026-06-21 22:08:35 -05:00
parent 65a19af135
commit 1d72c537ed
27 changed files with 360 additions and 176 deletions

View file

@ -11,13 +11,13 @@
<!-- Privacy -->
<meta name="referrer" content="no-referrer" />
<title>Quinn — San Francisco Escort</title>
<meta name="description" content="Quinn — upscale trans escort based in San Francisco. Touring nationwide. Bookings by text only." />
<title>Quinn — Cali Bimbo Trans Escort & Gamedev</title>
<meta name="description" content="Quinn — Cali bimbo, trans escort, and gamedev. Based in San Francisco, touring nationwide. Bookings by text only." />
<!-- Open Graph -->
<meta property="og:type" content="profile" />
<meta property="og:title" content="Quinn — San Francisco Escort" />
<meta property="og:description" content="Upscale trans escort based in San Francisco. Touring nationwide." />
<meta property="og:title" content="Quinn — Cali Bimbo Trans Escort & Gamedev" />
<meta property="og:description" content="Cali bimbo, trans escort, and gamedev. Touring nationwide." />
<meta property="og:image" content="https://transquinnftw.com/og-image.jpg" />
<!-- Default canonical — useMeta overwrites per route after JS hydration -->

View file

@ -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', () => {

View file

@ -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<string, string>, content: string): void {
const selector = Object.entries(attrs)
@ -26,9 +31,7 @@ function upsertMeta(attrs: Record<string, string>, content: string): void {
let el = document.head.querySelector<HTMLMetaElement>(`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,
]);
}

View file

@ -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);
});
});

View file

@ -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 <title>/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 {

View file

@ -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();
});
});

View file

@ -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] };
}

View file

@ -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);

View file

@ -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 ?? [];

View file

@ -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 (

View file

@ -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');

View file

@ -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 {

View file

@ -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);

View file

@ -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.');

View file

@ -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();

View file

@ -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');

View file

@ -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.');

View file

@ -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();

View file

@ -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();

View file

@ -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');

View file

@ -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');

View file

@ -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');

View file

@ -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 ?? [];

View file

@ -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');

View file

@ -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.');

View file

@ -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,
},

View file

@ -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;
}