"use client"; import { useState, useMemo, type CSSProperties } from "react"; import type { Block, TextBlockProps, ButtonBlockProps, ImageBlockProps, VideoBlockProps, SectionBlockProps, DividerBlockProps, ListBlockProps, ListItemNode, FormBlockProps, FormInputBlockProps, FormSelectBlockProps, FormCheckboxBlockProps, FormRadioBlockProps, FormSelectOption, FormRadioOption, FormCheckboxOption, } from "@/features/editor/state/editorStore"; import { normalizeVideoSourceUrl, resolveVideoPlatform, buildVideoEmbedUrl, computeVideoPublicTokens, } from "@/features/editor/utils/videoHelpers"; import { computeButtonPublicTokens, computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers"; import { computeTextPublicTokens, computeTextPbTokens } from "@/features/editor/utils/textHelpers"; import { computeDividerPublicTokens } from "@/features/editor/utils/dividerHelpers"; import { computeImagePublicTokens } from "@/features/editor/utils/imageHelpers"; import { computeListPublicTokens } from "@/features/editor/utils/listHelpers"; import { computeSectionPublicTokens, computeSectionExportTokens } from "@/features/editor/utils/sectionHelpers"; import { computeFormInputPublicTokens, computeFormSelectPublicTokens, computeFormCheckboxPublicTokens, computeFormRadioPublicTokens, computeFormControllerPublicTokens, } from "@/features/editor/utils/formHelpers"; // 섹션 레이아웃/스타일 토큰을 실제 Tailwind 클래스 조합으로 변환하는 유틸 // (에디터/퍼블릭 렌더러에서 동일한 규칙을 재사용하기 위해 export 한다.) export function getSectionLayoutConfig(props: SectionBlockProps) { const backgroundClass = props.background === "muted" ? "bg-slate-900" : props.background === "primary" ? "bg-sky-900" : "bg-slate-950"; // 섹션 수직 패딩: builder.css 의 pb-section-py-sm/md/lg (1rem/1.5rem/2.5rem)에 대응 // - sm → 1rem → py-4 // - md → 1.5rem → py-6 // - lg → 2.5rem → py-10 const paddingYClass = props.paddingY === "sm" ? "py-4" : props.paddingY === "lg" ? "py-10" : "py-6"; const maxWidthClass = props.maxWidthMode === "narrow" ? "max-w-3xl" : props.maxWidthMode === "wide" ? "max-w-6xl" : "max-w-5xl"; const gapXClass = props.gapX === "sm" ? "gap-4" : props.gapX === "lg" ? "gap-10" : "gap-8"; const alignItemsClass = props.alignItems === "center" ? "items-center" : props.alignItems === "bottom" ? "items-end" : "items-start"; return { backgroundClass, paddingYClass, maxWidthClass, gapXClass, alignItemsClass, } as const; } // 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트 interface PublicPageRendererProps { blocks: Block[]; projectSlug?: string; } const pxToEm = (px: number, base = 16) => `${px / base}em`; const convertPxStringToEm = (value?: string | null) => { if (!value) return null; const match = value.trim().match(/-?\d+(?:\.\d+)?/); if (!match) return null; const px = parseFloat(match[0]); if (!Number.isFinite(px)) return null; return pxToEm(px); }; export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererProps) { const sectionBlocks = blocks.filter((b) => b.type === "section"); const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section"); const [formStatus, setFormStatus] = useState<"idle" | "submitting" | "success" | "error">("idle"); const [formMessage, setFormMessage] = useState(""); const [activeFormId, setActiveFormId] = useState(null); // 폼 컨트롤러 기준으로 필수 필드 맵을 구성한다. const requiredFieldIdSet = useMemo(() => { const set = new Set(); for (const block of blocks) { if (block.type !== "form") continue; const props = block.props as any; const fieldIds: string[] = Array.isArray(props.requiredFieldIds) ? props.requiredFieldIds : []; for (const id of fieldIds) { if (id) set.add(id); } } return set; }, [blocks]); const submitFormByController = async (formBlock: Block) => { const props = formBlock.props as FormBlockProps; const tokens = computeFormControllerPublicTokens(formBlock, blocks); const fields = tokens.fields; const missingRequiredLabels: string[] = []; fields.forEach((field) => { if (!field.required) { return; } const name = field.name; if (!name) { return; } let isEmpty = false; if (field.type === "checkbox") { const nodes = document.querySelectorAll( `input[type="checkbox"][name="${name}"]`, ); const anyChecked = Array.from(nodes).some((el) => el.checked); isEmpty = !anyChecked; } else if (field.type === "radio") { const selected = document.querySelector( `input[type="radio"][name="${name}"]:checked`, ); isEmpty = !selected; } else if (field.type === "select") { const selectEl = document.querySelector(`select[name="${name}"]`); if (!selectEl) { isEmpty = true; } else { const value = selectEl.value; isEmpty = value == null || `${value}`.trim() === ""; } } else { const inputEl = document.querySelector(`input[name="${name}"]`); const textareaEl = document.querySelector(`textarea[name="${name}"]`); const valueSource = (inputEl as HTMLInputElement | null) ?? (textareaEl as HTMLTextAreaElement | null); if (!valueSource) { isEmpty = true; } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any const rawValue = (valueSource as any).value; const value = typeof rawValue === "string" ? rawValue : rawValue != null ? String(rawValue) : ""; isEmpty = value.trim() === ""; } } if (isEmpty) { const label = (field.label || field.name || "").trim(); if (label && !missingRequiredLabels.includes(label)) { missingRequiredLabels.push(label); } } }); if (missingRequiredLabels.length > 0) { setActiveFormId(formBlock.id); setFormStatus("error"); const bulletLines = missingRequiredLabels.map((label) => `- ${label}`).join("\n"); const message = `다음 필수 항목을 입력해 주세요:\n${bulletLines}`; setFormMessage(message); return; } const data = new FormData(); data.append("__config", JSON.stringify(props)); if (projectSlug && projectSlug.trim() !== "") { data.append("__projectSlug", projectSlug.trim()); } fields.forEach((field) => { const name = field.name; if (!name) { return; } if (field.type === "checkbox") { const nodes = document.querySelectorAll( `input[type="checkbox"][name="${name}"]`, ); nodes.forEach((el) => { if (el.checked) { data.append(name, el.value); } }); return; } if (field.type === "radio") { const selected = document.querySelector( `input[type="radio"][name="${name}"]:checked`, ); if (selected) { data.append(name, selected.value); } return; } if (field.type === "select") { const selectEl = document.querySelector(`select[name="${name}"]`); if (selectEl) { data.append(name, selectEl.value); } return; } const inputEl = document.querySelector(`input[name="${name}"]`); const textareaEl = document.querySelector(`textarea[name="${name}"]`); const valueSource = (inputEl as HTMLInputElement | null) ?? (textareaEl as HTMLTextAreaElement | null); if (valueSource) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const value = (valueSource as any).value ?? ""; data.append(name, value); } }); try { setActiveFormId(formBlock.id); setFormStatus("submitting"); setFormMessage(""); const res = await fetch("/api/forms/submit", { method: "POST", body: data, }); if (res.ok) { setFormStatus("success"); setFormMessage(props.successMessage ?? "성공적으로 전송되었습니다."); } else { setFormStatus("error"); setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다."); } } catch (error) { console.error("[PublicPageRenderer] form submit error", error); setFormStatus("error"); setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다."); } }; const renderBlock = (block: Block) => { if (block.type === "text") { const props = block.props as TextBlockProps; const publicTokens = computeTextPublicTokens(props); const text = typeof props.text === "string" ? props.text : ""; const pbTokens = computeTextPbTokens({ text, align: props.align ?? "left", size: props.size ?? "base", fontSizeMode: props.fontSizeMode ?? "scale", fontSizeScale: props.fontSizeScale ?? undefined, fontSizeCustom: props.fontSizeCustom ?? undefined, lineHeightMode: props.lineHeightMode ?? "scale", lineHeightScale: props.lineHeightScale ?? undefined, lineHeightCustom: props.lineHeightCustom ?? undefined, fontWeightMode: props.fontWeightMode ?? "scale", fontWeightScale: props.fontWeightScale ?? undefined, fontWeightCustom: props.fontWeightCustom ?? undefined, colorMode: props.colorMode ?? undefined, colorPalette: props.colorPalette ?? undefined, colorCustom: props.colorCustom ?? undefined, backgroundColorCustom: (props as any).backgroundColorCustom ?? undefined, maxWidthMode: props.maxWidthMode ?? "scale", maxWidthScale: props.maxWidthScale ?? undefined, underline: props.underline ?? false, strike: props.strike ?? false, italic: props.italic ?? false, }); const className = [ pbTokens.alignClass, pbTokens.sizeClass, pbTokens.leadingClass, pbTokens.weightClass, pbTokens.colorClass, pbTokens.maxWidthClass, ...pbTokens.extraClasses, ] .filter(Boolean) .join(" "); const textStyle: CSSProperties = { ...publicTokens.styleOverrides }; return (

{props.text}

); } if (block.type === "formInput") { const props = block.props as FormInputBlockProps; const tokens = computeFormInputPublicTokens(props); const isRequired = requiredFieldIdSet.has(block.id); const labelDisplay = (props as any).labelDisplay ?? "visible"; const wrapperClassName = `${tokens.wrapperLayoutClass} text-xs text-slate-200`; const rawPlaceholder = typeof props.placeholder === "string" ? props.placeholder.trim() : ""; const rawLabel = typeof props.label === "string" ? props.label.trim() : ""; // 플로팅 라벨 모드에서는 placeholder 텍스트를 인풋 안에 직접 렌더링하지 않고, // 라벨이 인풋 안/위에서 이동하도록 사용한다. placeholder 가 라벨과 다를 경우에만 // 인풋 하단의 보조 설명 텍스트로 노출한다. const helperText = labelDisplay === "floating" && rawPlaceholder !== "" && rawPlaceholder !== rawLabel ? rawPlaceholder : null; const inputId = props.formFieldName || block.id; if (labelDisplay === "floating") { // builder.css 기반 플로팅 라벨 마크업: pb-form-field/pb-form-field--floating + pb-form-label + pb-input // Export 경로와 동일한 DOM 구조를 사용해 프리뷰/퍼블릭/Export 가 모두 같은 레이아웃을 갖도록 맞춘다. // paddingY 값을 사용해 플로팅 라벨과 인풋 높이를 함께 제어하기 위해 CSS 변수에 전달한다. const paddingY = typeof (props as any).paddingY === "number" && (props as any).paddingY >= 0 ? (props as any).paddingY : null; const floatingFieldStyle: CSSProperties = {}; if (paddingY !== null) { (floatingFieldStyle as any)["--pb-input-padding-y"] = `${paddingY / 16}rem`; } return (
{props.inputType === "textarea" ? (