763 lines
28 KiB
TypeScript
763 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import { useState, 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 } from "@/features/editor/utils/buttonHelpers";
|
|
import { computeTextPublicTokens } 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 } 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";
|
|
|
|
const paddingYClass =
|
|
props.paddingY === "sm" ? "py-8" : props.paddingY === "lg" ? "py-20" : "py-12";
|
|
|
|
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[];
|
|
}
|
|
|
|
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 }: 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 renderBlock = (block: Block) => {
|
|
if ((block as any).type === "form") {
|
|
return null;
|
|
}
|
|
|
|
if (block.type === "text") {
|
|
const props = block.props as TextBlockProps;
|
|
const tokens = computeTextPublicTokens(props);
|
|
const textStyle: CSSProperties = { ...tokens.styleOverrides };
|
|
|
|
return (
|
|
<p
|
|
key={block.id}
|
|
className={`${tokens.alignClass} ${tokens.sizeClass} text-slate-50 leading-relaxed whitespace-pre-wrap`}
|
|
style={textStyle}
|
|
>
|
|
{props.text}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
if (block.type === "formInput") {
|
|
const props = block.props as FormInputBlockProps;
|
|
const tokens = computeFormInputPublicTokens(props);
|
|
|
|
const baseInputClass =
|
|
"rounded border px-3 py-2 text-xs outline-none focus:border-sky-500";
|
|
|
|
return (
|
|
<label
|
|
key={block.id}
|
|
data-testid="preview-form-input-wrapper"
|
|
className={`${tokens.wrapperLayoutClass} text-xs text-slate-200`}
|
|
style={tokens.wrapperStyle}
|
|
>
|
|
<span>{props.label}</span>
|
|
{props.inputType === "textarea" ? (
|
|
<textarea
|
|
data-testid="preview-form-input"
|
|
className={`${baseInputClass} ${tokens.widthClass}`}
|
|
style={tokens.inputStyle}
|
|
placeholder={props.placeholder || props.label}
|
|
/>
|
|
) : (
|
|
<input
|
|
data-testid="preview-form-input"
|
|
className={`${baseInputClass} ${tokens.widthClass}`}
|
|
style={tokens.inputStyle}
|
|
type={props.inputType === "email" ? "email" : "text"}
|
|
placeholder={props.placeholder || props.label}
|
|
/>
|
|
)}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
if (block.type === "formSelect") {
|
|
const props = block.props as FormSelectBlockProps;
|
|
const tokens = computeFormSelectPublicTokens(props);
|
|
|
|
return (
|
|
<label key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
|
|
<span>{props.label}</span>
|
|
<div
|
|
data-testid="preview-form-select"
|
|
className={`${tokens.widthClass}`}
|
|
style={tokens.wrapperStyle}
|
|
>
|
|
<select
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
|
style={tokens.selectStyle}
|
|
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";
|
|
|
|
return (
|
|
<div
|
|
key={block.id}
|
|
data-testid="preview-form-checkbox-group"
|
|
className={["flex flex-col gap-1", tokens.textSizeClass, "text-slate-200", tokens.widthClass]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
style={tokens.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}
|
|
/>
|
|
) : (
|
|
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
|
|
)}
|
|
<div className="flex flex-col" data-testid="preview-form-checkbox-options" style={tokens.optionsStyle}>
|
|
{props.options.map((opt) => (
|
|
<label
|
|
key={opt.value}
|
|
data-testid="preview-form-checkbox-option-container"
|
|
className="inline-flex items-center gap-1"
|
|
style={tokens.optionContainerStyle}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
|
/>
|
|
{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";
|
|
|
|
return (
|
|
<div
|
|
key={block.id}
|
|
data-testid="preview-form-radio-group"
|
|
className={["flex flex-col gap-1", tokens.textSizeClass, "text-slate-200", tokens.widthClass]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
style={tokens.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}
|
|
/>
|
|
) : (
|
|
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
|
|
)}
|
|
<div className="flex flex-col" data-testid="preview-form-radio-options" style={tokens.optionsStyle}>
|
|
{props.options.map((opt) => (
|
|
<label
|
|
key={opt.value}
|
|
className="inline-flex items-center gap-1"
|
|
style={tokens.optionContainerStyle}
|
|
>
|
|
<input
|
|
type="radio"
|
|
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
|
name={props.formFieldName}
|
|
/>
|
|
{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 tokens = computeButtonPublicTokens(props);
|
|
const buttonStyle: CSSProperties = { ...tokens.styleOverrides };
|
|
|
|
return (
|
|
<div key={block.id} className={tokens.alignClass}>
|
|
<a
|
|
href={props.href}
|
|
className={`inline-flex items-center justify-center text-sm font-medium hover:bg-sky-500 transition-colors ${tokens.widthClass}`}
|
|
style={buttonStyle}
|
|
>
|
|
<span className="whitespace-pre-wrap">{props.label}</span>
|
|
</a>
|
|
</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 ${tokens.alignClass}`}
|
|
style={{ marginTop: tokens.wrapperMarginEm, marginBottom: tokens.wrapperMarginEm }}
|
|
>
|
|
<div style={tokens.innerStyle}>
|
|
<div
|
|
data-testid="preview-divider-line"
|
|
className={`${tokens.thicknessClass} ${tokens.innerWidthClass}`}
|
|
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={tokens.alignClass}
|
|
style={{
|
|
...tokens.listStyle,
|
|
listStyleType:
|
|
tokens.bulletStyle === "none"
|
|
? "none"
|
|
: tokens.bulletStyle,
|
|
paddingLeft: 12 + level * 8,
|
|
}}
|
|
>
|
|
{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="mt-2 text-xs text-slate-400 hidden"
|
|
>
|
|
{props.captionText}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (block.type === "form") {
|
|
const props = block.props as FormBlockProps;
|
|
const tokens = computeFormControllerPublicTokens(block, blocks);
|
|
const fields = tokens.fields as any[];
|
|
const hasFields = fields.length > 0;
|
|
const submitLabelBase = tokens.submitLabel ?? (hasFields ? "폼 전송" : null);
|
|
|
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
|
|
const form = e.currentTarget;
|
|
const data = new FormData(form);
|
|
|
|
try {
|
|
setFormStatus("submitting");
|
|
setFormMessage("");
|
|
|
|
const res = await fetch("/api/forms/submit", {
|
|
method: "POST",
|
|
body: data,
|
|
});
|
|
|
|
if (res.ok) {
|
|
setFormStatus("success");
|
|
setFormMessage(props.successMessage ?? "성공적으로 전송되었습니다.");
|
|
form.reset();
|
|
} else {
|
|
setFormStatus("error");
|
|
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error("[PublicPageRenderer] form submit error", error);
|
|
setFormStatus("error");
|
|
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form
|
|
key={block.id}
|
|
data-testid="preview-form-controller"
|
|
className={tokens.formClassName}
|
|
style={tokens.formStyle}
|
|
onSubmit={handleSubmit}
|
|
>
|
|
{/* 폼 설정 전체를 서버로 함께 전달하기 위한 hidden 필드 */}
|
|
<input type="hidden" name="__config" value={JSON.stringify(props)} />
|
|
{hasFields && (
|
|
<div className="flex flex-col gap-2 text-xs text-slate-200">
|
|
{fields.map((field: any) => (
|
|
<label key={field.id} className="flex flex-col gap-1">
|
|
<span>{field.label}</span>
|
|
{field.type === "textarea" ? (
|
|
<textarea
|
|
className="w-full min-h-[100px] rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
|
name={field.name}
|
|
placeholder={field.label}
|
|
required={field.required}
|
|
/>
|
|
) : field.type === "select" ? (
|
|
<select
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
|
name={field.name}
|
|
required={field.required}
|
|
defaultValue={field.options?.[0]?.value ?? ""}
|
|
>
|
|
{(field.options ?? []).map((opt: FormSelectOption) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : field.type === "checkbox" ? (
|
|
<div className="flex flex-col gap-1">
|
|
{/* 그룹 타이틀: 텍스트/이미지 모드를 지원한다. alt 는 그룹 라벨 텍스트를 사용한다. */}
|
|
{field.groupLabelMode === "image" && field.groupLabelImageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={field.groupLabelImageUrl}
|
|
alt={field.label}
|
|
className="max-h-10 w-auto max-w-[200px]"
|
|
/>
|
|
) : (
|
|
<span>{field.label}</span>
|
|
)}
|
|
{(field.options ?? []).map((opt: FormCheckboxOption, index: number) => (
|
|
<label key={opt.value} className="inline-flex items-center gap-1 text-xs">
|
|
<input
|
|
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
|
type="checkbox"
|
|
name={field.name}
|
|
value={opt.value}
|
|
required={field.required && index === 0}
|
|
/>
|
|
{opt.labelImageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={opt.labelImageUrl}
|
|
alt={opt.label || field.label || "체크박스 옵션"}
|
|
className="h-4 w-auto"
|
|
/>
|
|
) : (
|
|
<span>{opt.label}</span>
|
|
)}
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : field.type === "radio" ? (
|
|
<div className="flex flex-col gap-1">
|
|
{field.groupLabelMode === "image" && field.groupLabelImageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={field.groupLabelImageUrl}
|
|
alt={field.label}
|
|
className="max-h-10 w-auto max-w-[200px] md:max-w-[300px] lg:max-w-[400px]"
|
|
/>
|
|
) : (
|
|
<span>{field.label}</span>
|
|
)}
|
|
{(field.options ?? []).map((opt: FormRadioOption, index: number) => (
|
|
<label key={opt.value} className="inline-flex items-center gap-1 text-xs">
|
|
<input
|
|
type="radio"
|
|
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
|
name={field.name}
|
|
value={opt.value}
|
|
required={field.required && index === 0}
|
|
/>
|
|
{opt.labelImageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={opt.labelImageUrl}
|
|
alt={opt.label || field.label || "라디오 옵션"}
|
|
className="h-5 w-auto max-w-[120px]"
|
|
/>
|
|
) : (
|
|
<span>{opt.label}</span>
|
|
)}
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<input
|
|
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
|
name={field.name}
|
|
type={field.type === "email" ? "email" : "text"}
|
|
placeholder={field.label}
|
|
required={field.required}
|
|
/>
|
|
)}
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{submitLabelBase != null && (
|
|
<button
|
|
type="submit"
|
|
disabled={formStatus === "submitting"}
|
|
className="inline-flex items-center justify-center rounded border border-emerald-500 bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
|
>
|
|
{formStatus === "submitting" ? "전송 중..." : submitLabelBase}
|
|
</button>
|
|
)}
|
|
|
|
{formStatus !== "idle" && formMessage ? (
|
|
<p
|
|
className={`text-xs mt-1 ${
|
|
formStatus === "success" ? "text-emerald-400" : "text-rose-400"
|
|
}`}
|
|
>
|
|
{formMessage}
|
|
</p>
|
|
) : null}
|
|
</form>
|
|
);
|
|
}
|
|
|
|
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 { backgroundClass, paddingYClass, maxWidthClass, gapXClass, alignItemsClass } =
|
|
getSectionLayoutConfig(props);
|
|
|
|
const columns = props.columns && props.columns.length > 0 ? props.columns : [{ id: `${section.id}_col`, span: 12 }];
|
|
const tokens = computeSectionPublicTokens(props);
|
|
|
|
return (
|
|
<section
|
|
key={section.id}
|
|
data-testid="preview-section"
|
|
data-section-id={section.id}
|
|
className={`${backgroundClass} ${typeof props.paddingYPx === "number" ? "" : paddingYClass}`}
|
|
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={`mx-auto ${typeof props.maxWidthPx === "number" ? "" : maxWidthClass} px-4`}
|
|
style={tokens.innerWrapperStyle}
|
|
>
|
|
<div
|
|
data-testid="preview-section-columns"
|
|
className={`flex ${typeof props.gapXPx === "number" ? "" : gapXClass} ${alignItemsClass}`}
|
|
style={tokens.columnsContainerStyle}
|
|
>
|
|
{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="flex flex-col gap-4" style={{ flexBasis: basis }}>
|
|
{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">
|
|
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 페이지 상단에 노출 */}
|
|
{rootBlocks.length > 0 && (
|
|
<section className="py-12">
|
|
<div className="mx-auto max-w-3xl px-4 flex flex-col gap-4">
|
|
{rootBlocks.map((b) => (
|
|
<div key={b.id}>{renderBlock(b)}</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* 섹션 블록들 */}
|
|
{sectionBlocks.map((section) => renderSection(section))}
|
|
</div>
|
|
);
|
|
}
|