- add site-settings singleton to admin registry + schema + migration - add editor config + route + nav in admin frontend - surface defaultSiteTheme via data-api serialize + shared types + validator - carry through api /www/provider-config (the public edge-cached path on vps0) - remove DEFAULT_SITE_THEME hardcode; ultimate fallback luxe-dark; registry comments updated for admin-driven live selector - live bootstrap in quinn.www root + data hook to pick admin default without quinn.www rebuild (chrome + tokens update post-fetch) - fixed incidental sortable test assertion to match current registry (pre-existing mismatch) - other public hardcodes remain in deployment configs; see analysis This makes the visitor-facing default theme choice Quinn-editable via admin UI and flows as public data through the quinn.api public surface (edge cacheable).
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
import { getCurrentVisit } from '@/entities/city-visit';
|
|
import { getProviderProfile } from '@/entities/provider-profile';
|
|
import { listDestinations } from '@/entities/destination';
|
|
import { listGalleryItems } from '@/entities/gallery-item';
|
|
import { listHeroStripItems } from '@/entities/hero-strip';
|
|
import { listLoreSections } from '@/entities/lore-section';
|
|
import { listPaymentMethods } from '@/entities/payment-method';
|
|
import { assembleProviderRates } from '@/entities/rate-card';
|
|
import { listRosterContent } from '@/entities/roster-content';
|
|
import { listShopListings } from '@/entities/shop-listing';
|
|
import { assembleSiteContent } from '@/entities/site-text';
|
|
import { listSpecialtyCategories_withItems } from '@/entities/specialty';
|
|
import { listTourStops } from '@/entities/tour-stop';
|
|
import { listVerifiedProfiles } from '@/entities/verified-profile';
|
|
import type { Sql } from '@/shared/db';
|
|
import { logger } from '@/shared/logger';
|
|
|
|
import type { ProviderData, ProviderTourStop } from './types';
|
|
|
|
interface CacheEntry {
|
|
readonly data: ProviderData;
|
|
readonly expires: number;
|
|
}
|
|
|
|
const providerConfigCache = new Map<string, CacheEntry>();
|
|
|
|
export function resetProviderConfigCache(): void {
|
|
providerConfigCache.clear();
|
|
}
|
|
|
|
export interface AssembleProviderConfigInput {
|
|
readonly db: Sql;
|
|
readonly dataApiUrl: string;
|
|
readonly dataApiToken?: string | undefined;
|
|
readonly providerSlug: string;
|
|
readonly today: string;
|
|
}
|
|
|
|
async function fetchFromDataApi(
|
|
dataApiUrl: string,
|
|
providerSlug: string,
|
|
dataApiToken?: string | undefined,
|
|
): Promise<ProviderData | null> {
|
|
const cacheKey = providerSlug;
|
|
const now = Date.now();
|
|
const cached = providerConfigCache.get(cacheKey);
|
|
if (cached && now < cached.expires) return cached.data;
|
|
|
|
const url = `${dataApiUrl}/api/data`;
|
|
const headers: Record<string, string> = {};
|
|
if (dataApiToken) {
|
|
headers['Authorization'] = `Bearer ${dataApiToken}`;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: 'GET',
|
|
headers,
|
|
signal: AbortSignal.timeout(5_000),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
logger.warn('data-api provider-config fetch failed', { status: res.status, providerSlug });
|
|
return null;
|
|
}
|
|
|
|
const data = (await res.json()) as ProviderData;
|
|
providerConfigCache.set(cacheKey, { data, expires: now + 60_000 });
|
|
return data;
|
|
} catch (err) {
|
|
logger.warn('data-api provider-config fetch error', { error: String(err), providerSlug });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function buildDegradedShape(providerSlug: string): ProviderData {
|
|
return {
|
|
identity: {
|
|
name: providerSlug,
|
|
pronouns: '',
|
|
gender: '',
|
|
location: '',
|
|
secondaryLocations: [],
|
|
languages: [],
|
|
tagline: '',
|
|
},
|
|
physical: {
|
|
age: '',
|
|
height: '',
|
|
bodyType: '',
|
|
ethnicity: '',
|
|
hairColor: '',
|
|
hairLength: '',
|
|
eyeColor: '',
|
|
cupSize: '',
|
|
additional: {},
|
|
},
|
|
rates: [],
|
|
addOns: { entries: [] },
|
|
travelFees: { entries: [] },
|
|
touringPackages: { entries: [] },
|
|
onlineServices: { entries: [] },
|
|
tour: [],
|
|
currentLocation: null,
|
|
gallery: [],
|
|
contact: {
|
|
phone: '',
|
|
communicationNote: '',
|
|
responseTime: '',
|
|
paymentMethods: [],
|
|
},
|
|
policies: [],
|
|
about: {
|
|
bio: '',
|
|
personality: [],
|
|
availableFor: [],
|
|
availableTo: [],
|
|
},
|
|
// defaultSiteTheme omitted — falls back to deployment DEFAULT when absent
|
|
};
|
|
}
|
|
|
|
export async function assembleProviderConfig(
|
|
input: AssembleProviderConfigInput,
|
|
): Promise<ProviderData> {
|
|
const base = await fetchFromDataApi(input.dataApiUrl, input.providerSlug, input.dataApiToken);
|
|
const shape = base ?? buildDegradedShape(input.providerSlug);
|
|
|
|
const nativeProfile = await getProviderProfile(input.db, input.providerSlug);
|
|
const identityOverride = nativeProfile ? nativeProfile.identity : shape.identity;
|
|
const physicalOverride = nativeProfile ? nativeProfile.physical : shape.physical;
|
|
|
|
const nativeTourStops = await listTourStops(input.db, {
|
|
providerSlug: input.providerSlug,
|
|
publicOnly: true,
|
|
});
|
|
|
|
const today = input.today;
|
|
const tour: ProviderTourStop[] = nativeTourStops.map((s) => {
|
|
// null endDate = open-ended (ongoing): never "completed", active once started.
|
|
const timeStatus: 'upcoming' | 'active' | 'completed' =
|
|
today < s.startDate ? 'upcoming' : s.endDate != null && today > s.endDate ? 'completed' : 'active';
|
|
return {
|
|
city: s.city,
|
|
state: s.state,
|
|
startDate: s.startDate,
|
|
endDate: s.endDate,
|
|
status: timeStatus,
|
|
bookingStatus: s.status,
|
|
lat: s.lat,
|
|
lng: s.lng,
|
|
...(s.destinationSlug && { destinationSlug: s.destinationSlug }),
|
|
...(s.pricingTiers && {
|
|
pricingTiers: {
|
|
...(s.pricingTiers.incall !== undefined && { incall: s.pricingTiers.incall }),
|
|
...(s.pricingTiers.outcall !== undefined && { outcall: s.pricingTiers.outcall }),
|
|
...(s.pricingTiers.overnight !== undefined && { overnight: s.pricingTiers.overnight }),
|
|
},
|
|
}),
|
|
...(s.notes !== '' && { notes: s.notes }),
|
|
};
|
|
});
|
|
|
|
const cityVisit = await getCurrentVisit(input.db, input.providerSlug);
|
|
const currentLocation = cityVisit
|
|
? {
|
|
city: cityVisit.city,
|
|
state: cityVisit.state,
|
|
country: cityVisit.country,
|
|
incallAvailable: cityVisit.incallAvailable,
|
|
}
|
|
: null;
|
|
|
|
const nativePaymentMethods = await listPaymentMethods(input.db, {
|
|
providerSlug: input.providerSlug,
|
|
visibility: 'public',
|
|
});
|
|
|
|
const paymentMethodsOverride: import('../../../../provider-website/shared/src/types').PaymentMethod[] | null =
|
|
nativePaymentMethods.length > 0
|
|
? nativePaymentMethods.map((pm) => {
|
|
const isUrlKind = pm.kind === 'gift_card' || pm.kind === 'wishlist';
|
|
const base: import('../../../../provider-website/shared/src/types').PaymentMethod = {
|
|
method: pm.label,
|
|
};
|
|
if (isUrlKind) {
|
|
return { ...base, url: pm.value };
|
|
}
|
|
return { ...base, handle: pm.value };
|
|
})
|
|
: null;
|
|
|
|
const nativeRates = await assembleProviderRates(input.db, input.providerSlug);
|
|
|
|
// Per-city rate selection. When the data-api base shape exposes city-tagged
|
|
// bundles, serve the one for the active city — Quinn's real whereabouts
|
|
// (currentLocation from city_visits) when touring, else her home incallCity,
|
|
// else the null "default" card. The chosen bundle collapses into the flat
|
|
// rate fields the frontend already renders, so switching is invisible to it.
|
|
const activeRateCity = currentLocation?.city ?? identityOverride.incallCity ?? null;
|
|
const cityBundles = shape.rateCardsByCity;
|
|
const selectedCityBundle =
|
|
cityBundles && cityBundles.length > 0
|
|
? (cityBundles.find((b) => b.city === activeRateCity) ??
|
|
cityBundles.find((b) => b.city === identityOverride.incallCity) ??
|
|
cityBundles.find((b) => b.city === null) ??
|
|
cityBundles[0])
|
|
: undefined;
|
|
|
|
const ratesOverride = nativeRates ?? (selectedCityBundle
|
|
? {
|
|
rates: selectedCityBundle.rates,
|
|
addOns: selectedCityBundle.addOns,
|
|
travelFees: selectedCityBundle.travelFees,
|
|
touringPackages: selectedCityBundle.touringPackages,
|
|
onlineServices: selectedCityBundle.onlineServices,
|
|
}
|
|
: {
|
|
rates: shape.rates,
|
|
addOns: shape.addOns,
|
|
travelFees: shape.travelFees,
|
|
touringPackages: shape.touringPackages,
|
|
onlineServices: shape.onlineServices,
|
|
});
|
|
|
|
const nativeSiteContent = await assembleSiteContent(input.db, input.providerSlug);
|
|
const aboutOverride = nativeSiteContent?.about ?? shape.about;
|
|
const etiquetteOverride = nativeSiteContent ? nativeSiteContent.etiquette : shape.etiquette;
|
|
const policiesOverride = nativeSiteContent ? nativeSiteContent.policies : shape.policies;
|
|
const siteTextOverride = nativeSiteContent ? nativeSiteContent.siteText : shape.siteText;
|
|
|
|
const nativeDestinations = await listDestinations(input.db, {
|
|
providerSlug: input.providerSlug,
|
|
visibility: 'public',
|
|
});
|
|
|
|
const destinationsOverride = nativeDestinations.length > 0
|
|
? nativeDestinations.map((d) => {
|
|
const mapped: import('../../../../provider-website/shared/src/types').Destination = {
|
|
slug: d.slug,
|
|
city: d.city,
|
|
country: d.country,
|
|
...(d.region !== null ? { region: d.region } : {}),
|
|
fmtyTier: d.fmtyTier,
|
|
metaTitle: d.metaTitle,
|
|
metaDescription: d.metaDescription,
|
|
headline: d.headline,
|
|
intro: d.intro,
|
|
...(d.linkedTourStop ? { linkedTourStop: d.linkedTourStop } : {}),
|
|
...(d.experiences.length > 0 ? { experiences: [...d.experiences] } : {}),
|
|
...(d.note !== null ? { note: d.note } : {}),
|
|
...(d.illustrationImage !== null ? { illustrationImage: d.illustrationImage } : {}),
|
|
...(d.illustrationSide !== null
|
|
? { illustrationSide: d.illustrationSide as 'left' | 'right' }
|
|
: {}),
|
|
...(d.illustrationHeight !== null ? { illustrationHeight: d.illustrationHeight } : {}),
|
|
...(d.illustrationOpacity !== null ? { illustrationOpacity: d.illustrationOpacity } : {}),
|
|
...(d.lat !== null ? { lat: d.lat } : {}),
|
|
...(d.lng !== null ? { lng: d.lng } : {}),
|
|
};
|
|
return mapped;
|
|
})
|
|
: shape.destinations;
|
|
|
|
const nativeSpecialtiesRaw = await listSpecialtyCategories_withItems(input.db, input.providerSlug);
|
|
const specialtiesOverride = nativeSpecialtiesRaw ? [...nativeSpecialtiesRaw] : shape.specialties;
|
|
|
|
const nativeShopListings = await listShopListings(input.db, {
|
|
providerSlug: input.providerSlug,
|
|
availableOnly: false,
|
|
});
|
|
|
|
const shopOverride = nativeShopListings.length > 0
|
|
? nativeShopListings.map((s) => {
|
|
const mapped: import('../../../../provider-website/shared/src/types').ShopListing = {
|
|
id: s.id,
|
|
slug: s.slug,
|
|
title: s.title,
|
|
description: s.description,
|
|
price: s.price,
|
|
currency: s.currency,
|
|
condition: s.condition,
|
|
category: s.category,
|
|
...(s.size !== null ? { size: s.size } : {}),
|
|
status: s.status,
|
|
photos: s.photos as import('../../../../provider-website/shared/src/types').ShopListingPhoto[],
|
|
};
|
|
return mapped;
|
|
})
|
|
: shape.shop;
|
|
|
|
const nativeRosterContent = await listRosterContent(input.db, { providerSlug: input.providerSlug });
|
|
const rosterContentOverride = nativeRosterContent.length > 0
|
|
? nativeRosterContent.map((r) => {
|
|
const mapped: import('../../../../provider-website/shared/src/types').RosterTrackContent = {
|
|
slug: r.trackSlug,
|
|
name: r.name,
|
|
metaTitle: r.metaTitle,
|
|
metaDescription: r.metaDescription,
|
|
heroLine: r.heroLine,
|
|
description: [...r.description],
|
|
whatToExpect: [...r.whatToExpect],
|
|
interestsConfig: r.interestsConfig.map((i) => ({ value: i.value, label: i.label })),
|
|
};
|
|
return mapped;
|
|
})
|
|
: shape.rosterContent;
|
|
|
|
const nativeLoreSections = await listLoreSections(input.db, { providerSlug: input.providerSlug });
|
|
const cultOfLilithOverride = nativeLoreSections.length > 0
|
|
? nativeLoreSections.map((l) => {
|
|
const mapped: import('../../../../provider-website/shared/src/types').CultOfLilithSection = {
|
|
sectionKey: l.sectionKey,
|
|
title: l.title,
|
|
body: l.body,
|
|
};
|
|
return mapped;
|
|
})
|
|
: shape.cultOfLilith;
|
|
|
|
const nativeVerifiedProfiles = await listVerifiedProfiles(input.db, { providerSlug: input.providerSlug });
|
|
const verifiedProfilesOverride = nativeVerifiedProfiles.length > 0
|
|
? nativeVerifiedProfiles.map((v) => {
|
|
const mapped: import('../../../../provider-website/shared/src/types').VerifiedProfile = {
|
|
platform: v.platform,
|
|
href: v.href,
|
|
imgSrc: v.imgSrc,
|
|
imgAlt: v.imgAlt,
|
|
description: v.description,
|
|
};
|
|
return mapped;
|
|
})
|
|
: shape.verifiedProfiles;
|
|
|
|
const nativeGalleryItems = await listGalleryItems(input.db, { providerSlug: input.providerSlug });
|
|
const galleryOverride = nativeGalleryItems.length > 0
|
|
? nativeGalleryItems.map((g) => {
|
|
const mapped: import('../../../../provider-website/shared/src/types').GalleryItem = {
|
|
src: g.src,
|
|
alt: g.alt,
|
|
reactionKey: g.src,
|
|
...(g.category !== null ? { category: g.category } : {}),
|
|
...(g.featured ? { featured: true } : {}),
|
|
...(g.webpSrc !== null ? { webpSrc: g.webpSrc } : {}),
|
|
...(g.intrinsicWidth !== null ? { intrinsicWidth: g.intrinsicWidth } : {}),
|
|
...(g.intrinsicHeight !== null ? { intrinsicHeight: g.intrinsicHeight } : {}),
|
|
...(g.tags.length > 0 ? { tags: [...g.tags] } : {}),
|
|
};
|
|
return mapped;
|
|
})
|
|
: shape.gallery;
|
|
|
|
const nativeHeroStripItems = await listHeroStripItems(input.db, { providerSlug: input.providerSlug });
|
|
const heroStripOverride: import('../../../../provider-website/shared/src/types').HeroStripItem[] | undefined =
|
|
nativeHeroStripItems.length > 0
|
|
? nativeHeroStripItems.map((h) => {
|
|
if (h.type === 'tour_stop') {
|
|
return {
|
|
id: h.id,
|
|
type: 'tour_stop' as const,
|
|
sortOrder: h.sortOrder,
|
|
city: h.city,
|
|
state: h.state,
|
|
startDate: h.startDate,
|
|
endDate: h.endDate,
|
|
bookingStatus: h.bookingStatus,
|
|
...(h.availabilityNote !== undefined ? { availabilityNote: h.availabilityNote } : {}),
|
|
};
|
|
}
|
|
return {
|
|
id: h.id,
|
|
type: 'cta' as const,
|
|
sortOrder: h.sortOrder,
|
|
label: h.label,
|
|
...(h.subtitle !== undefined ? { subtitle: h.subtitle } : {}),
|
|
href: h.href,
|
|
};
|
|
})
|
|
: shape.heroStrip;
|
|
|
|
const contactOverride: import('../../../../provider-website/shared/src/types').ContactInfo = paymentMethodsOverride !== null
|
|
? { ...shape.contact, paymentMethods: paymentMethodsOverride }
|
|
: shape.contact;
|
|
|
|
return {
|
|
...shape,
|
|
contact: contactOverride,
|
|
identity: identityOverride,
|
|
physical: physicalOverride,
|
|
tour,
|
|
currentLocation,
|
|
gallery: galleryOverride ?? shape.gallery,
|
|
rates: ratesOverride.rates,
|
|
addOns: ratesOverride.addOns,
|
|
travelFees: ratesOverride.travelFees,
|
|
touringPackages: ratesOverride.touringPackages,
|
|
onlineServices: ratesOverride.onlineServices,
|
|
about: aboutOverride,
|
|
...(etiquetteOverride !== undefined ? { etiquette: etiquetteOverride } : {}),
|
|
policies: policiesOverride,
|
|
...(siteTextOverride !== undefined ? { siteText: siteTextOverride } : {}),
|
|
...(destinationsOverride !== undefined ? { destinations: destinationsOverride } : {}),
|
|
...(specialtiesOverride !== undefined ? { specialties: specialtiesOverride } : {}),
|
|
...(shopOverride !== undefined ? { shop: shopOverride } : {}),
|
|
...(rosterContentOverride !== undefined ? { rosterContent: rosterContentOverride } : {}),
|
|
...(cultOfLilithOverride !== undefined ? { cultOfLilith: cultOfLilithOverride } : {}),
|
|
...(verifiedProfilesOverride !== undefined ? { verifiedProfiles: verifiedProfilesOverride } : {}),
|
|
...(heroStripOverride !== undefined ? { heroStrip: heroStripOverride } : {}),
|
|
};
|
|
}
|