With You는 사용자가 직접 아름다운 모바일 청첩장을 만들고 공유할 수 있는 웹 서비스입니다. 결혼식 정보, 웨딩 사진, 혼주 연락처, RSVP 기능을 모두 담아 하객에게 특별한 초대 경험을 제공합니다.
-- 청첩장 정보
CREATE TABLE invitations (
id INT PRIMARY KEY AUTO_INCREMENT,
userId INT NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
groomName VARCHAR(100),
brideName VARCHAR(100),
weddingDate DATE,
weddingTime TIME,
venueName VARCHAR(255),
venueAddress VARCHAR(255),
venueDetail TEXT,
greetingMessage TEXT,
theme ENUM('romantic-pink', 'natural-beige', 'modern-white', 'elegance-gold', 'forest-green'),
galleryLayout ENUM('grid', 'swipe', 'list'),
downloadProtection BOOLEAN DEFAULT true,
isPublished BOOLEAN DEFAULT false,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 혼주 정보
CREATE TABLE parents (
id INT PRIMARY KEY AUTO_INCREMENT,
invitationId INT NOT NULL UNIQUE,
groomFatherName VARCHAR(100),
groomFatherPhone VARCHAR(20),
groomFatherDeceased BOOLEAN DEFAULT false,
groomMotherName VARCHAR(100),
groomMotherPhone VARCHAR(20),
groomMotherDeceased BOOLEAN DEFAULT false,
brideFatherName VARCHAR(100),
brideFatherPhone VARCHAR(20),
brideFatherDeceased BOOLEAN DEFAULT false,
brideMotherName VARCHAR(100),
brideMotherPhone VARCHAR(20),
brideMotherDeceased BOOLEAN DEFAULT false,
FOREIGN KEY (invitationId) REFERENCES invitations(id) ON DELETE CASCADE
);
-- 갤러리 이미지
CREATE TABLE gallery_images (
id INT PRIMARY KEY AUTO_INCREMENT,
invitationId INT NOT NULL,
storageKey VARCHAR(255) NOT NULL,
storageUrl VARCHAR(500) NOT NULL,
sortOrder INT NOT NULL,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (invitationId) REFERENCES invitations(id) ON DELETE CASCADE
);
-- RSVP 응답
CREATE TABLE rsvp_responses (
id INT PRIMARY KEY AUTO_INCREMENT,
invitationId INT NOT NULL,
guestName VARCHAR(100) NOT NULL,
guestPhone VARCHAR(20),
attendance ENUM('attending', 'not-attending') NOT NULL,
guestCount INT DEFAULT 1,
mealChoice ENUM('korean', 'western', 'none') DEFAULT 'none',
message TEXT,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (invitationId) REFERENCES invitations(id) ON DELETE CASCADE
);
프로젝트에서 제공된 우아한 웨딩 청첩장 디자인을 기반으로 CSS 변수로 정의했습니다:
:root {
/* Primary Colors */
--ivory: #FBF5EF; /* 배경 */
--paper: #F6EEE5; /* 보조 배경 */
--wine: #8B3A42; /* 주요 강조색 */
--wine-deep: #6E2C33; /* 진한 강조색 */
/* Accent Colors */
--blush: #F3DCD4; /* 밝은 강조색 */
--sage: #9CAF93; /* 자연스러운 녹색 */
--sage-deep: #74876B; /* 진한 녹색 */
--gold: #C8A464; /* 고급스러운 금색 */
/* Text Colors */
--ink: #3D332E; /* 기본 텍스트 */
--ink-soft: #7A6F68; /* 보조 텍스트 */
--line: rgba(139, 58, 66, 0.16); /* 구분선 */
}
5가지 테마를 CSS 클래스로 구현:
const THEME_COLORS = {
'romantic-pink': {
bg: '#FBF5EF',
accent: '#8B3A42',
text: '#3D332E',
},
'natural-beige': {
bg: '#F6EEE5',
accent: '#9CAF93',
text: '#3D332E',
},
'modern-white': {
bg: '#fafafa',
accent: '#3D332E',
text: '#3D332E',
},
'elegance-gold': {
bg: '#1a1410',
accent: '#C8A464',
text: '#f5e8d0',
},
'forest-green': {
bg: '#f3f6f2',
accent: '#74876B',
text: '#1a2820',
},
};
// server/routers.ts
export const appRouter = router({
invitations: router({
create: protectedProcedure
.input(z.object({
groomName: z.string(),
brideName: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const slug = nanoid(8);
return await db.insertInvitation({
userId: ctx.user.id,
slug,
...input,
});
}),
getBySlug: publicProcedure
.input(z.object({ slug: z.string(), preview: z.boolean().optional() }))
.query(async ({ input }) => {
const inv = await db.getBySlug(input.slug);
if (!inv || (!inv.isPublished && !input.preview)) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return inv;
}),
}),
});
장점:
// server/storage.ts
import { storagePut } from "./server/storage";
const { url: imageUrl } = await storagePut(
`${userId}-gallery/${fileName}.jpg`,
fileBuffer,
"image/jpeg"
);
// 반환된 URL: /manus-storage/{key}
이점:
세 가지 갤러리 레이아웃을 React 컴포넌트로 구현:
// Grid Layout - 바둑판형
function GridGallery({ images }: { images: GalleryImage[] }) {
return (
<div className="grid grid-cols-3 gap-1">
{images.map((img) => (
<img key={img.id} src={img.storageUrl} alt="" />
))}
</div>
);
}
// Swipe Layout - 슬라이드형
function SwipeGallery({ images }: { images: GalleryImage[] }) {
const [current, setCurrent] = useState(0);
return (
<div className="overflow-hidden">
<div
className="flex transition-transform duration-300"
style={{ transform: `translateX(-${current * 100}%)` }}
>
{images.map((img) => (
<img key={img.id} src={img.storageUrl} alt="" className="flex-shrink-0 w-full" />
))}
</div>
</div>
);
}
// List Layout - 펼쳐보기형
function ListGallery({ images }: { images: GalleryImage[] }) {
return (
<div className="space-y-2">
{images.map((img) => (
<img key={img.id} src={img.storageUrl} alt="" className="w-full rounded" />
))}
</div>
);
}
// 하객 응답 제출
const submitRSVP = trpc.rsvp.submit.useMutation({
onSuccess: () => {
toast.success("참석 여부가 등록되었습니다!");
setShowThanks(true);
},
});
// 청첩장 주인 알림
const notifyOwner = async (invitationId: number) => {
await notifyOwner({
title: "새로운 RSVP 응답이 있습니다",
content: `${guestName}님이 참석 여부를 제출했습니다.`,
});
};
// 우클릭 방지
<div onContextMenu={protect ? (e) => e.preventDefault() : undefined}>
<img
src={imageUrl}
className={protect ? "no-download" : ""}
draggable={false}
/>
</div>
// CSS
.no-download {
-webkit-user-select: none;
user-select: none;
pointer-events: none;
}
.no-download img {
pointer-events: none;
-webkit-user-drag: none;
}
Vitest를 활용한 유닛 테스트 작성:
// server/invitations.test.ts
describe("invitations", () => {
it("creates a new invitation", async () => {
const caller = appRouter.createCaller(mockContext);
const result = await caller.invitations.create({
groomName: "이도현",
brideName: "박서연",
});
expect(result.slug).toBeDefined();
expect(result.userId).toBe(mockContext.user.id);
});
it("rejects non-public invitations without preview flag", async () => {
const caller = appRouter.createCaller(publicContext);
await expect(
caller.invitations.getBySlug({
slug: "private-slug",
preview: false,
})
).rejects.toThrow("NOT_FOUND");
});
});
테스트 결과: 13/13 통과 ✅
이미지 최적화
번들 최적화
렌더링 최적화