/** * Composable for reading curated review selections from DCS content. * Reviews are stored in content.yaml by the visual editor's ReviewPickerSheet. */ import { computed, onMounted, onUnmounted, shallowRef, type ComputedRef } from 'vue' export interface ReviewItem { id: string platform: 'google' | 'meta' | string rating: number authorName: string authorPhotoUrl?: string text?: string date?: string replyText?: string locationName?: string sourceLocationName?: string sourceUrl?: string } function withoutAuthorPhotos(items: ReviewItem[]): ReviewItem[] { return items.map(item => ({ ...item, authorPhotoUrl: undefined, })) } /** * True only in a Vite dev build. Vite statically replaces `import.meta.env.DEV` * with a boolean literal (`true` in dev, `false` in production). The * `typeof import.meta` guard + try/catch keep this safe if the package is ever * SSR-externalized (where `import.meta.env` is undefined at the Node runtime and * accessing it would throw) — in that case we deliberately return `false` so the * production-safe branch is taken. */ function isDevBuild(): boolean { try { if (typeof import.meta !== 'undefined' && import.meta.env) { return import.meta.env.DEV === true } } catch { // import.meta.env unavailable (externalized SSR / non-Vite host) → prod-safe. } return false } /** * Honesty guard: consumer-supplied `defaults` are a DEV-ONLY affordance so a * freshly-scaffolded site shows sample reviews while it is being wired up. In a * production build they must NEVER render — an SSR production pass emitting * placeholder reviews is exactly the fabricated-social-proof / hydration bug * this guards against (the content define can be missing server-side if the cms * package is SSR-externalized). Production therefore falls back to an honest * empty state; real reviews always come from `.dcs/content.yaml`. */ function fallbackReviews(defaults: ReviewItem[]): ReviewItem[] { return isDevBuild() ? withoutAuthorPhotos(defaults) : [] } export interface UseReviewContentConfig { /** The section key matching the data-dcs-reviews attribute value */ sectionKey: string /** Page slug for page-specific content lookup (defaults to current page) */ pageSlug?: string /** Fallback reviews when no content is available */ defaults?: ReviewItem[] } export interface UseReviewContentReturn { /** The curated review items from content */ reviews: ComputedRef /** Whether any reviews are available */ hasReviews: ComputedRef /** Number of reviews */ count: ComputedRef } const previewReviewOverrides = shallowRef>({}) let activePreviewReviewConsumers = 0 // Declare the global content variable injected by dcsContentPlugin declare const __DCS_CONTENT__: { global?: Record pages?: Record> } | undefined function normalizeReviewItem(item: Record): ReviewItem | null { const id = String(item.id ?? '').trim() if (!id) { return null } const rawRating = Number(item.rating ?? 5) const rating = Number.isFinite(rawRating) ? Math.min(5, Math.max(1, Math.round(rawRating))) : 5 return { id, platform: String(item.platform ?? 'google'), rating, authorName: String(item.authorName ?? 'Anonymous'), authorPhotoUrl: undefined, text: item.text ? String(item.text) : undefined, date: item.date ? String(item.date) : undefined, replyText: item.replyText ? String(item.replyText) : undefined, locationName: item.locationName ? String(item.locationName) : undefined, sourceLocationName: item.sourceLocationName ? String(item.sourceLocationName) : undefined, sourceUrl: item.sourceUrl ? String(item.sourceUrl) : undefined, } } function normalizeReviewList(value: unknown): ReviewItem[] { if (!Array.isArray(value)) { return [] } return value .filter((item): item is Record => item != null && typeof item === 'object') .map(normalizeReviewItem) .filter((item): item is ReviewItem => item != null) } function handlePreviewReviewUpdate(event: Event) { const detail = event instanceof CustomEvent && event.detail != null && typeof event.detail === 'object' ? event.detail as { key?: unknown; reviews?: unknown } : null const key = typeof detail?.key === 'string' ? detail.key.trim() : '' if (!key) { return } const next = { ...previewReviewOverrides.value } if (Array.isArray(detail?.reviews)) { next[key] = normalizeReviewList(detail.reviews) } else { delete next[key] } previewReviewOverrides.value = next } export function useReviewContent(config: UseReviewContentConfig): UseReviewContentReturn { const { sectionKey, pageSlug, defaults = [] } = config onMounted(() => { activePreviewReviewConsumers += 1 if (activePreviewReviewConsumers === 1) { window.addEventListener('dcs:reviews-updated', handlePreviewReviewUpdate) } }) onUnmounted(() => { activePreviewReviewConsumers = Math.max(0, activePreviewReviewConsumers - 1) if (activePreviewReviewConsumers === 0) { window.removeEventListener('dcs:reviews-updated', handlePreviewReviewUpdate) } }) const reviews = computed(() => { if (Object.prototype.hasOwnProperty.call(previewReviewOverrides.value, sectionKey)) { return previewReviewOverrides.value[sectionKey] ?? [] } if (typeof __DCS_CONTENT__ === 'undefined' || __DCS_CONTENT__ == null) { return fallbackReviews(defaults) } let reviewData: unknown = null if (pageSlug && __DCS_CONTENT__.pages?.[pageSlug]) { reviewData = __DCS_CONTENT__.pages[pageSlug][`reviews.${sectionKey}.items`] ?? __DCS_CONTENT__.pages[pageSlug][`reviews.${sectionKey}`] } if (!reviewData && __DCS_CONTENT__.global) { reviewData = __DCS_CONTENT__.global[`reviews.${sectionKey}.items`] ?? __DCS_CONTENT__.global[`reviews.${sectionKey}`] } if (!reviewData || !Array.isArray(reviewData)) { return fallbackReviews(defaults) } return normalizeReviewList(reviewData) }) const hasReviews = computed(() => reviews.value.length > 0) const count = computed(() => reviews.value.length) return { reviews, hasReviews, count } }