포토부스, 전세버스, 답례품이나 안내할 정보가 있으면 추가가 가능
React + TypeScript + Cloudflare Pages로 만드는 모바일 청첩장 SaaS 개발 기록입니다.
오늘은 크게 두 가지 방향으로 작업했습니다.
청첩장 커버 위에 텍스트(이름, 날짜, 장소 등)를 원하는 위치로 자유롭게 드래그해서 배치할 수 있는 기능을 구현했습니다.
Cover 섹션
├── CoverComponent (템플릿 — 기존 텍스트 그대로 유지)
├── OverlayLayer (fog/vignette 효과)
└── CoverTextOverlay (390×690 고정 박스 안에 배치)
├── 격자 레이어 (editMode && showGrid)
└── TextBlock × N (드래그 이동 / 크기 / 회전)
export interface CoverTextBlock {
id: string;
content?: string; // 미설정 시 결혼 정보 자동 표시
x: number; // 중심 X (0~100%)
y: number; // 중심 Y (0~100%)
fontSize: number; // px
color: string;
align: 'left' | 'center' | 'right';
visible: boolean;
rotation?: number; // deg
letterSpacing?: string;
}
포인터 이벤트 하나로 이동 / 크기 / 회전을 처리했습니다.
type DragMode = 'move' | 'resize' | 'rotate';
// 이동: delta를 컨테이너 크기로 나눠서 % 변환
const dx = ((e.clientX - d.startX) / d.containerW) * 100;
const dy = ((e.clientY - d.startY) / d.containerH) * 100;
// 크기: 오른쪽+아래 드래그 → 폰트 크기 증가
const delta = (e.clientX - d.startX + e.clientY - d.startY) * 0.25;
// 회전: 블록 중심 기준 atan2 계산
const angleRad = Math.atan2(e.clientY - centerY, e.clientX - centerX);
const angleDeg = angleRad * (180 / Math.PI) + 90;
const SNAP_X = [0, 25, 33.333, 50, 66.667, 75, 100];
const SNAP_Y = [0, 16.667, 25, 33.333, 50, 66.667, 75, 83.333, 100];
const SNAP_THRESHOLD = 2.5; // 2.5% 이내 자동 흡착
function snapValue(value: number, points: number[]): number {
for (const p of points) {
if (Math.abs(value - p) < SNAP_THRESHOLD) return p;
}
return value;
}
격자는 수직 3분할(33%, 50%, 66%)과 수평 7분할로 구성하고, 중앙선을 더 밝게 강조했습니다.
편집 모달을 fixed로 구현했는데 조상 요소에 transform이 있으면 fixed 포지셔닝이 그 요소 기준으로 잡히는 문제가 있었습니다.
문제: DesignTab 안에 fixed 모달 → 조상 transform에 갇힘
해결: 모달 state/JSX를 MakePage 루트 레벨로 끌어올림
(전체화면 미리보기 모달과 동일한 패턴)
편집 모달(300×532)과 실제 Cover 섹션(min-h-screen = 100vh)의 높이 비율이 달라서 저장된 % 위치가 달라 보이는 문제가 있었습니다.
해결: Cover.tsx에서 CoverTextOverlay를 390×690 고정 박스 안에 배치
편집 모달도 300×532 (= 390×690의 77% 축소, 동일 비율)
→ 어디서 렌더해도 동일한 % 기준점 사용
{textOverlayActive && (
<div style={{
position: 'absolute', top: 0, left: '50%',
transform: 'translateX(-50%)',
width: 390, height: 690,
pointerEvents: 'none',
}}>
<CoverTextOverlay blocks={design.coverTextBlocks} editMode={false} />
</div>
)}
처음에는 오버레이 활성 시 기존 커버 텍스트를 CSS로 숨기고 오버레이 텍스트로 대체하는 방식이었습니다. 그런데 기존 디자인이 사라지는 게 어색했습니다.
변경 전: coverTextOverlay ON → 기존 .cover-text-layer 숨김 + 오버레이로 대체
변경 후: 기존 텍스트 그대로 유지 + 오버레이는 추가 텍스트 레이어로만 동작
DEFAULT_COVER_TEXT_BLOCKS 모두 visible: false (처음엔 비어있음)
하객에게 전달할 주차 안내, 드레스 코드, 셔틀 정보 등을 편집할 수 있는 섹션을 새로 만들었습니다.
카테고리별(주차/식사/대중교통/드레스코드 등) 샘플 문구를 모달로 제공해서 클릭하면 바로 입력되도록 했습니다.
기존 카카오맵·네이버·구글 3버튼에서 용도별 2행으로 개편했습니다.
길찾기 행: [네이버지도] [카카오맵] [구글지도] ← 대중교통/도보/국제 사용자
내비게이션: [티맵] [카카오내비] ← 자차 하객 딥링크
티맵은 tmap:// 딥링크로 앱을 직접 실행합니다.
프리텐다드가 없다는 걸 뒤늦게 발견했습니다. 국내에서 가장 많이 쓰이는 폰트들을 추가했습니다.
| 폰트 | 용도 | CDN |
|---|---|---|
| Pretendard | 제목+본문 | jsDelivr (Light~Bold 5단계) |
| 롯데마트 드림 | 제목 | noonnu CDN |
| 에스코어 드림 (SCDream) | 제목+본문 | noonnu CDN (5단계) |
| 야놀자야체 | 제목 | noonnu CDN |
| 넥슨 Lv.1 고딕 | 본문 | noonnu CDN |
@font-face는 index.html의 <style> 블록에 직접 선언했습니다. Google Fonts에 없는 폰트들이라 CDN 직접 참조 방식을 사용했습니다.
<!-- 프리텐다드 -->
@font-face {
font-family: 'Pretendard';
src: url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/woff2-subset/Pretendard-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
전체화면 미리보기에서 인트로 효과가 샘플 데이터 기준으로 재생되는 버그가 있었습니다.
원인: IntroRenderer가 WeddingContext.Provider 바깥에 렌더되어
formData 대신 상위 샘플 컨텍스트를 읽음
해결: IntroRenderer를 WeddingContext.Provider 안으로 이동
편집 모달 컨테이너에 overflow: hidden이 있어서 선택 툴팁(-58px 위)과 회전 핸들(-36px 위)이 잘리는 문제가 있었습니다.
해결: 외부 wrapper(overflow: visible) + 내부 이미지 클립 div(overflow: hidden) 분리
→ 커버 이미지는 borderRadius로 clip, 핸들은 자유롭게 밖으로 나옴
position: fixed + 조상 transform 조합은 항상 조심해야 합니다. 모달류 컴포넌트는 가능하면 루트 레벨에 렌더하는 게 안전합니다.