TDD,E2E 개선, 미적용 스타일 개선
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { useState, useMemo, type CSSProperties } from "react";
|
||||
import type {
|
||||
Block,
|
||||
TextBlockProps,
|
||||
@@ -27,11 +27,11 @@ import {
|
||||
computeVideoPublicTokens,
|
||||
} from "@/features/editor/utils/videoHelpers";
|
||||
import { computeButtonPublicTokens, computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers";
|
||||
import { computeTextPublicTokens } from "@/features/editor/utils/textHelpers";
|
||||
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 } from "@/features/editor/utils/sectionHelpers";
|
||||
import { computeSectionPublicTokens, computeSectionExportTokens } from "@/features/editor/utils/sectionHelpers";
|
||||
import {
|
||||
computeFormInputPublicTokens,
|
||||
computeFormSelectPublicTokens,
|
||||
@@ -107,18 +107,217 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
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 tokens = computeTextPublicTokens(props);
|
||||
const textStyle: CSSProperties = { ...tokens.styleOverrides };
|
||||
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={`${tokens.alignClass} ${tokens.sizeClass} text-slate-50 leading-relaxed whitespace-pre-wrap`}
|
||||
style={textStyle}
|
||||
>
|
||||
<p key={block.id} className={className} style={textStyle}>
|
||||
{props.text}
|
||||
</p>
|
||||
);
|
||||
@@ -127,31 +326,112 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
if (block.type === "formInput") {
|
||||
const props = block.props as FormInputBlockProps;
|
||||
const tokens = computeFormInputPublicTokens(props);
|
||||
const isRequired = requiredFieldIdSet.has(block.id);
|
||||
|
||||
const baseInputClass =
|
||||
"rounded border px-3 py-2 text-xs outline-none focus:border-sky-500";
|
||||
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={`${tokens.wrapperLayoutClass} text-xs text-slate-200`}
|
||||
className={wrapperClassName}
|
||||
style={tokens.wrapperStyle}
|
||||
>
|
||||
<span>{props.label}</span>
|
||||
{labelSpan}
|
||||
{props.inputType === "textarea" ? (
|
||||
<textarea
|
||||
data-testid="preview-form-input"
|
||||
className={`${baseInputClass} ${tokens.widthClass}`}
|
||||
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={`${baseInputClass} ${tokens.widthClass}`}
|
||||
className={`pb-input ${tokens.widthClass}`}
|
||||
style={tokens.inputStyle}
|
||||
type={props.inputType === "email" ? "email" : "text"}
|
||||
name={props.formFieldName}
|
||||
required={isRequired}
|
||||
placeholder={props.placeholder || props.label}
|
||||
/>
|
||||
)}
|
||||
@@ -163,17 +443,45 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
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="flex flex-col gap-1 text-xs text-slate-200">
|
||||
<span>{props.label}</span>
|
||||
<label key={block.id} className={wrapperClassName} style={wrapperStyle}>
|
||||
{labelSpan}
|
||||
<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"
|
||||
className="pb-select"
|
||||
style={tokens.selectStyle}
|
||||
name={props.formFieldName}
|
||||
required={isRequired}
|
||||
defaultValue={props.options[0]?.value ?? ""}
|
||||
>
|
||||
{props.options.map((opt) => (
|
||||
@@ -192,14 +500,45 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
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={["flex flex-col gap-1", tokens.textSizeClass, "text-slate-200", tokens.widthClass]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={tokens.groupStyle}
|
||||
className={[layoutClass, baseClass].filter(Boolean).join(" ")}
|
||||
style={groupStyle}
|
||||
>
|
||||
{groupLabelMode === "image" && props.groupLabelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -210,19 +549,26 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
style={tokens.groupTextStyle}
|
||||
/>
|
||||
) : (
|
||||
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
|
||||
groupLabelSpan
|
||||
)}
|
||||
<div className="flex flex-col" data-testid="preview-form-checkbox-options" style={tokens.optionsStyle}>
|
||||
{props.options.map((opt) => (
|
||||
<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="inline-flex items-center gap-1"
|
||||
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
|
||||
@@ -249,14 +595,45 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
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={["flex flex-col gap-1", tokens.textSizeClass, "text-slate-200", tokens.widthClass]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={tokens.groupStyle}
|
||||
className={[layoutClass, baseClass].filter(Boolean).join(" ")}
|
||||
style={groupStyle}
|
||||
>
|
||||
{groupLabelMode === "image" && props.groupLabelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -267,19 +644,25 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
style={tokens.groupTextStyle}
|
||||
/>
|
||||
) : (
|
||||
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
|
||||
groupLabelSpan
|
||||
)}
|
||||
<div className="flex flex-col" data-testid="preview-form-radio-options" style={tokens.optionsStyle}>
|
||||
{props.options.map((opt) => (
|
||||
<div
|
||||
className={optionsLayoutClass}
|
||||
data-testid="preview-form-radio-options"
|
||||
style={tokens.optionsStyle}
|
||||
>
|
||||
{props.options.map((opt, index) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="inline-flex items-center gap-1"
|
||||
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
|
||||
@@ -339,7 +722,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
|
||||
let innerContent: React.ReactNode;
|
||||
if (!hasImage) {
|
||||
innerContent = <span className="whitespace-pre-wrap">{props.label}</span>;
|
||||
innerContent = props.label;
|
||||
} else {
|
||||
const src = (props as any).imageSrc as string;
|
||||
const altRaw = (props as any).imageAlt as string | undefined;
|
||||
@@ -372,11 +755,57 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
);
|
||||
}
|
||||
|
||||
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}>
|
||||
<a href={props.href} className={buttonClassName} style={buttonStyle}>
|
||||
{innerContent}
|
||||
</a>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -532,10 +961,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
)}
|
||||
</div>
|
||||
{props.captionText && props.captionText.trim() !== "" && (
|
||||
<p
|
||||
data-testid="preview-video-caption"
|
||||
className="mt-2 text-xs text-slate-400 hidden"
|
||||
>
|
||||
<p data-testid="preview-video-caption" className="pb-video-caption">
|
||||
{props.captionText}
|
||||
</p>
|
||||
)}
|
||||
@@ -545,184 +971,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
}
|
||||
|
||||
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)} />
|
||||
{projectSlug && projectSlug.trim() !== "" ? (
|
||||
<input type="hidden" name="__projectSlug" value={projectSlug.trim()} />
|
||||
) : null}
|
||||
{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>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (block.type === "image") {
|
||||
@@ -748,18 +997,19 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
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);
|
||||
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={`${backgroundClass} ${typeof props.paddingYPx === "number" ? "" : paddingYClass}`}
|
||||
className={exportTokens.sectionClasses}
|
||||
style={tokens.sectionStyle}
|
||||
>
|
||||
{tokens.hasBackgroundVideo && tokens.backgroundVideoSrc && (
|
||||
@@ -775,20 +1025,27 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
)}
|
||||
<div
|
||||
data-testid="preview-section-inner"
|
||||
className={`mx-auto ${typeof props.maxWidthPx === "number" ? "" : maxWidthClass} px-4`}
|
||||
className="pb-section-inner"
|
||||
style={tokens.innerWrapperStyle}
|
||||
>
|
||||
<div
|
||||
data-testid="preview-section-columns"
|
||||
className={`flex ${typeof props.gapXPx === "number" ? "" : gapXClass} ${alignItemsClass}`}
|
||||
style={tokens.columnsContainerStyle}
|
||||
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="flex flex-col gap-4" style={{ flexBasis: basis }}>
|
||||
<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>
|
||||
))}
|
||||
@@ -803,10 +1060,10 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col text-slate-50">
|
||||
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 페이지 상단에 노출 */}
|
||||
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 pb-root 내에 페이지 상단에 노출 */}
|
||||
{rootBlocks.length > 0 && (
|
||||
<section className="py-12">
|
||||
<div className="mx-auto max-w-3xl px-4 flex flex-col gap-4">
|
||||
<section className="pb-root">
|
||||
<div className="pb-root-inner">
|
||||
{rootBlocks.map((b) => (
|
||||
<div key={b.id}>{renderBlock(b)}</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user