TDD,E2E 개선, 미적용 스타일 개선
CI / test (push) Failing after 2m54s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-12-07 09:52:42 +09:00
parent e726f43f7c
commit a5b432fb7b
167 changed files with 7397 additions and 663 deletions
@@ -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>
))}
+7
View File
@@ -559,6 +559,7 @@ export interface FormFieldStyleProps {
optionGapPx?: number;
labelGapPx?: number;
labelLayout?: "stacked" | "inline";
optionLayout?: "stacked" | "inline";
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
// 필드 텍스트 색상/타이포그라피
fontSizeCustom?: string;
@@ -580,6 +581,7 @@ export interface FormInputBlockProps extends FormFieldStyleProps {
inputType?: "text" | "email" | "textarea";
placeholder?: string;
labelMode?: FormLabelMode;
labelDisplay?: "visible" | "hidden" | "floating";
labelImageUrl?: string;
labelImageAlt?: string;
}
@@ -596,6 +598,7 @@ export interface FormSelectBlockProps extends FormFieldStyleProps {
options: FormSelectOption[];
required?: boolean;
labelMode?: FormLabelMode;
labelDisplay?: "visible" | "hidden";
labelImageUrl?: string;
labelImageAlt?: string;
}
@@ -615,6 +618,7 @@ export interface FormCheckboxBlockProps extends FormFieldStyleProps {
required?: boolean;
// 그룹 타이틀 자체도 텍스트/이미지 모드를 가질 수 있도록 분리한다.
groupLabelMode?: FormLabelMode;
groupLabelDisplay?: "visible" | "hidden";
groupLabelImageUrl?: string;
groupLabelImageSource?: "url" | "upload";
optionImageSource?: "url" | "upload";
@@ -635,6 +639,7 @@ export interface FormRadioBlockProps extends FormFieldStyleProps {
required?: boolean;
// 라디오 그룹 타이틀의 텍스트/이미지 모드
groupLabelMode?: FormLabelMode;
groupLabelDisplay?: "visible" | "hidden";
groupLabelImageUrl?: string;
groupLabelImageSource?: "url" | "upload";
optionImageSource?: "url" | "upload";
@@ -671,6 +676,8 @@ export interface FormBlockProps {
fields?: FormFieldConfig[];
// v2 컨트롤러: 개별 폼 요소 블록과 제출 버튼을 연결하기 위한 ID 목록
fieldIds?: string[];
// v2 컨트롤러: fieldIds 로 연결된 필드 중 어떤 필드를 필수로 처리할지 관리하는 ID 목록
requiredFieldIds?: string[];
submitButtonId?: string | null;
// 폼 레이아웃
formWidthMode?: "auto" | "full" | "fixed";
+47 -8
View File
@@ -18,6 +18,9 @@ export interface ButtonStyleInput {
widthPx?: number | null | undefined;
paddingX?: number | null | undefined;
paddingY?: number | null | undefined;
fontSizeCustom?: string | null | undefined;
lineHeightCustom?: string | null | undefined;
letterSpacingCustom?: string | null | undefined;
fillColorCustom?: string | null | undefined;
strokeColorCustom?: string | null | undefined;
textColorCustom?: string | null | undefined;
@@ -68,15 +71,13 @@ export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
}
if (typeof input.paddingX === "number") {
const paddingInline = pxToEm(input.paddingX);
inlineStyles.push(`padding-left:${paddingInline}`);
inlineStyles.push(`padding-right:${paddingInline}`);
inlineStyles.push(`padding-left:${pxToEm(input.paddingX)}`);
inlineStyles.push(`padding-right:${pxToEm(input.paddingX)}`);
}
if (typeof input.paddingY === "number") {
const paddingBlock = pxToEm(input.paddingY);
inlineStyles.push(`padding-top:${paddingBlock}`);
inlineStyles.push(`padding-bottom:${paddingBlock}`);
inlineStyles.push(`padding-top:${pxToEm(input.paddingY)}`);
inlineStyles.push(`padding-bottom:${pxToEm(input.paddingY)}`);
}
if (input.fillColorCustom && input.fillColorCustom.trim() !== "") {
@@ -91,6 +92,42 @@ export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
inlineStyles.push(`color:${input.textColorCustom.trim()}`);
}
if (input.fontSizeCustom && input.fontSizeCustom.trim() !== "") {
const fontSizeValue = input.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
inlineStyles.push(`font-size:${fontSizeEm}`);
}
} else {
inlineStyles.push(`font-size:${fontSizeValue}`);
}
}
if (input.lineHeightCustom && input.lineHeightCustom.trim() !== "") {
const lineHeightValue = input.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
inlineStyles.push(`line-height:${lineHeightEm}`);
}
} else {
inlineStyles.push(`line-height:${lineHeightValue}`);
}
}
if (input.letterSpacingCustom && input.letterSpacingCustom.trim() !== "") {
const letterSpacingValue = input.letterSpacingCustom.trim();
if (letterSpacingValue.endsWith("px")) {
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
if (letterSpacingEm) {
inlineStyles.push(`letter-spacing:${letterSpacingEm}`);
}
} else {
inlineStyles.push(`letter-spacing:${letterSpacingValue}`);
}
}
return {
alignClass,
sizeClass,
@@ -155,11 +192,13 @@ export function computeButtonPublicTokens(props: ButtonBlockProps): ButtonPublic
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
styleOverrides.paddingInline = pxToEm(props.paddingX);
const paddingEm = pxToEm(props.paddingX);
styleOverrides.paddingInline = paddingEm;
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
styleOverrides.paddingBlock = pxToEm(props.paddingY);
const paddingEm = pxToEm(props.paddingY);
styleOverrides.paddingBlock = paddingEm;
}
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
+27 -1
View File
@@ -21,7 +21,33 @@ export const computeDividerExportTokens = (
const marginY = typeof props.marginYPx === "number" && props.marginYPx > 0 ? props.marginYPx : 16;
const marginEm = pxToEm(marginY);
const style = `border:0;border-bottom:${thickness} solid ${escapeAttr(color)};margin:${marginEm} 0;`;
const widthMode = props.widthMode ?? "full";
const align = props.align ?? "left";
const styleParts: string[] = [];
styleParts.push("border:0");
styleParts.push(`border-bottom:${thickness} solid ${escapeAttr(color)}`);
styleParts.push(`margin:${marginEm} 0`);
if (widthMode === "auto") {
styleParts.push("width:50%");
} else if (widthMode === "fixed") {
const widthPx =
typeof props.widthPx === "number" && props.widthPx > 0 ? props.widthPx : 320;
styleParts.push(`width:${pxToEm(widthPx)}`);
}
if (widthMode === "auto" || widthMode === "fixed") {
if (align === "center") {
styleParts.push("margin-left:auto");
styleParts.push("margin-right:auto");
} else if (align === "right") {
styleParts.push("margin-left:auto");
styleParts.push("margin-right:0");
}
}
const style = `${styleParts.join(";")};`;
return { style };
};
+110 -53
View File
@@ -42,6 +42,12 @@ const normalizeTextColor = (raw: unknown): string => {
return trimmed !== "" ? trimmed : "";
};
// Export/Public 레이어용 모서리 둥글기 매핑
// - none: 0px
// - sm: 2px
// - lg: 6px
// - full: 9999px (완전 둥근 모서리)
// - md(기본): 4px
const computeRadiusPx = (radius: unknown): number => {
const token = typeof radius === "string" ? radius : "md";
if (token === "none") return 0;
@@ -51,6 +57,19 @@ const computeRadiusPx = (radius: unknown): number => {
return 4;
};
// Editor 레이어용 모서리 둥글기 매핑
// - none: 0px
// - sm: 4px
// - md: 8px (기본)
// - lg/full: 9999px (pill 스타일)
const computeEditorRadiusPx = (radius: unknown): number => {
const token = typeof radius === "string" ? radius : "md";
if (token === "none") return 0;
if (token === "sm") return 4;
if (token === "lg" || token === "full") return 9999;
return 8;
};
const computeWidthMode = (props: { widthMode?: string; fullWidth?: boolean }): "auto" | "full" | "fixed" => {
if (typeof props.widthMode === "string") {
return props.widthMode as "auto" | "full" | "fixed";
@@ -101,9 +120,68 @@ export const computeFormInputExportTokens = (
inputStyleParts.push(`width:${em}em`);
}
const radiusPx = computeRadiusPx(props.borderRadius);
const radiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md";
let radiusPx = 4;
if (radiusToken === "none") {
radiusPx = 0;
} else if (radiusToken === "sm") {
radiusPx = 2;
} else if (radiusToken === "lg") {
radiusPx = 6;
} else if (radiusToken === "full") {
radiusPx = 9999;
}
inputStyleParts.push(`border-radius:${radiusPx}px`);
// 폰트 관련 커스텀 값: px 단위일 경우 em 으로 변환하고, 그렇지 않으면 원문 값을 그대로 사용한다.
if (typeof props.fontSizeCustom === "string" && props.fontSizeCustom.trim() !== "") {
const raw = props.fontSizeCustom.trim();
if (raw.endsWith("px")) {
const match = raw.match(/-?\d+(?:\.\d+)?/);
if (match) {
const px = parseFloat(match[0]);
if (Number.isFinite(px)) {
const em = px / 16;
inputStyleParts.push(`font-size:${em}em`);
}
}
} else {
inputStyleParts.push(`font-size:${escapeAttr(raw)}`);
}
}
if (typeof props.lineHeightCustom === "string" && props.lineHeightCustom.trim() !== "") {
const raw = props.lineHeightCustom.trim();
if (raw.endsWith("px")) {
const match = raw.match(/-?\d+(?:\.\d+)?/);
if (match) {
const px = parseFloat(match[0]);
if (Number.isFinite(px)) {
const em = px / 16;
inputStyleParts.push(`line-height:${em}em`);
}
}
} else {
inputStyleParts.push(`line-height:${escapeAttr(raw)}`);
}
}
if (typeof props.letterSpacingCustom === "string" && props.letterSpacingCustom.trim() !== "") {
const raw = props.letterSpacingCustom.trim();
if (raw.endsWith("px")) {
const match = raw.match(/-?\d+(?:\.\d+)?/);
if (match) {
const px = parseFloat(match[0]);
if (Number.isFinite(px)) {
const em = px / 16;
inputStyleParts.push(`letter-spacing:${em}em`);
}
}
} else {
inputStyleParts.push(`letter-spacing:${escapeAttr(raw)}`);
}
}
const inputStyleAttr = inputStyleParts.length > 0 ? ` style="${inputStyleParts.join(";")}"` : "";
return {
@@ -254,14 +332,9 @@ export const computeFormInputEditorTokens = (props: FormInputBlockProps): FormIn
fieldStyle.width = `${props.widthPx}px`;
}
const radius = props.borderRadius ?? "md";
if (radius === "none") {
fieldStyle.borderRadius = 0;
} else if (radius === "sm") {
fieldStyle.borderRadius = 4;
} else if (radius === "lg" || radius === "full") {
fieldStyle.borderRadius = 9999;
}
// 모서리 둥글기: Editor 레이어에서는 lg/full 토큰을 pill 형태(9999px)로 사용한다.
const radiusPx = computeEditorRadiusPx(props.borderRadius);
fieldStyle.borderRadius = radiusPx;
// 에디터에서는 px 기반 padding/타이포 값을 그대로 fieldStyle 에 반영해 미리보기에서 변화가 보이도록 한다.
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
@@ -336,14 +409,9 @@ export const computeFormSelectEditorTokens = (props: FormSelectBlockProps): Form
fieldStyle.width = `${props.widthPx}px`;
}
const radius = props.borderRadius ?? "md";
if (radius === "none") {
fieldStyle.borderRadius = 0;
} else if (radius === "sm") {
fieldStyle.borderRadius = 4;
} else if (radius === "lg" || radius === "full") {
fieldStyle.borderRadius = 9999;
}
// 모서리 둥글기: Editor 레이어에서는 lg/full 토큰을 pill 형태(9999px)로 사용한다.
const selectRadiusPx = computeEditorRadiusPx(props.borderRadius);
fieldStyle.borderRadius = selectRadiusPx;
// 체크박스/라디오 그룹도 에디터에서 padding/타이포 스타일을 일부 반영해준다.
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
@@ -389,14 +457,9 @@ export const computeFormOptionGroupEditorTokens = (
fieldStyle.width = `${props.widthPx}px`;
}
const radius = props.borderRadius ?? "md";
if (radius === "none") {
fieldStyle.borderRadius = 0;
} else if (radius === "sm") {
fieldStyle.borderRadius = 4;
} else if (radius === "lg" || radius === "full") {
fieldStyle.borderRadius = 9999;
}
// 모서리 둥글기: Editor 레이어에서는 lg/full 토큰을 pill 형태(9999px)로 사용한다.
const groupRadiusPx = computeEditorRadiusPx(props.borderRadius);
fieldStyle.borderRadius = groupRadiusPx;
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
fieldStyle.paddingInline = `${props.paddingX}px`;
@@ -535,23 +598,18 @@ export const computeFormInputPublicTokens = (props: FormInputBlockProps): FormIn
}
}
// 색상 관련 커스텀 값
// 색상 관련 커스텀 값: builder.css 의 pb-input 기본 색상을 덮어쓰지 않도록,
// 사용자가 커스텀 값을 설정했을 때에만 인라인 스타일을 적용한다.
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
inputStyle.color = props.textColorCustom.trim();
} else {
inputStyle.color = "#f9fafb";
}
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
inputStyle.backgroundColor = props.fillColorCustom.trim();
} else {
inputStyle.backgroundColor = "#020617";
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
inputStyle.borderColor = props.strokeColorCustom.trim();
} else {
inputStyle.borderColor = "#334155";
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
@@ -629,14 +687,12 @@ export const computeFormSelectPublicTokens = (props: FormSelectBlockProps): Form
}
}
// 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고, 없으면 기본 밝은 텍스트 색상을 사용한다.
// 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고,
// 없으면 builder.css 의 pb-select 기본 색상을 그대로 사용한다.
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
const colorValue = props.textColorCustom.trim();
wrapperStyle.color = colorValue;
selectStyle.color = colorValue;
} else {
wrapperStyle.color = "#f9fafb";
selectStyle.color = "#f9fafb";
}
// 배경/테두리 색상: fillColorCustom/strokeColorCustom 이 있으면 select 에 적용한다.
@@ -694,6 +750,7 @@ const computeOptionGroupPublicTokensBase = (
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
// 퍼블릭 토큰에서는 고정 너비를 em 단위로 변환해 px 단위를 피한다.
groupStyle.width = pxToEm(props.widthPx);
}
@@ -783,7 +840,12 @@ const computeOptionGroupPublicTokensBase = (
optionContainerStyle.borderRadius = `${radiusPx}px`;
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
optionsStyle.rowGap = pxToEm(props.optionGapPx);
const gapPx = props.optionGapPx;
const gapEm = pxToEm(gapPx);
optionsStyle.rowGap = gapEm;
if ((props as any).optionLayout === "inline") {
optionsStyle.columnGap = gapEm;
}
}
return {
@@ -844,6 +906,10 @@ export const computeFormControllerPublicTokens = (
.map((fieldBlock) => {
const anyProps: any = fieldBlock.props ?? {};
const isRequired = Array.isArray(props.requiredFieldIds)
? props.requiredFieldIds.includes(fieldBlock.id)
: false;
if (fieldBlock.type === "formInput") {
const name = anyProps.formFieldName ?? fieldBlock.id;
const label = anyProps.label ?? anyProps.formFieldName ?? "입력 필드";
@@ -854,7 +920,7 @@ export const computeFormControllerPublicTokens = (
label,
// 기존 PublicPageRenderer 컨트롤러 로직과 동일하게 formInput 은 항상 type "text" 로 취급한다.
type: "text",
required: Boolean(anyProps.required),
required: isRequired,
} satisfies FormControllerFieldPublicConfig;
}
@@ -869,7 +935,7 @@ export const computeFormControllerPublicTokens = (
label,
type: "select",
options,
required: Boolean(anyProps.required),
required: isRequired,
} satisfies FormControllerFieldPublicConfig;
}
@@ -886,7 +952,7 @@ export const computeFormControllerPublicTokens = (
options,
groupLabelMode: anyProps.groupLabelMode,
groupLabelImageUrl: anyProps.groupLabelImageUrl,
required: Boolean(anyProps.required),
required: isRequired,
} satisfies FormControllerFieldPublicConfig;
}
@@ -902,23 +968,14 @@ export const computeFormControllerPublicTokens = (
options,
groupLabelMode: anyProps.groupLabelMode,
groupLabelImageUrl: anyProps.groupLabelImageUrl,
required: Boolean(anyProps.required),
required: isRequired,
} satisfies FormControllerFieldPublicConfig;
})
: [];
const fields: FormControllerFieldPublicConfig[] =
hasControllerFields && controllerFields.length > 0
? controllerFields
: Array.isArray(props.fields) && props.fields.length > 0
? props.fields.map((field) => ({
id: field.id,
name: field.name,
label: field.label,
type: field.type,
required: field.required,
}))
: [];
// 프리뷰/퍼블릭 렌더러에서는 FormBlock 의 v1 fields 설정을 사용하지 않고,
// fieldIds 로 연결된 실제 폼 필드 블록들만 기반으로 폼 필드를 렌더링한다.
const fields: FormControllerFieldPublicConfig[] = controllerFields;
const mappedSubmitButton = blocks.find(
(b) => b.type === "button" && b.id === props.submitButtonId,
+22 -1
View File
@@ -5,6 +5,7 @@ export interface ListExportTokens {
Tag: "ul" | "ol";
items: string[];
listStyleParts: string[];
gapEm: number;
}
export const computeListExportTokens = (props: ListBlockProps): ListExportTokens => {
@@ -37,6 +38,21 @@ export const computeListExportTokens = (props: ListBlockProps): ListExportTokens
const align = alignToken === "center" ? "center" : alignToken === "right" ? "right" : "left";
const listStyleParts: string[] = [`text-align:${align}`];
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
const bulletStyle = bulletStyleRaw === "none" ? "none" : bulletStyleRaw;
listStyleParts.push(`list-style-type:${bulletStyle}`);
const gapPx =
typeof props.gapYPx === "number"
? props.gapYPx
: props.gapY === "sm"
? 4
: props.gapY === "lg"
? 16
: 8;
const gapEm = gapPx / 16;
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
listStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
}
@@ -45,6 +61,7 @@ export const computeListExportTokens = (props: ListBlockProps): ListExportTokens
Tag,
items,
listStyleParts,
gapEm,
};
};
@@ -106,7 +123,11 @@ const convertPxStringToEm = (value?: string | null) => {
export const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens => {
const alignClass =
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
props.align === "center"
? "pb-text-center"
: props.align === "right"
? "pb-text-right"
: "pb-text-left";
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
+22 -2
View File
@@ -1,9 +1,13 @@
import type { CSSProperties } from "react";
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
const pxToEm = (px: number, base = 16) => `${px / base}em`;
export interface SectionExportTokens {
sectionClasses: string;
sectionStyleParts: string[];
innerWrapperStyleParts: string[];
columnsStyleParts: string[];
backgroundVideoHtml: string;
}
@@ -26,6 +30,8 @@ export function computeSectionExportTokens(props: SectionBlockProps): SectionExp
const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" ");
const sectionStyleParts: string[] = [];
const innerWrapperStyleParts: string[] = [];
const columnsStyleParts: string[] = [];
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
sectionStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
@@ -65,6 +71,20 @@ export function computeSectionExportTokens(props: SectionBlockProps): SectionExp
sectionStyleParts.push(`background-repeat:${repeat}`);
}
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
const paddingEm = pxToEm(props.paddingYPx);
sectionStyleParts.push(`padding-top:${paddingEm}`);
sectionStyleParts.push(`padding-bottom:${paddingEm}`);
}
if (typeof props.maxWidthPx === "number" && props.maxWidthPx > 0) {
innerWrapperStyleParts.push(`max-width:${pxToEm(props.maxWidthPx)}`);
}
if (typeof props.gapXPx === "number" && props.gapXPx > 0) {
columnsStyleParts.push(`column-gap:${pxToEm(props.gapXPx)}`);
}
const bgVideoSrc = props.backgroundVideoSrc;
let backgroundVideoHtml = "";
if (typeof bgVideoSrc === "string" && bgVideoSrc.trim() !== "") {
@@ -77,6 +97,8 @@ export function computeSectionExportTokens(props: SectionBlockProps): SectionExp
return {
sectionClasses,
sectionStyleParts,
innerWrapperStyleParts,
columnsStyleParts,
backgroundVideoHtml,
};
}
@@ -89,8 +111,6 @@ export interface SectionPublicTokens {
backgroundVideoSrc: string | null;
}
const pxToEm = (px: number, base = 16) => `${px / base}em`;
export function computeSectionPublicTokens(props: SectionBlockProps): SectionPublicTokens {
const sectionStyle: CSSProperties = {};
+7 -6
View File
@@ -58,8 +58,10 @@ export function computeTextPbTokens(input: TextStyleInput): TextPbTokens {
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
const fontSizeMode = input.fontSizeMode ?? "scale";
const fallbackScale: TextFontSizeScale = input.size === "sm" ? "sm" : input.size === "lg" ? "lg" : "base";
const fontSizeScale: TextFontSizeScale = (input.fontSizeScale as TextFontSizeScale | null) ?? fallbackScale;
const fallbackScale: TextFontSizeScale =
input.size === "sm" ? "sm" : input.size === "lg" ? "lg" : "base";
const fontSizeScale: TextFontSizeScale =
(input.fontSizeScale as TextFontSizeScale | null) ?? fallbackScale;
const fontSizeMap: Record<TextFontSizeScale, string> = {
xs: "pb-text-xs",
@@ -71,10 +73,9 @@ export function computeTextPbTokens(input: TextStyleInput): TextPbTokens {
"3xl": "pb-text-3xl",
};
const sizeClass =
fontSizeMode === "scale" && fontSizeScale && fontSizeMap[fontSizeScale]
? fontSizeMap[fontSizeScale]
: "";
const sizeClass = fontSizeScale && fontSizeMap[fontSizeScale]
? fontSizeMap[fontSizeScale]
: "";
const lineHeightMode = input.lineHeightMode ?? "scale";
const lineHeightScale: TextLineHeightScale = (input.lineHeightScale as TextLineHeightScale | null) ?? "normal";
+1
View File
@@ -0,0 +1 @@
export { buildStaticHtml } from "@/app/api/export/route";
+25
View File
@@ -0,0 +1,25 @@
import type { Block } from "@/features/editor/state/editorStore";
interface PublicProjectSnapshot {
slug: string;
title: string;
contentJson: Block[];
}
function getStore(): Map<string, PublicProjectSnapshot> {
const g = globalThis as any;
if (!g.__PB_PUBLIC_PROJECTS) {
g.__PB_PUBLIC_PROJECTS = new Map<string, PublicProjectSnapshot>();
}
return g.__PB_PUBLIC_PROJECTS as Map<string, PublicProjectSnapshot>;
}
export function cachePublicProject(snapshot: PublicProjectSnapshot): void {
const store = getStore();
store.set(snapshot.slug, snapshot);
}
export function getCachedPublicProject(slug: string): PublicProjectSnapshot | null {
const store = getStore();
return store.get(slug) ?? null;
}