Files
page-builder/src/features/editor/components/PublicPageRenderer.tsx
T
jaybe a5b432fb7b
CI / test (push) Failing after 2m54s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped
TDD,E2E 개선, 미적용 스타일 개선
2025-12-07 09:52:42 +09:00

1079 lines
37 KiB
TypeScript

"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<string>("");
const [activeFormId, setActiveFormId] = useState<string | null>(null);
// 폼 컨트롤러 기준으로 필수 필드 맵을 구성한다.
const requiredFieldIdSet = useMemo(() => {
const set = new Set<string>();
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<HTMLInputElement>(
`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<HTMLInputElement>(
`input[type="radio"][name="${name}"]:checked`,
);
isEmpty = !selected;
} else if (field.type === "select") {
const selectEl = document.querySelector<HTMLSelectElement>(`select[name="${name}"]`);
if (!selectEl) {
isEmpty = true;
} else {
const value = selectEl.value;
isEmpty = value == null || `${value}`.trim() === "";
}
} else {
const inputEl = document.querySelector<HTMLInputElement>(`input[name="${name}"]`);
const textareaEl = document.querySelector<HTMLTextAreaElement>(`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<HTMLInputElement>(
`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<HTMLInputElement>(
`input[type="radio"][name="${name}"]:checked`,
);
if (selected) {
data.append(name, selected.value);
}
return;
}
if (field.type === "select") {
const selectEl = document.querySelector<HTMLSelectElement>(`select[name="${name}"]`);
if (selectEl) {
data.append(name, selectEl.value);
}
return;
}
const inputEl = document.querySelector<HTMLInputElement>(`input[name="${name}"]`);
const textareaEl = document.querySelector<HTMLTextAreaElement>(`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 (
<p key={block.id} className={className} style={textStyle}>
{props.text}
</p>
);
}
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 (
<div
key={block.id}
data-testid="preview-form-input-wrapper"
className="pb-form-field pb-form-field--floating"
style={floatingFieldStyle}
>
{props.inputType === "textarea" ? (
<textarea
id={inputId}
data-testid="preview-form-input"
className={`pb-textarea ${tokens.widthClass}`}
style={tokens.inputStyle}
name={props.formFieldName}
required={isRequired}
// 공백 placeholder 로 라벨이 인풋 안에서 겹치지 않도록 한다.
placeholder=" "
/>
) : (
<input
id={inputId}
data-testid="preview-form-input"
className={`pb-input ${tokens.widthClass}`}
style={tokens.inputStyle}
type={props.inputType === "email" ? "email" : "text"}
name={props.formFieldName}
required={isRequired}
placeholder=" "
/>
)}
{helperText && (
<p className="mt-1 text-[10px] text-slate-400">{helperText}</p>
)}
<label htmlFor={inputId} className="pb-form-label">
{props.label}
</label>
</div>
);
}
const labelSpan =
labelDisplay === "hidden" ? (
<span className="sr-only">{props.label}</span>
) : (
<span>{props.label}</span>
);
return (
<label
key={block.id}
data-testid="preview-form-input-wrapper"
className={wrapperClassName}
style={tokens.wrapperStyle}
>
{labelSpan}
{props.inputType === "textarea" ? (
<textarea
data-testid="preview-form-input"
className={`pb-textarea ${tokens.widthClass}`}
style={tokens.inputStyle}
name={props.formFieldName}
required={isRequired}
placeholder={props.placeholder || props.label}
/>
) : (
<input
data-testid="preview-form-input"
className={`pb-input ${tokens.widthClass}`}
style={tokens.inputStyle}
type={props.inputType === "email" ? "email" : "text"}
name={props.formFieldName}
required={isRequired}
placeholder={props.placeholder || props.label}
/>
)}
</label>
);
}
if (block.type === "formSelect") {
const props = block.props as FormSelectBlockProps;
const tokens = computeFormSelectPublicTokens(props);
const isRequired = requiredFieldIdSet.has(block.id);
const labelDisplay = (props as any).labelDisplay ?? "visible";
const labelLayout = (props as any).labelLayout ?? "stacked";
const isInlineLayout = labelLayout === "inline" && labelDisplay === "visible";
const labelSpan =
labelDisplay === "hidden" ? (
<span className="sr-only">{props.label}</span>
) : (
<span className="pb-form-label">{props.label}</span>
);
const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
const gapEm = pxToEm(gapPx);
const wrapperClassName = [
isInlineLayout ? "flex items-center" : "flex flex-col gap-1",
]
.filter(Boolean)
.join(" ");
const wrapperStyle: CSSProperties = isInlineLayout
? { columnGap: gapEm }
: {};
return (
<label key={block.id} className={wrapperClassName} style={wrapperStyle}>
{labelSpan}
<div
data-testid="preview-form-select"
className={`${tokens.widthClass}`}
style={tokens.wrapperStyle}
>
<select
className="pb-select"
style={tokens.selectStyle}
name={props.formFieldName}
required={isRequired}
defaultValue={props.options[0]?.value ?? ""}
>
{props.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</label>
);
}
if (block.type === "formCheckbox") {
const props = block.props as FormCheckboxBlockProps;
const tokens = computeFormCheckboxPublicTokens(props);
const groupLabelMode = props.groupLabelMode ?? "text";
const isRequired = requiredFieldIdSet.has(block.id);
const groupLabelDisplay = (props as any).groupLabelDisplay ?? "visible";
const labelLayout = (props as any).labelLayout ?? "stacked";
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
const optionLayout = (props as any).optionLayout ?? "stacked";
const optionsLayoutClass = [
"pb-form-options",
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
].join(" ");
const groupLabelSpan =
groupLabelDisplay === "hidden" ? (
<span className="sr-only" style={tokens.groupTextStyle}>
{props.groupLabel}
</span>
) : (
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
);
const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
const gapEm = pxToEm(gapPx);
const baseClass = [tokens.textSizeClass, "text-slate-200", tokens.widthClass]
.filter(Boolean)
.join(" ");
const layoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";
const groupStyle: CSSProperties = {
...tokens.groupStyle,
...(isInlineLayout ? { columnGap: gapEm } : {}),
};
return (
<div
key={block.id}
data-testid="preview-form-checkbox-group"
className={[layoutClass, baseClass].filter(Boolean).join(" ")}
style={groupStyle}
>
{groupLabelMode === "image" && props.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={props.groupLabelImageUrl}
alt={props.groupLabel || "폼 그룹 타이틀"}
className="inline-block max-w-full h-auto"
style={tokens.groupTextStyle}
/>
) : (
groupLabelSpan
)}
<div
className={optionsLayoutClass}
data-testid="preview-form-checkbox-options"
style={tokens.optionsStyle}
>
{props.options.map((opt, index) => (
<label
key={opt.value}
data-testid="preview-form-checkbox-option-container"
className="pb-form-option"
style={tokens.optionContainerStyle}
>
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
name={props.formFieldName}
value={opt.value}
required={isRequired && index === 0}
/>
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || props.groupLabel || "체크박스 옵션"}
className="inline-block max-w-full h-auto"
style={tokens.optionTextStyle}
/>
) : (
<span data-testid="preview-form-checkbox-option" style={tokens.optionTextStyle}>
{opt.label}
</span>
)}
</label>
))}
</div>
</div>
);
}
if (block.type === "formRadio") {
const props = block.props as FormRadioBlockProps;
const tokens = computeFormRadioPublicTokens(props);
const groupLabelMode = props.groupLabelMode ?? "text";
const isRequired = requiredFieldIdSet.has(block.id);
const groupLabelDisplay = (props as any).groupLabelDisplay ?? "visible";
const labelLayout = (props as any).labelLayout ?? "stacked";
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
const optionLayout = (props as any).optionLayout ?? "stacked";
const optionsLayoutClass = [
"pb-form-options",
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
].join(" ");
const groupLabelSpan =
groupLabelDisplay === "hidden" ? (
<span className="sr-only" style={tokens.groupTextStyle}>
{props.groupLabel}
</span>
) : (
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
);
const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
const gapEm = pxToEm(gapPx);
const baseClass = [tokens.textSizeClass, "text-slate-200", tokens.widthClass]
.filter(Boolean)
.join(" ");
const layoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";
const groupStyle: CSSProperties = {
...tokens.groupStyle,
...(isInlineLayout ? { columnGap: gapEm } : {}),
};
return (
<div
key={block.id}
data-testid="preview-form-radio-group"
className={[layoutClass, baseClass].filter(Boolean).join(" ")}
style={groupStyle}
>
{groupLabelMode === "image" && props.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={props.groupLabelImageUrl}
alt={props.groupLabel || "폼 그룹 타이틀"}
className="inline-block max-w-full h-auto"
style={tokens.groupTextStyle}
/>
) : (
groupLabelSpan
)}
<div
className={optionsLayoutClass}
data-testid="preview-form-radio-options"
style={tokens.optionsStyle}
>
{props.options.map((opt, index) => (
<label
key={opt.value}
className="pb-form-option"
style={tokens.optionContainerStyle}
>
<input
type="radio"
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
name={props.formFieldName}
value={opt.value}
required={isRequired && index === 0}
/>
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || props.groupLabel || "라디오 옵션"}
className="inline-block max-w-full h-auto"
style={tokens.optionTextStyle}
/>
) : (
<span data-testid="preview-form-radio-option" style={tokens.optionTextStyle}>
{opt.label}
</span>
)}
</label>
))}
</div>
</div>
);
}
if (block.type === "button") {
const props = block.props as ButtonBlockProps;
const pbTokens = computeButtonPbTokens({
align: props.align ?? "left",
size: props.size ?? "md",
variant: props.variant ?? "solid",
colorPalette: props.colorPalette ?? "primary",
borderRadius: props.borderRadius ?? "md",
fullWidth: props.fullWidth ?? false,
widthMode: props.widthMode ?? undefined,
widthPx: props.widthPx ?? undefined,
paddingX: props.paddingX ?? undefined,
paddingY: props.paddingY ?? undefined,
fillColorCustom: props.fillColorCustom ?? undefined,
strokeColorCustom: props.strokeColorCustom ?? undefined,
textColorCustom: props.textColorCustom ?? undefined,
});
const publicTokens = computeButtonPublicTokens(props);
const buttonStyle: CSSProperties = { ...publicTokens.styleOverrides };
const wrapperClass = pbTokens.alignClass;
const buttonClassName = [
"pb-btn-base",
pbTokens.sizeClass,
pbTokens.variantClass,
pbTokens.radiusClass,
publicTokens.widthClass,
]
.filter(Boolean)
.join(" ");
const hasImage = typeof (props as any).imageSrc === "string" && (props as any).imageSrc.trim() !== "";
const placement = ((props as any).imagePlacement as string | undefined) ?? "left";
let innerContent: React.ReactNode;
if (!hasImage) {
innerContent = props.label;
} else {
const src = (props as any).imageSrc as string;
const altRaw = (props as any).imageAlt as string | undefined;
const alt = altRaw && altRaw.trim() !== "" ? altRaw.trim() : props.label;
const imgEl = (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt} className="inline-block max-w-full h-auto" />
);
const labelSpan = <span className="whitespace-pre-wrap">{props.label}</span>;
const isVertical = placement === "top" || placement === "bottom";
const wrapperClassNames = [
"inline-flex",
"items-center",
"gap-2",
isVertical ? "flex-col" : "",
]
.filter(Boolean)
.join(" ");
const first = placement === "left" || placement === "top" ? imgEl : labelSpan;
const second = placement === "left" || placement === "top" ? labelSpan : imgEl;
innerContent = (
<span className={wrapperClassNames}>
{first}
{second}
</span>
);
}
const owningFormBlock = blocks.find(
(b) => b.type === "form" && (b.props as FormBlockProps).submitButtonId === block.id,
);
const isSubmitButton = (() => {
if (!owningFormBlock) return false;
const tokens = computeFormControllerPublicTokens(owningFormBlock, blocks);
return (tokens.fields ?? []).length > 0;
})();
const handleClick = async (e: React.MouseEvent<HTMLAnchorElement>) => {
if (!isSubmitButton || !owningFormBlock) {
return;
}
e.preventDefault();
await submitFormByController(owningFormBlock);
};
const anchor = (
<a
href={props.href}
className={buttonClassName}
style={buttonStyle}
onClick={isSubmitButton ? handleClick : undefined}
>
{innerContent}
</a>
);
if (!isSubmitButton) {
return (
<div key={block.id} className={wrapperClass}>
{anchor}
</div>
);
}
return (
<div key={block.id} className={wrapperClass}>
<div className="flex flex-col items-start gap-1">
{anchor}
{owningFormBlock && activeFormId === owningFormBlock.id && formStatus !== "idle" && formMessage ? (
<p
className={`text-xs mt-1 ${
formStatus === "success" ? "text-emerald-400" : "text-rose-400"
}`}
>
{formMessage}
</p>
) : null}
</div>
</div>
);
}
if (block.type === "divider") {
const props = block.props as DividerBlockProps;
const tokens = computeDividerPublicTokens(props);
return (
<div
key={block.id}
data-testid="preview-divider"
className={`flex w-full ${tokens.alignClass}`}
style={{ marginTop: tokens.wrapperMarginEm, marginBottom: tokens.wrapperMarginEm }}
>
<div className={tokens.innerWidthClass} style={tokens.innerStyle}>
<div
data-testid="preview-divider-line"
className={tokens.thicknessClass}
style={tokens.lineStyle}
/>
</div>
</div>
);
}
if (block.type === "list") {
const props = block.props as ListBlockProps;
const tokens = computeListPublicTokens(props);
const itemsTree: ListItemNode[] =
(props as any).itemsTree && (props as any).itemsTree.length > 0
? ((props as any).itemsTree as ListItemNode[])
: props.items && props.items.length > 0
? props.items.map((text, index) => ({
id: `${block.id}_item_${index + 1}`,
text,
children: [],
}))
: [
{
id: `${block.id}_item_1`,
text: "리스트 아이템 1",
children: [],
},
];
const renderListNodes = (nodes: ListItemNode[], level: number): React.ReactNode => {
const isOrderedLevel = tokens.bulletStyle === "decimal";
const ListTag = isOrderedLevel ? "ol" : "ul";
return (
<ListTag
className={`pb-list ${tokens.alignClass}`}
style={{
...tokens.listStyle,
listStyleType:
tokens.bulletStyle === "none"
? "none"
: tokens.bulletStyle,
}}
>
{nodes.map((node, index) => (
<li
key={node.id}
style={index < nodes.length - 1 ? { marginBottom: `${tokens.gapEm}em` } : undefined}
>
{node.text}
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
</li>
))}
</ListTag>
);
};
return renderListNodes(itemsTree, 0);
}
if (block.type === "video") {
const props = block.props as VideoBlockProps;
const rawUrl = normalizeVideoSourceUrl(props.sourceUrl ?? "");
if (!rawUrl) {
return null;
}
const platform = resolveVideoPlatform(rawUrl, props.platform ?? "auto");
const embedUrl = buildVideoEmbedUrl(rawUrl, platform, { enableVimeoEmbed: true });
const tokens = computeVideoPublicTokens(props);
const startTimeSec =
typeof (props as any).startTimeSec === "number" && (props as any).startTimeSec >= 0
? (props as any).startTimeSec
: null;
const endTimeSec =
typeof (props as any).endTimeSec === "number" && (props as any).endTimeSec >= 0
? (props as any).endTimeSec
: null;
const isEmbed = platform === "youtube" || platform === "vimeo";
return (
<div key={block.id} className={`w-full flex ${tokens.justifyClass}`}>
<div>
<div className={`pb-video-wrapper${tokens.aspectClass}`} style={tokens.wrapperStyle}>
{isEmbed ? (
<iframe
title={props.titleText && props.titleText.trim() !== "" ? props.titleText.trim() : "비디오"}
src={embedUrl}
className="pb-video-frame"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
) : (
<video
data-testid="preview-video"
className="pb-video-frame"
src={rawUrl}
style={tokens.videoStyle}
poster={props.posterImageSrc && props.posterImageSrc.trim() !== "" ? props.posterImageSrc.trim() : undefined}
aria-label={props.ariaLabel && props.ariaLabel.trim() !== "" ? props.ariaLabel.trim() : undefined}
onLoadedMetadata={(event) => {
if (startTimeSec != null) {
const el = event.currentTarget;
try {
el.currentTime = startTimeSec;
} catch {
// ignore
}
}
}}
onTimeUpdate={(event) => {
if (endTimeSec != null) {
const el = event.currentTarget;
if (el.currentTime >= endTimeSec) {
el.pause();
try {
if (!Number.isNaN(endTimeSec)) {
el.currentTime = endTimeSec;
}
} catch {
// ignore
}
}
}
}}
controls={props.controls !== false}
autoPlay={!!props.autoplay}
loop={!!props.loop}
muted={!!props.muted}
/>
)}
</div>
{props.captionText && props.captionText.trim() !== "" && (
<p data-testid="preview-video-caption" className="pb-video-caption">
{props.captionText}
</p>
)}
</div>
</div>
);
}
if (block.type === "form") {
return null;
}
if (block.type === "image") {
const props = block.props as ImageBlockProps;
const tokens = computeImagePublicTokens(props);
const rawSrc = typeof props.src === "string" ? props.src.trim() : "";
const hasSrc = rawSrc !== "";
return (
<div key={block.id} className={`w-full flex ${tokens.alignClass}`} style={tokens.wrapperStyle}>
{hasSrc && (
// eslint-disable-next-line @next/next/no-img-element
<img src={rawSrc} alt={props.alt} className="object-contain" style={tokens.imageStyle} />
)}
</div>
);
}
return null;
};
const renderSection = (section: Block) => {
const props = section.props as SectionBlockProps;
const columns = props.columns && props.columns.length > 0 ? props.columns : [{ id: `${section.id}_col`, span: 12 }];
const tokens = computeSectionPublicTokens(props);
const exportTokens = computeSectionExportTokens(props);
const alignItems =
props.alignItems === "center" ? "center" : props.alignItems === "bottom" ? "flex-end" : "flex-start";
return (
<section
key={section.id}
data-testid="preview-section"
data-section-id={section.id}
className={exportTokens.sectionClasses}
style={tokens.sectionStyle}
>
{tokens.hasBackgroundVideo && tokens.backgroundVideoSrc && (
<video
data-testid="preview-section-bg-video"
className="pb-section-bg-video"
src={tokens.backgroundVideoSrc}
autoPlay
loop
muted
playsInline
/>
)}
<div
data-testid="preview-section-inner"
className="pb-section-inner"
style={tokens.innerWrapperStyle}
>
<div
data-testid="preview-section-columns"
className="pb-section-columns"
style={{
...tokens.columnsContainerStyle,
alignItems,
}}
>
{columns.map((col) => {
const basis = `${(col.span / 12) * 100}%`;
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
return (
<div
key={col.id}
className="pb-section-column"
style={{ flexBasis: basis, display: "flex", flexDirection: "column", rowGap: "1rem" }}
>
{columnBlocks.map((b) => (
<div key={b.id}>{renderBlock(b)}</div>
))}
</div>
);
})}
</div>
</div>
</section>
);
};
return (
<div className="flex-1 flex flex-col text-slate-50">
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 pb-root 내에 페이지 상단에 노출 */}
{rootBlocks.length > 0 && (
<section className="pb-root">
<div className="pb-root-inner">
{rootBlocks.map((b) => (
<div key={b.id}>{renderBlock(b)}</div>
))}
</div>
</section>
)}
{/* 섹션 블록들 */}
{sectionBlocks.map((section) => renderSection(section))}
</div>
);
}