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:
parent
65a19af135
commit
1d72c537ed
27 changed files with 360 additions and 176 deletions
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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] };
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue