"use client"; import type { CSSProperties } from "react"; import { ArrowRight, CheckCircle2, ShieldCheck, Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter"; import type { DetailUiSafeZone, DetailUiSubjectAnalysis, } from "@/lib/detail-ui-subject-analysis"; type RenderableOverlayComponent = DetailUiScreenOverlaySnapshot["components"][number]; const OVERLAY_RENDERABLE_TYPES = new Set([ "detail_text_block", "detail_card", "detail_badge", "detail_feature_card", "detail_feature_tag", "detail_spec_table", "detail_table_label", "detail_table_value", "detail_comparison_strip", "detail_compare_before", "detail_compare_after", "detail_proof_bar", "detail_cta_banner", "detail_cta_button", "detail_cta_support", ]); function collectRenderableComponents(snapshot: DetailUiScreenOverlaySnapshot) { return snapshot.components .filter((component) => OVERLAY_RENDERABLE_TYPES.has(component.type)) .sort((left, right) => { const leftZ = Number(left.layout?.zIndex || 0); const rightZ = Number(right.layout?.zIndex || 0); return leftZ - rightZ; }); } function resolveOverlayText( snapshot: DetailUiScreenOverlaySnapshot, component: RenderableOverlayComponent, ) { const binding = component.bindings?.content; if (!binding || binding.kind !== "text") { return String(component.props?.content || ""); } const path = String(binding.path || "").trim(); const screen = snapshot.dataModel.screen; if (!path || !screen) { return String(component.props?.content || ""); } if (path.endsWith("/title")) return String(screen.title || ""); if (path.endsWith("/subtitle")) return String(screen.subtitle || ""); if (path.endsWith("/copy")) return String(screen.copy || ""); if (path.endsWith("/layoutNote")) return String(screen.layoutNote || ""); return String(component.props?.content || ""); } function renderOverlayComponentContent( snapshot: DetailUiScreenOverlaySnapshot, component: RenderableOverlayComponent, scale: number, ) { const content = resolveOverlayText(snapshot, component); const lines = content.split("\n").filter(Boolean); const iconSize = Math.max(12, Math.round(16 * scale)); const smallIconSize = Math.max(10, Math.round(14 * scale)); if (component.type === "detail_feature_card") { const headline = lines[0] || content; const detail = lines.slice(1).join("\n"); return (
{headline}
{detail ? (
{detail}
) : null}
); } if (component.type === "detail_feature_tag") { return (
{content}
); } if (component.type === "detail_compare_before" || component.type === "detail_compare_after") { const title = lines[0] || ""; const body = lines.slice(1).join("\n"); return (
{title}
{body}
); } if (component.type === "detail_proof_bar") { return (
{content}
); } if (component.type === "detail_cta_button") { return (
{content}
); } if (component.type === "detail_cta_support" || component.type === "detail_cta_banner") { return (
{content}
{component.type === "detail_cta_banner" ? ( ) : null}
); } return content; } function getOverlayComponentStyle(params: { component: RenderableOverlayComponent; snapshot: DetailUiScreenOverlaySnapshot; content: string; scale: number; frameOverride?: { x: number; y: number; width: number; height: number; }; }) { const frame = (params.frameOverride || params.component.layout?.frame || {}) as { x?: number; y?: number; width?: number; height?: number; }; const style = (params.component.style || {}) as Record; const widthScale = 100 / Math.max(1, params.snapshot.screenSize.width); const heightScale = 100 / Math.max(1, params.snapshot.screenSize.height); const scale = Math.max(0.55, Math.min(2.4, params.scale)); const overlayStyle: CSSProperties = { position: "absolute", left: `${Number(frame.x || 0) * widthScale}%`, top: `${Number(frame.y || 0) * heightScale}%`, width: `${Number(frame.width || 0) * widthScale}%`, height: `${Number(frame.height || 0) * heightScale}%`, zIndex: Number(params.component.layout?.zIndex || 1), fontSize: typeof style.fontSize === "number" ? `${style.fontSize * scale}px` : undefined, fontFamily: typeof style.fontFamily === "string" ? style.fontFamily : undefined, fontWeight: typeof style.fontWeight === "string" ? style.fontWeight : undefined, lineHeight: typeof style.lineHeight === "number" ? style.lineHeight : undefined, letterSpacing: typeof style.letterSpacing === "number" ? `${style.letterSpacing * scale}px` : undefined, color: typeof style.color === "string" ? style.color : undefined, textAlign: style.textAlign === "center" || style.textAlign === "right" ? style.textAlign : "left", textTransform: style.textTransform === "uppercase" || style.textTransform === "lowercase" || style.textTransform === "capitalize" ? style.textTransform : undefined, background: typeof style.backgroundColor === "string" ? style.backgroundColor : undefined, borderColor: typeof style.borderColor === "string" ? style.borderColor : undefined, borderWidth: typeof style.borderWidth === "number" ? `${Math.max(1, style.borderWidth * scale)}px` : undefined, borderStyle: typeof style.borderWidth === "number" && style.borderWidth > 0 ? "solid" : undefined, borderRadius: typeof style.borderRadius === "number" ? `${style.borderRadius * scale}px` : undefined, padding: typeof style.padding === "number" ? `${style.padding * scale}px` : undefined, boxShadow: typeof style.boxShadow === "string" ? style.boxShadow : undefined, backdropFilter: typeof style.backdropBlur === "number" ? `blur(${style.backdropBlur * scale}px)` : undefined, WebkitBackdropFilter: typeof style.backdropBlur === "number" ? `blur(${style.backdropBlur * scale}px)` : undefined, overflow: "hidden", whiteSpace: "pre-wrap", wordBreak: "break-word", display: "flex", alignItems: "center", justifyContent: style.textAlign === "center" ? "center" : style.textAlign === "right" ? "flex-end" : "flex-start", }; if ( params.component.type === "detail_feature_card" || params.component.type === "detail_spec_table" || params.component.type === "detail_compare_before" || params.component.type === "detail_compare_after" || params.component.type === "detail_proof_bar" || params.component.type === "detail_cta_banner" || params.component.type === "detail_cta_support" ) { overlayStyle.alignItems = "flex-start"; } if (params.component.type === "detail_cta_button") { overlayStyle.justifyContent = "center"; overlayStyle.cursor = "pointer"; } if (params.component.type === "detail_table_label") { overlayStyle.justifyContent = "center"; } return overlayStyle; } function normalizeZoneToPixels(params: { zone: DetailUiSafeZone; width: number; height: number; }) { return { x: params.zone.x * params.width, y: params.zone.y * params.height, width: params.zone.width * params.width, height: params.zone.height * params.height, }; } function chooseGroupZone(params: { subjectAnalysis: DetailUiSubjectAnalysis; imageWidth: number; imageHeight: number; preferred: Array<"left" | "right" | "top" | "bottom">; minimumWidth: number; minimumHeight: number; }) { for (const key of params.preferred) { const zone = params.subjectAnalysis.safeZones[key]; if (!zone) continue; const pixelZone = normalizeZoneToPixels({ zone, width: params.imageWidth, height: params.imageHeight, }); if ( pixelZone.width >= params.minimumWidth + 12 && pixelZone.height >= params.minimumHeight + 12 ) { return { key, zone: pixelZone }; } } return null; } function moveGroupToZone(params: { frames: Array<{ id: string; x: number; y: number; width: number; height: number }>; zone: { x: number; y: number; width: number; height: number }; align: "start" | "end" | "center"; }) { const minX = Math.min(...params.frames.map((frame) => frame.x)); const minY = Math.min(...params.frames.map((frame) => frame.y)); const maxX = Math.max(...params.frames.map((frame) => frame.x + frame.width)); const maxY = Math.max(...params.frames.map((frame) => frame.y + frame.height)); const groupWidth = maxX - minX; const groupHeight = maxY - minY; let targetX = params.zone.x + 8; if (params.align === "end") { targetX = params.zone.x + Math.max(8, params.zone.width - groupWidth - 8); } else if (params.align === "center") { targetX = params.zone.x + Math.max(0, (params.zone.width - groupWidth) / 2); } const targetY = params.zone.y + 8; const dx = targetX - minX; const dy = targetY - minY; return new Map( params.frames.map((frame) => [ frame.id, { x: frame.x + dx, y: frame.y + dy, width: frame.width, height: frame.height, }, ]), ); } function buildFrameOverrides(params: { overlay: DetailUiScreenOverlaySnapshot; components: RenderableOverlayComponent[]; subjectAnalysis?: DetailUiSubjectAnalysis | null; imageWidth: number; imageHeight: number; }) { if (!params.subjectAnalysis) return new Map(); const pixelFrames = params.components.map((component) => { const frame = (component.layout?.frame || {}) as { x?: number; y?: number; width?: number; height?: number; }; return { id: component.id, role: String(component.metadata?.componentRole || ""), type: component.type, x: (Number(frame.x || 0) / params.overlay.screenSize.width) * params.imageWidth, y: (Number(frame.y || 0) / params.overlay.screenSize.height) * params.imageHeight, width: (Number(frame.width || 0) / params.overlay.screenSize.width) * params.imageWidth, height: (Number(frame.height || 0) / params.overlay.screenSize.height) * params.imageHeight, }; }); const heroFrames = pixelFrames.filter((frame) => ["badge", "title", "subtitle"].includes(frame.role), ); const supportFrames = pixelFrames.filter((frame) => [ "feature_tag", "support_card", "spec_table_section", "table_label", "table_value", "comparison_before", "comparison_after", "proof_bar", ].includes(frame.role), ); const ctaFrames = pixelFrames.filter((frame) => ["cta_support", "cta_button", "cta_banner"].includes(frame.role), ); const overrides = new Map(); const heroPreferred = params.subjectAnalysis.dominantHorizontal === "left" ? ["right", "top", "bottom", "left"] : params.subjectAnalysis.dominantHorizontal === "right" ? ["left", "top", "bottom", "right"] : ["top", "left", "right", "bottom"]; const supportPreferred = params.subjectAnalysis.dominantVertical === "bottom" ? ["top", "left", "right", "bottom"] : ["bottom", "right", "left", "top"]; const ctaPreferred = ["bottom", "right", "left", "top"] as const; if (heroFrames.length > 0) { const groupWidth = Math.max(...heroFrames.map((frame) => frame.x + frame.width)) - Math.min(...heroFrames.map((frame) => frame.x)); const groupHeight = Math.max(...heroFrames.map((frame) => frame.y + frame.height)) - Math.min(...heroFrames.map((frame) => frame.y)); const zone = chooseGroupZone({ subjectAnalysis: params.subjectAnalysis, imageWidth: params.imageWidth, imageHeight: params.imageHeight, preferred: heroPreferred as any, minimumWidth: groupWidth, minimumHeight: groupHeight, }); if (zone) { const moved = moveGroupToZone({ frames: heroFrames, zone: zone.zone, align: zone.key === "right" ? "end" : zone.key === "top" ? "center" : "start", }); moved.forEach((frame, id) => overrides.set(id, frame)); } } if (supportFrames.length > 0) { const groupWidth = Math.max(...supportFrames.map((frame) => frame.x + frame.width)) - Math.min(...supportFrames.map((frame) => frame.x)); const groupHeight = Math.max(...supportFrames.map((frame) => frame.y + frame.height)) - Math.min(...supportFrames.map((frame) => frame.y)); const zone = chooseGroupZone({ subjectAnalysis: params.subjectAnalysis, imageWidth: params.imageWidth, imageHeight: params.imageHeight, preferred: supportPreferred as any, minimumWidth: groupWidth, minimumHeight: groupHeight, }); if (zone) { const moved = moveGroupToZone({ frames: supportFrames, zone: zone.zone, align: zone.key === "right" ? "end" : "start", }); moved.forEach((frame, id) => overrides.set(id, frame)); } } if (ctaFrames.length > 0) { const groupWidth = Math.max(...ctaFrames.map((frame) => frame.x + frame.width)) - Math.min(...ctaFrames.map((frame) => frame.x)); const groupHeight = Math.max(...ctaFrames.map((frame) => frame.y + frame.height)) - Math.min(...ctaFrames.map((frame) => frame.y)); const zone = chooseGroupZone({ subjectAnalysis: params.subjectAnalysis, imageWidth: params.imageWidth, imageHeight: params.imageHeight, preferred: [...ctaPreferred] as any, minimumWidth: groupWidth, minimumHeight: groupHeight, }); if (zone) { const moved = moveGroupToZone({ frames: ctaFrames, zone: zone.zone, align: zone.key === "right" ? "end" : zone.key === "top" ? "center" : "start", }); moved.forEach((frame, id) => overrides.set(id, frame)); } } return overrides; } export function DetailSurfaceOverlayRenderer(params: { overlay: DetailUiScreenOverlaySnapshot | null; imageMetrics?: { offsetX: number; offsetY: number; renderedWidth: number; renderedHeight: number; } | null; subjectAnalysis?: DetailUiSubjectAnalysis | null; className?: string; }) { if (!params.overlay) return null; const components = collectRenderableComponents(params.overlay); if (components.length === 0) return null; const overlayWidth = params.imageMetrics && params.imageMetrics.renderedWidth > 0 ? params.imageMetrics.renderedWidth : params.overlay.screenSize.width; const overlayHeight = params.imageMetrics && params.imageMetrics.renderedHeight > 0 ? params.imageMetrics.renderedHeight : params.overlay.screenSize.height; const overlayScale = Math.min( overlayWidth / Math.max(1, params.overlay.screenSize.width), overlayHeight / Math.max(1, params.overlay.screenSize.height), ); const frameOverrides = buildFrameOverrides({ overlay: params.overlay, components, subjectAnalysis: params.subjectAnalysis, imageWidth: overlayWidth, imageHeight: overlayHeight, }); const containerStyle = params.imageMetrics && params.imageMetrics.renderedWidth > 0 && params.imageMetrics.renderedHeight > 0 ? { position: "absolute" as const, left: `${params.imageMetrics.offsetX}px`, top: `${params.imageMetrics.offsetY}px`, width: `${params.imageMetrics.renderedWidth}px`, height: `${params.imageMetrics.renderedHeight}px`, } : { position: "absolute" as const, inset: "0px", }; return (
{components.map((component) => { const content = resolveOverlayText(params.overlay!, component); return (
{ const frame = frameOverrides.get(component.id); if (!frame) return undefined; return { x: (frame.x / overlayWidth) * params.overlay!.screenSize.width, y: (frame.y / overlayHeight) * params.overlay!.screenSize.height, width: (frame.width / overlayWidth) * params.overlay!.screenSize.width, height: (frame.height / overlayHeight) * params.overlay!.screenSize.height, }; })(), })} > {renderOverlayComponentContent(params.overlay!, component, overlayScale)}
); })}
); }