o2o-infinith-demo/src/components/discovery/ImageReviewPanel.tsx
Haewon Kam 14c4b82155 refactor(design): 요청문과 설명문을 텍스트 역할 토큰으로 가른다
"한 장씩 봐 주세요" 같은 요청문이 읽고 넘어가는 설명문과 같은 무게로 놓여 있어
지나치기 쉬웠다. 역할별 토큰과 클래스를 두고 화면에 적용한다.

  .ui-ask   요청문. 사람이 무엇을 해야 하는지 말하는 문장 (17px·600·인디고)
  .ui-ask-block  문단으로 서는 요청문. 왼쪽 규칙선으로 설명과 구분한다
  .ui-body  설명 본문 (16px·slate-600)
  .ui-note  보조·출처 (14px·slate-400)

크기는 기존 화면을 세어 맞췄다. text-base 167회·text-sm 58회가 지배적이라
body 를 16px, note 를 14px 로 두면 치환해도 크기가 변하지 않고 역할과 색만 정리된다.

한글 줄바꿈: word-break keep-all 로 어절을 지키고, 긴 URL·영문 토큰만
overflow-wrap anywhere 로 자른다. text-wrap 으로 마지막 줄에 한 어절만 남는 것을
줄이고, 62ch 로 줄 길이를 묶는다. 제목에도 keep-all 과 balance 를 건다.

다크 섹션은 .on-dark 로 값을 뒤집고, 그 안의 흰 카드는 .on-light 로 되돌린다.
이걸 두지 않으면 흰 글씨가 흰 배경에 얹혀 보이지 않는다(디자인 시스템 기록된 실수).

사진 확인 화면의 썸네일 줄이 누를 수 있는 것으로 안 읽혀 같이 고친다. 72px 카드,
호버 반응, 순번 배지, 현재 위치 표시, 안 정한 것 아래 띠, 범례, 이전·다음 이동을 둔다.
상태가 안 보이는 것이 크기보다 큰 문제였다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:56:52 +09:00

411 lines
21 KiB
TypeScript

/**
* 이미지 확인 패널 — 서포터즈 사이트에 쓸 사진을 병원이 한 장씩 고른다.
* - 읽기: supporter_builds(최신 1건).report.images · supporter_inputs(key='image_review')
* - 쓰기: supporter_inputs insert (anon RLS). 저장하면 워커가 다음 빌드에서 제외 목록을 반영한다.
* - 사진 파일은 미리보기 사이트(preview_url)에서 불러온다. 배포 전 빌드는 볼 수 없다.
* - 대상은 병원 담당자다. 제작 과정이나 내부 규칙은 쓰지 않고, 무엇을 판단하면 되는지만 둔다.
*/
import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { supabase } from '../../lib/supabase';
import { CheckFilled, CrossFilled, EyeFilled, WarningFilled, LinkExternalFilled } from '../icons/FilledIcons';
type Role = 'hero' | 'used' | 'stored';
type ImageItem = {
id: string;
src: string;
alt: string;
category: 'clinic' | 'procedure' | 'equipment' | 'doctors';
width: number;
height: number;
sourceUrl: string;
pages: string[];
role: Role;
};
type Build = {
id: string;
status: string;
preview_url: string | null;
updated_at: string;
report: { images?: { capturedAt: string; total: number; used: number; items: ImageItem[] } } | null;
};
type Decision = 'use' | 'exclude';
const ROLE_LABEL: Record<Role, string> = {
hero: '첫 화면 대표 사진',
used: '사이트에 실립니다',
stored: '아직 쓰이지 않습니다',
};
const CATEGORY_LABEL: Record<ImageItem['category'], string> = {
clinic: '병원 공간',
procedure: '시술 설명',
equipment: '장비',
doctors: '의료진',
};
/** 병원이 판단할 때 떠올릴 것. 법 조항이 아니라 눈으로 확인할 수 있는 말로 둔다. */
const CHECKPOINTS = [
'환자분의 얼굴이나 몸이 알아볼 수 있게 나오나요',
'수술 전과 후를 나란히 놓고 비교한 사진인가요',
'주사·절개처럼 시술하는 장면이 담겼나요',
'효과나 성능을 숫자·화살표로 내세운 그림인가요',
'상장·수상·인증 마크가 들어 있나요',
];
export function ImageReviewPanel({ clinicId }: { clinicId: string }) {
const [build, setBuild] = useState<Build | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
const [cursor, setCursor] = useState(0);
const [inputBy, setInputBy] = useState('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
void (async () => {
const [b, inputs] = await Promise.all([
supabase.from('supporter_builds').select('id,status,preview_url,report,updated_at').eq('clinic_id', clinicId).order('created_at', { ascending: false }).limit(1),
supabase.from('supporter_inputs').select('value,input_by,created_at').eq('clinic_id', clinicId).eq('key', 'image_review').order('created_at', { ascending: true }),
]);
if (!alive) return;
if (b.error) setLoadError(b.error.message);
setBuild((b.data?.[0] as Build) ?? null);
// 같은 key 는 최신 행이 유효값이다.
const last = inputs.data?.[inputs.data.length - 1]?.value as { decisions?: Record<string, Decision> } | undefined;
if (last?.decisions) setDecisions(last.decisions);
setLoading(false);
})();
return () => { alive = false; };
}, [clinicId]);
const items = useMemo(() => {
const list = build?.report?.images?.items ?? [];
// 첫 화면 대표 사진과 실제로 실리는 사진을 먼저 본다.
const rank: Record<Role, number> = { hero: 0, used: 1, stored: 2 };
return [...list].sort((a, b) => rank[a.role] - rank[b.role]);
}, [build]);
const decidedCount = items.filter((i) => decisions[i.id]).length;
const excluded = items.filter((i) => decisions[i.id] === 'exclude');
const done = items.length > 0 && decidedCount === items.length;
const current = items[cursor];
const previewUrl = build?.preview_url ?? '';
function decide(id: string, d: Decision) {
setDecisions((p) => ({ ...p, [id]: d }));
setSaved(null);
setCursor((c) => Math.min(c + 1, items.length - 1));
}
async function save() {
setSaving(true);
setSaveError(null);
const { error } = await supabase.from('supporter_inputs').insert({
clinic_id: clinicId,
key: 'image_review',
value: { decisions, excludeImages: excluded.map((i) => i.id), total: items.length, decidedAt: new Date().toISOString() },
input_by: inputBy.trim() || null,
});
setSaving(false);
if (error) { setSaveError(error.message); return; }
setSaved(new Date().toISOString().slice(0, 16).replace('T', ' '));
}
if (loading) {
return (
<section className="on-light bg-white py-20 px-6">
<div className="max-w-7xl mx-auto"><p className="text-slate-500">사진을 불러오는 중입니다.</p></div>
</section>
);
}
if (!items.length || !previewUrl) {
return (
<section className="on-light bg-white py-20 px-6 [word-break:keep-all]">
<div className="on-light max-w-3xl mx-auto bg-white border border-slate-200 rounded-2xl p-8 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]">
<h2 className="font-bold text-xl text-primary-900 mb-2">아직 확인하실 사진이 없습니다</h2>
<p className="ui-body">
{loadError
? `자료를 불러오지 못했습니다. ${loadError}`
: !previewUrl
? '사이트 초안이 준비되면 이 화면에서 사진을 한 장씩 확인하실 수 있습니다.'
: '홈페이지에서 모은 사진이 아직 없습니다.'}
</p>
</div>
</section>
);
}
return (
<section className="on-light bg-white py-16 md:py-20 px-6 [word-break:keep-all]">
<div className="max-w-7xl mx-auto">
{/* 진행 */}
<div className="flex flex-wrap items-end justify-between gap-4 mb-3">
<div>
<h2 className="font-serif text-3xl md:text-4xl font-bold text-[#0A1128]">Photo Check</h2>
<p className="ui-ask ui-ask-block mt-3">
홈페이지에서 모은 사진 {items.length}장입니다.{' '}
서포터즈 사이트에 실어도 괜찮은 사진인지 한 장씩 봐 주세요.
</p>
{build?.report?.images?.used ? (
<p className="ui-body mt-2">이 중 {build.report.images.used}장이 지금 사이트에 실립니다.</p>
) : null}
</div>
<div className="text-right shrink-0">
<p className="ui-note">확인</p>
<p className="text-2xl font-bold text-[#1D0024]">{decidedCount}<span className="text-slate-400 text-lg"> / {items.length}</span></p>
</div>
</div>
<div className="h-1.5 rounded-full bg-slate-100 overflow-hidden mb-10">
<motion.div
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#021341]"
animate={{ width: `${(decidedCount / items.length) * 100}%` }}
transition={{ duration: 0.35 }}
/>
</div>
<div className="grid lg:grid-cols-[minmax(0,1fr)_340px] gap-8 items-start">
{/* 한 장씩 보기 */}
<div>
<AnimatePresence mode="wait">
<motion.div
key={current.id}
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.22 }}
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden"
>
<div className="bg-slate-50 flex items-center justify-center p-4">
<img
src={`${previewUrl}${current.src}`}
alt={current.alt}
className="max-h-[420px] w-auto object-contain rounded-lg"
loading="eager"
/>
</div>
<div className="p-6 md:p-7">
<div className="flex flex-wrap items-center gap-2 mb-3">
<RoleBadge role={current.role} />
<span className="text-xs font-semibold px-2.5 py-1 rounded-full bg-slate-100 text-slate-600">{CATEGORY_LABEL[current.category]}</span>
</div>
<h3 className="ui-ask text-lg mb-1.5">이 사진을 사이트에 써도 될까요?</h3>
<p className="ui-body mb-1">
{current.alt || '사진에 붙은 설명이 없습니다.'}
</p>
<p className="ui-note">
출처 {current.pages[0] ?? '홈페이지'}{current.pages.length > 1 ? ` 외 ${current.pages.length - 1}곳` : ''}
{' · '}
<a href={current.sourceUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline underline-offset-4 text-slate-500">
원본 열기 <LinkExternalFilled size={12} />
</a>
</p>
<div className="flex flex-wrap gap-3 mt-6">
<button
type="button"
onClick={() => decide(current.id, 'use')}
className={`inline-flex items-center gap-2 rounded-full px-6 py-3 text-base font-semibold transition-shadow ${
decisions[current.id] === 'use'
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
: 'bg-white border border-slate-200 text-[#021341] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
}`}
>
<CheckFilled size={16} /> 사용합니다
</button>
<button
type="button"
onClick={() => decide(current.id, 'exclude')}
className={`inline-flex items-center gap-2 rounded-full px-6 py-3 text-base font-semibold transition-shadow ${
decisions[current.id] === 'exclude'
? 'bg-[#FFF0F0] border border-[#F5D5DC] text-[#7C3A4B] shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
: 'bg-white border border-slate-200 text-[#021341] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
}`}
>
<CrossFilled size={16} /> 빼 주세요
</button>
<div className="flex items-center gap-1 ml-auto">
<button
type="button"
onClick={() => setCursor((c) => Math.max(c - 1, 0))}
disabled={cursor === 0}
className="rounded-full px-4 py-3 text-base font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
>
이전
</button>
<span className="text-sm text-slate-400 tabular-nums px-1">{cursor + 1} / {items.length}</span>
<button
type="button"
onClick={() => setCursor((c) => Math.min(c + 1, items.length - 1))}
disabled={cursor === items.length - 1}
className="rounded-full px-4 py-3 text-base font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
>
다음
</button>
</div>
</div>
</div>
</motion.div>
</AnimatePresence>
{/* 전체 훑기 */}
<div className="mt-6">
<div className="flex items-baseline justify-between gap-3 mb-3">
<h3 className="text-base font-bold text-[#1D0024]">다른 사진으로 이동</h3>
<p className="ui-note">
{items.length - decidedCount > 0
? <>아직 <strong className="text-[#1D0024]">{items.length - decidedCount}장</strong>이 남았습니다</>
: '모두 확인하셨습니다'}
</p>
</div>
<div className="flex flex-wrap gap-2.5">
{items.map((i, idx) => {
const d = decisions[i.id];
const isCur = idx === cursor;
return (
<button
key={i.id}
type="button"
onClick={() => setCursor(idx)}
aria-label={`${idx + 1}번째 사진으로 이동${d ? (d === 'use' ? ', 사용으로 정함' : ', 빼기로 정함') : ', 아직 안 정함'}`}
aria-current={isCur ? 'true' : undefined}
title={i.alt || `${idx + 1}번째 사진`}
className={`group relative w-[72px] h-[72px] rounded-xl overflow-hidden bg-slate-100 cursor-pointer
transition-all duration-150 outline-none
hover:-translate-y-0.5 hover:shadow-[3px_5px_14px_rgba(0,0,0,0.14)]
focus-visible:ring-2 focus-visible:ring-[#6C5CE7] focus-visible:ring-offset-2
${isCur
? 'ring-2 ring-[#6C5CE7] ring-offset-2 shadow-[3px_4px_12px_rgba(108,92,231,0.28)]'
: 'ring-1 ring-slate-200 hover:ring-[#9B8AD4]'}`}
>
<img
src={`${previewUrl}${i.src}`}
alt=""
loading="lazy"
className={`w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.06]
${d === 'exclude' ? 'grayscale opacity-45' : ''}`}
/>
{/* 순번. 어디까지 봤는지 세기 쉽게 한다 */}
<span className="on-dark absolute top-1 left-1 min-w-[18px] h-[18px] px-1 rounded-md bg-[#0A1128]/65 text-white text-[11px] font-semibold leading-[18px] text-center">
{idx + 1}
</span>
{/* 정한 것 */}
{d === 'exclude' && (
<span className="absolute inset-0 flex items-center justify-center">
<span className="w-6 h-6 rounded-full bg-[#FFF0F0] border border-[#F5D5DC] text-[#7C3A4B] flex items-center justify-center">
<CrossFilled size={12} />
</span>
</span>
)}
{d === 'use' && (
<span className="absolute bottom-1 right-1 w-5 h-5 rounded-full bg-[#F3F0FF] border border-[#D5CDF5] text-[#4A3A7C] flex items-center justify-center">
<CheckFilled size={11} />
</span>
)}
{/* 아직 안 정한 것은 아래에 띠를 둬서 남은 일이 보이게 한다 */}
{!d && <span className="absolute inset-x-0 bottom-0 h-1 bg-[#D4A872]" />}
{/* 현재 보고 있는 것 */}
{isCur && (
<span className="absolute inset-x-0 bottom-0 bg-[#6C5CE7] text-white text-[10px] font-bold text-center py-0.5">
보는 중
</span>
)}
</button>
);
})}
</div>
<div className="ui-note flex flex-wrap items-center gap-x-4 gap-y-1 mt-3">
<span className="inline-flex items-center gap-1.5"><span className="w-3 h-1 rounded-full bg-[#D4A872]" /> 아직 안 정함</span>
<span className="inline-flex items-center gap-1.5"><span className="w-3.5 h-3.5 rounded-full bg-[#F3F0FF] border border-[#D5CDF5]" /> 사용</span>
<span className="inline-flex items-center gap-1.5"><span className="w-3.5 h-3.5 rounded-full bg-[#FFF0F0] border border-[#F5D5DC]" /> 빼기</span>
</div>
</div>
</div>
{/* 판단 기준 + 제출 */}
<div className="flex flex-col gap-5 lg:sticky lg:top-24">
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<div className="flex items-center gap-2 mb-3 text-[#7C5C3A]">
<WarningFilled size={16} />
<h3 className="text-base font-bold">이런 사진이면 빼 주세요</h3>
</div>
<ul className="flex flex-col gap-2">
{CHECKPOINTS.map((c) => (
<li key={c} className="ui-body !text-[#1D0024] flex gap-2">
<span className="mt-[9px] w-1.5 h-1.5 rounded-full bg-[#D4A872] shrink-0" />
<span>{c}</span>
</li>
))}
</ul>
<p className="ui-ask text-[15px] mt-4">판단이 서지 않으면 빼는 쪽으로 봐 주세요.{' '}<span className="ui-note">나중에 다시 넣을 수 있습니다.</span></p>
</div>
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<h3 className="text-base font-bold text-[#1D0024] mb-3">확인 결과</h3>
<dl className="flex flex-col gap-1.5 text-[15px]">
<div className="flex justify-between"><dt className="text-slate-500">사용</dt><dd className="font-bold text-[#4A3A7C]">{decidedCount - excluded.length}장</dd></div>
<div className="flex justify-between"><dt className="text-slate-500">빼기</dt><dd className="font-bold text-[#7C3A4B]">{excluded.length}장</dd></div>
<div className="flex justify-between"><dt className="text-slate-500">남음</dt><dd className="font-bold text-slate-400">{items.length - decidedCount}장</dd></div>
</dl>
<label className="flex flex-col gap-1.5 mt-5">
<span className="text-sm text-slate-600">확인하신 분 (이름·직함)</span>
<input
value={inputBy}
onChange={(e) => setInputBy(e.target.value)}
placeholder="예: 김담당 실장"
className="border border-slate-200 rounded-lg px-3 py-2 text-base"
/>
</label>
<button
type="button"
onClick={() => void save()}
disabled={saving || decidedCount === 0}
className="w-full mt-4 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-6 py-3 text-base disabled:opacity-40"
>
{saving ? '보내는 중' : done ? '확인 결과 보내기' : `여기까지 보내기 (${decidedCount}장)`}
</button>
{saved && (
<p className="text-sm text-[#4A3A7C] mt-3 flex items-start gap-1.5">
<span className="mt-0.5"><CheckFilled size={13} /></span>
<span>{saved} 에 보냈습니다. 빼기로 하신 사진은 다음 초안부터 빠집니다.</span>
</p>
)}
{saveError && <p className="text-sm text-[#7C3A4B] mt-3">보내지 못했습니다. {saveError}</p>}
{!done && decidedCount > 0 && !saved && (
<p className="ui-note mt-3">지금 보내고 나중에 이어서 보셔도 됩니다.</p>
)}
</div>
{previewUrl && (
<a
href={previewUrl}
target="_blank"
rel="noreferrer"
className="on-light inline-flex items-center justify-center gap-2 rounded-full bg-white border border-slate-200 text-[#021341] font-semibold px-6 py-3 text-base"
>
<EyeFilled size={15} /> 사이트 초안 열기
</a>
)}
</div>
</div>
</div>
</section>
);
}
function RoleBadge({ role }: { role: Role }) {
const style: Record<Role, string> = {
hero: 'bg-[#EFF0FF] text-[#3A3F7C] border-[#C5CBF5]',
used: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
stored: 'bg-slate-100 text-slate-500 border-slate-200',
};
return <span className={`text-xs font-semibold px-2.5 py-1 rounded-full border ${style[role]}`}>{ROLE_LABEL[role]}</span>;
}