/** * 엽서 쓰기 — 손님이 사진을 고르고 한 마디를 적어 그 자리에서 엽서를 만들어 공유한다. * * ★ 시연본(`scripts/mockup/inject.js` postcardMaker)에서 옮겨 왔다 (2026-09-14 대표: * "웹빌드 해도 엽서쓰기 나오게"). 시연본은 주입 스크립트라 발행본에는 없었다. * ★ 그림 규칙은 `@site/lib/postcard-canvas` 한 곳에 있다 — 여기서는 고르기·입력·공유만 한다. * ★ **파일 그대로 공유**다(대표 확정). Web Share API 가 파일을 받으면 이미지가 그대로 대화방에 * 올라간다. 못 받는 브라우저(주로 데스크톱)는 링크만 보내고, 그것도 안 되면 저장으로 떨어진다. * ★ 버튼 문구에 앱 이름을 못 박지 않는다 — 공유 시트는 OS 가 채운다. 손끝으로 쓰는 기기에서만 * "카카오톡 등으로 공유" 라고 적는다(맥에서는 메시지·AirDrop 정도만 떠서 거짓말이 된다). */ import {useEffect, useRef, useState} from 'react'; import {useSite} from '@site/lib/site-context'; import {Section} from '@site/lib/ui'; import {MAX_LINES, POSTCARD_SIZE, drawPostcard, textColumnWidth, wrapLines} from '@site/lib/postcard-canvas'; const FILE_NAME = 'postcard.png'; export function PostcardMakerSection() { const payload = useSite(); const photos = (payload.media ?? []).filter((m) => m.url); const canvasRef = useRef(null); const [picked, setPicked] = useState(0); const [text, setText] = useState(''); const [hint, setHint] = useState(''); const [touch, setTouch] = useState(false); /* 사진이 남의 도메인이면 캔버스가 오염돼 파일로 못 뽑는다(postcard-canvas.loadImage 주석). 굽는 쪽이 사진을 우리 오리진으로 옮기고부터(prerender `mirrorMedia`) 여기 걸리는 건 **내려받기에 실패한 사진**뿐이다. 그때는 미리보기만 남기고 저장·공유를 막는다 — 눌러도 안 되는 단추를 두지 않는다. */ const [exportable, setExportable] = useState(true); useEffect(() => { setTouch(window.matchMedia?.('(pointer: coarse)').matches ?? false); }, []); const placeName = payload.place.name; const region = String(payload.place.address ?? '').split(' ').slice(0, 2).join(' '); useEffect(() => { const canvas = canvasRef.current; const photo = photos[picked]; if (!canvas || !photo?.url) return; // 글자를 칠 때마다 다시 그린다. 80ms 는 시연본과 같은 값 — 한 글자마다 그리면 캔버스가 버벅인다. const timer = window.setTimeout(() => { void drawPostcard(canvas, {photoUrl: photo.url, text, placeName, region}).then(setExportable); }, 80); return () => window.clearTimeout(timer); }, [picked, text, placeName, region, photos]); // 폰트가 늦게 실리면 글씨가 기본 서체로 그려진 채 남는다 — 실리고 나서 한 번 더 그린다. useEffect(() => { const canvas = canvasRef.current; const photo = photos[picked]; if (!canvas || !photo?.url || !document.fonts?.ready) return; void document.fonts.ready.then(() => drawPostcard(canvas, {photoUrl: photo.url, text, placeName, region}).then(setExportable)); // 첫 그림 한 번만 — 이후는 위 효과가 맡는다. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (photos.length === 0) return null; /** 몇 줄로 접힐지는 폭·서체에 달려 글자 수로는 못 잰다 — 그릴 때와 같은 함수로 재서 막는다. */ function withinLineLimit(next: string): boolean { const ctx = canvasRef.current?.getContext('2d'); if (!ctx) return true; ctx.font = `600 46px serif`; return wrapLines(ctx, `“${next}”`, textColumnWidth()).length <= MAX_LINES; } function exportBlob(): Promise { return new Promise((resolve) => { const canvas = canvasRef.current; if (!canvas) return resolve(null); canvas.toBlob((blob) => resolve(blob), 'image/png', 0.95); }); } function download(blob: Blob) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = FILE_NAME; a.click(); URL.revokeObjectURL(url); } async function share() { const blob = await exportBlob(); if (!blob) return; const pageUrl = document.querySelector('meta[property="og:url"]')?.content ?? `${location.origin}${location.pathname}`; const file = new File([blob], FILE_NAME, {type: 'image/png'}); // 손님이 시트를 닫은 것도 거절로 온다 — 오류로 다루지 않는다. if (navigator.canShare?.({files: [file]})) { await navigator.share({files: [file], title: `${placeName} 엽서`, text: pageUrl}).catch(() => {}); return; } if (navigator.share) { await navigator.share({title: `${placeName} 엽서`, url: pageUrl}).catch(() => {}); setHint('이 브라우저는 사진을 같이 못 보내 링크만 보냈습니다. 사진은 "이미지로 저장" 을 눌러 주세요.'); return; } download(blob); setHint('이 브라우저는 공유 시트를 못 열어 저장으로 대신했습니다.'); } return (
{/* ★ 폭을 묶어 둔다 (2026-09-15 대표: "엽서 ui 너무 큼"). 엽서는 1:1 이라 열 폭을 그대로 주면 넓은 화면에서 한 변이 700px 넘는 정사각이 서서, 예약하러 온 손님이 이 한 칸을 스크롤로 넘겨야 다음 섹션에 닿는다. 미리보기는 손바닥만 하면 된다. ★ 320px 도 크다고 했다(2026-09-15, 같은 날 두 번째) — 240px 로 내렸다. */}
{photos.map((photo, i) => ( ))}