@restnfeel/agentc-starter-kit
Version:
한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템
459 lines (428 loc) • 13.8 kB
text/typescript
import { z } from "zod";
import React from "react";
// 기본 validation 스키마들
export const colorSchema = z
.string()
.regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, {
message: "올바른 색상 코드를 입력해주세요 (예: #FF0000)",
});
export const urlSchema = z
.string()
.url({
message: "올바른 URL을 입력해주세요",
})
.or(z.literal(""));
export const emailSchema = z
.string()
.email({
message: "올바른 이메일 주소를 입력해주세요",
})
.or(z.literal(""));
export const numberRangeSchema = (min: number, max: number) =>
z
.number()
.min(min, { message: `${min} 이상의 값을 입력해주세요` })
.max(max, { message: `${max} 이하의 값을 입력해주세요` });
export const stringLengthSchema = (min: number, max: number) =>
z
.string()
.min(min, { message: `최소 ${min}자 이상 입력해주세요` })
.max(max, { message: `최대 ${max}자까지 입력 가능합니다` });
// Image Content 스키마
export const imageContentSchema = z.object({
url: urlSchema,
alt: z.string().min(1, { message: "대체 텍스트를 입력해주세요" }),
width: z.number().optional(),
height: z.number().optional(),
caption: z.string().optional(),
});
// Video Content 스키마
export const videoContentSchema = z.object({
url: urlSchema,
thumbnail: urlSchema.optional(),
autoplay: z.boolean(),
loop: z.boolean(),
muted: z.boolean(),
controls: z.boolean(),
});
// Button Content 스키마
export const buttonContentSchema = z.object({
text: stringLengthSchema(1, 50),
url: urlSchema.optional(),
type: z.enum(["primary", "secondary", "outline", "ghost"]),
size: z.enum(["small", "medium", "large"]),
icon: z
.object({
name: z.string(),
library: z.enum(["heroicons", "lucide", "feather", "custom"]),
size: numberRangeSchema(12, 64),
color: colorSchema.optional(),
})
.optional(),
onClick: z.string().optional(),
});
// Background Style 스키마
export const backgroundStyleSchema = z.object({
type: z.enum(["color", "gradient", "image", "video"]),
color: colorSchema.optional(),
gradient: z
.object({
direction: numberRangeSchema(0, 360),
colors: z.array(
z.object({
color: colorSchema,
position: numberRangeSchema(0, 100),
})
),
})
.optional(),
image: z
.object({
url: urlSchema,
position: z.enum(["center", "top", "bottom", "left", "right"]),
size: z.enum(["cover", "contain", "auto"]),
repeat: z.enum(["no-repeat", "repeat", "repeat-x", "repeat-y"]),
overlay: colorSchema.optional(),
})
.optional(),
video: videoContentSchema.optional(),
});
// Spacing Style 스키마
export const spacingStyleSchema = z.object({
padding: z.object({
top: numberRangeSchema(0, 200),
right: numberRangeSchema(0, 200),
bottom: numberRangeSchema(0, 200),
left: numberRangeSchema(0, 200),
}),
margin: z.object({
top: numberRangeSchema(-100, 100),
right: numberRangeSchema(-100, 100),
bottom: numberRangeSchema(-100, 100),
left: numberRangeSchema(-100, 100),
}),
});
// Typography Style 스키마
export const typographyStyleSchema = z.object({
fontFamily: z.string().min(1, { message: "폰트 패밀리를 선택해주세요" }),
fontSize: numberRangeSchema(8, 128),
fontWeight: z.enum([
"100",
"200",
"300",
"400",
"500",
"600",
"700",
"800",
"900",
]),
lineHeight: numberRangeSchema(0.5, 3),
letterSpacing: numberRangeSchema(-5, 10),
textAlign: z.enum(["left", "center", "right", "justify"]),
textDecoration: z.enum(["none", "underline", "line-through"]),
textTransform: z.enum(["none", "uppercase", "lowercase", "capitalize"]),
});
// Hero Section Content 스키마
export const heroSectionContentSchema = z.object({
title: stringLengthSchema(1, 100),
subtitle: stringLengthSchema(0, 150).optional(),
description: stringLengthSchema(0, 300).optional(),
image: imageContentSchema.optional(),
video: videoContentSchema.optional(),
buttons: z.array(buttonContentSchema).max(3, {
message: "최대 3개의 버튼까지 추가할 수 있습니다",
}),
overlay: z
.object({
color: colorSchema,
opacity: numberRangeSchema(0, 1),
gradient: z
.object({
direction: numberRangeSchema(0, 360),
colors: z.array(
z.object({
color: colorSchema,
position: numberRangeSchema(0, 100),
})
),
})
.optional(),
})
.optional(),
});
// Features Section Content 스키마
export const featuresSectionContentSchema = z.object({
title: stringLengthSchema(1, 100),
description: stringLengthSchema(0, 300).optional(),
features: z
.array(
z.object({
id: z.string(),
title: stringLengthSchema(1, 50),
description: stringLengthSchema(1, 200),
icon: z
.object({
name: z.string(),
library: z.enum(["heroicons", "lucide", "feather", "custom"]),
size: numberRangeSchema(16, 64),
color: colorSchema.optional(),
})
.optional(),
image: imageContentSchema.optional(),
link: z
.object({
url: urlSchema,
text: stringLengthSchema(1, 30),
target: z.enum(["_self", "_blank", "_parent", "_top"]),
rel: z.string().optional(),
})
.optional(),
})
)
.min(1, { message: "최소 1개의 기능을 추가해주세요" }),
layout: z.enum(["grid", "list", "carousel"]),
columns: numberRangeSchema(1, 4),
});
// CTA Section Content 스키마
export const ctaSectionContentSchema = z.object({
title: stringLengthSchema(1, 100),
description: stringLengthSchema(0, 300).optional(),
button: buttonContentSchema,
background: z
.object({
type: z.enum(["color", "gradient", "image", "video"]),
value: z.string().min(1, { message: "배경 값을 설정해주세요" }),
overlay: z
.object({
color: colorSchema,
opacity: numberRangeSchema(0, 1),
gradient: z
.object({
direction: numberRangeSchema(0, 360),
colors: z.array(
z.object({
color: colorSchema,
position: numberRangeSchema(0, 100),
})
),
})
.optional(),
})
.optional(),
})
.optional(),
layout: z.enum(["centered", "split", "banner"]),
});
// Pricing Section Content 스키마
export const pricingSectionContentSchema = z.object({
title: stringLengthSchema(1, 100),
description: stringLengthSchema(0, 300).optional(),
plans: z
.array(
z.object({
id: z.string(),
name: stringLengthSchema(1, 30),
description: stringLengthSchema(0, 100).optional(),
price: z.number().min(0, { message: "가격은 0 이상이어야 합니다" }),
currency: z.string().length(3, {
message: "통화 코드는 3글자여야 합니다 (예: KRW, USD)",
}),
billing: z.enum(["monthly", "yearly"]),
features: z.array(
z.string().min(1, { message: "기능 설명을 입력해주세요" })
),
featured: z.boolean(),
button: buttonContentSchema,
})
)
.min(1, { message: "최소 1개의 요금제를 추가해주세요" }),
layout: z.enum(["cards", "table", "comparison"]),
billing: z.enum(["monthly", "yearly", "both"]),
});
// Text Section Content 스키마
export const textContentSchema = z.object({
title: stringLengthSchema(1, 100),
content: z.string().min(1, { message: "내용을 입력해주세요" }),
textAlign: z.enum(["left", "center", "right"]),
fontSize: numberRangeSchema(12, 72),
fontWeight: z.enum(["normal", "bold", "light"]),
color: colorSchema.optional(),
});
// Gallery Section Content 스키마
export const galleryContentSchema = z.object({
title: stringLengthSchema(1, 100).optional(),
description: stringLengthSchema(0, 300).optional(),
images: z
.array(
z.object({
id: z.string(),
src: urlSchema,
alt: z.string(),
title: z.string().optional(),
description: z.string().optional(),
})
)
.min(1, { message: "최소 1개의 이미지가 필요합니다" }),
layout: z.enum(["grid", "masonry", "carousel"]),
columns: numberRangeSchema(1, 6),
});
// Contact Section Content 스키마
export const contactContentSchema = z.object({
title: stringLengthSchema(1, 100),
description: stringLengthSchema(0, 300).optional(),
form: z.object({
fields: z.array(
z.object({
id: z.string(),
type: z.enum(["text", "email", "tel", "textarea", "select"]),
label: z.string(),
placeholder: z.string().optional(),
required: z.boolean(),
options: z.array(z.string()).optional(),
})
),
submitText: z.string(),
action: urlSchema.optional(),
}),
contact: z.object({
email: emailSchema.optional(),
phone: z.string().optional(),
address: z.string().optional(),
hours: z.string().optional(),
}),
map: z
.object({
enabled: z.boolean(),
lat: z.number().optional(),
lng: z.number().optional(),
zoom: numberRangeSchema(1, 20).optional(),
})
.optional(),
});
// Section Content Union 스키마
export const sectionContentSchema = z.object({
hero: heroSectionContentSchema.optional(),
text: textContentSchema.optional(),
image: imageContentSchema.optional(),
gallery: galleryContentSchema.optional(),
contact: contactContentSchema.optional(),
custom: z.record(z.any()).optional(),
});
// Complete Section 스키마
export const sectionSchema = z.object({
id: z.string().min(1, { message: "섹션 ID는 필수입니다" }),
type: z.enum(["hero", "text", "image", "gallery", "contact", "custom"]),
name: z.string().min(1, { message: "섹션 이름은 필수입니다" }),
order: z.number().int().min(0),
isActive: z.boolean(),
isLocked: z.boolean(),
content: sectionContentSchema,
style: z.object({
backgroundColor: colorSchema.optional(),
backgroundImage: urlSchema.optional(),
padding: z.object({
top: numberRangeSchema(0, 200),
bottom: numberRangeSchema(0, 200),
left: numberRangeSchema(0, 200),
right: numberRangeSchema(0, 200),
}),
margin: z.object({
top: numberRangeSchema(0, 200),
bottom: numberRangeSchema(0, 200),
left: numberRangeSchema(0, 200),
right: numberRangeSchema(0, 200),
}),
border: z.object({
width: numberRangeSchema(0, 20),
style: z.enum(["none", "solid", "dashed", "dotted"]),
color: colorSchema.optional(),
radius: numberRangeSchema(0, 50),
}),
shadow: z.object({
x: numberRangeSchema(-20, 20),
y: numberRangeSchema(-20, 20),
blur: numberRangeSchema(0, 50),
spread: numberRangeSchema(-20, 20),
color: colorSchema.optional(),
}),
animation: z.object({
type: z.enum(["none", "fade", "slide", "zoom", "bounce", "custom"]),
duration: numberRangeSchema(0, 5000),
delay: numberRangeSchema(0, 2000),
easing: z.enum(["ease", "ease-in", "ease-out", "ease-in-out", "linear"]),
}),
responsive: z.object({
mobile: z.object({
display: z.boolean(),
width: z.string().optional(),
height: z.string().optional(),
}),
tablet: z.object({
display: z.boolean(),
width: z.string().optional(),
height: z.string().optional(),
}),
desktop: z.object({
display: z.boolean(),
width: z.string().optional(),
height: z.string().optional(),
}),
}),
customCSS: z.string().optional(),
}),
metadata: z.object({
createdAt: z.date(),
updatedAt: z.date(),
version: z.number().int().min(1),
author: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
}),
});
// Validation utility functions
export const validateSection = (data: any) => {
return sectionSchema.safeParse(data);
};
export const validateFormField = (value: any, schema: z.ZodSchema) => {
return schema.safeParse(value);
};
export const formatValidationErrors = (
error: z.ZodError
): ValidationError[] => {
return error.errors.map((err) => ({
field: err.path.join("."),
message: err.message,
code: err.code,
}));
};
// Field-specific validation helpers
export const validateColor = (color: string) => colorSchema.safeParse(color);
export const validateUrl = (url: string) => urlSchema.safeParse(url);
export const validateEmail = (email: string) => emailSchema.safeParse(email);
// Validation presets for common use cases
export const createStringValidator = (min: number = 0, max: number = 1000) =>
stringLengthSchema(min, max);
export const createNumberValidator = (min: number = 0, max: number = 1000) =>
numberRangeSchema(min, max);
// Real-time validation hook
export const useFieldValidation = (value: any, schema: z.ZodSchema) => {
const [isValid, setIsValid] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
const result = schema.safeParse(value);
setIsValid(result.success);
setError(
result.success
? null
: result.error.errors[0]?.message || "유효하지 않은 값입니다"
);
}, [value, schema]);
return { isValid, error };
};
// Validation error 타입
export interface ValidationError {
field: string;
message: string;
code: string;
}