Root cause (same as aab5e88): fr tracks without minmax(0,-) let chart/table
min-content lock the track, clipping the right column or card content.
Audit: _workspace/wiseui_audit.md (5 blockers + 2 suspects fixed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
802 lines
32 KiB
TypeScript
802 lines
32 KiB
TypeScript
/*
|
|
* SCR-P7 입장권 예매 (M10·M9) — 비로그인 공개.
|
|
* design.md §3B. 예매 플로우(권종→예매자→결제→완료) + 예매 조회(주문번호+연락처).
|
|
* 라이브: GET /api/public/tickets/{eventId}, POST .../orders, GET .../lookup (ticketApi).
|
|
* 결제는 Mock PG(백엔드 어댑터) — 데모 승인. 응답의 구매자 정보는 서버 마스킹 필드만 소비(PII 원문 없음).
|
|
* 3상태(로딩/빈/에러) 준수. 목록은 NETWORK/NOT_FOUND 강등 시에만 샘플 폴백(배지 표기).
|
|
*/
|
|
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useParams } from 'react-router-dom';
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
import { PublicShell } from './PublicShell';
|
|
import { ticketApi, type TicketProduct } from './ticketApi';
|
|
import { RotatingTicketQr } from './RotatingTicketQr';
|
|
import { errorMessage } from './publicFormat';
|
|
import './ticketExtras.css';
|
|
import { ApiRequestError } from '../../api/client';
|
|
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
|
import {
|
|
IconTicket,
|
|
IconCalendar,
|
|
IconPin,
|
|
IconPlus,
|
|
IconMinus,
|
|
IconShield,
|
|
IconInfo,
|
|
IconCheckCircle,
|
|
IconQr,
|
|
IconArrowRight,
|
|
IconPerson,
|
|
} from './publicIcons';
|
|
|
|
const DEFAULT_EVENT_ID = 'e-2026-live';
|
|
const STEP_KEYS = ['ticket.steps.s1', 'ticket.steps.s2', 'ticket.steps.s3', 'ticket.steps.s4'];
|
|
const won = (n: number) => `₩${n.toLocaleString('ko-KR')}`;
|
|
|
|
/** NETWORK/NOT_FOUND/NOT_IMPLEMENTED 강등 시에만 샘플 폴백을 허용한다. */
|
|
function isDegradable(e: unknown): boolean {
|
|
return (
|
|
e instanceof ApiRequestError &&
|
|
(e.code === 'NETWORK' || e.code === 'NOT_FOUND' || e.code === 'NOT_IMPLEMENTED' || e.httpStatus === 404)
|
|
);
|
|
}
|
|
|
|
const SAMPLE_PRODUCTS: TicketProduct[] = [
|
|
{ id: 'general', eventId: '', code: 'GEN', kind: 'GENERAL', name: '일반권', description: '일반 관람객 및 개인 참가자', price: 15000, currency: 'KRW', saleStart: null, saleEnd: null, totalQty: 5000, remaining: 3758, maxPerOrder: 10, onSale: true, soldOut: false, sortOrder: 0 },
|
|
{ id: 'student', eventId: '', code: 'STU', kind: 'STUDENT', name: '학생/군인 할인권', description: '학생·군인 대상 할인(현장 신분 확인)', price: 8000, currency: 'KRW', saleStart: null, saleEnd: null, totalQty: 2000, remaining: 1679, maxPerOrder: 6, onSale: true, soldOut: false, sortOrder: 1 },
|
|
{ id: 'group', eventId: '', code: 'GRP', kind: 'GROUP', name: '단체권 (10매 이상)', description: '기업·기관 단체 관람 전용', price: 9000, currency: 'KRW', saleStart: null, saleEnd: null, totalQty: 3000, remaining: 2840, maxPerOrder: 100, onSale: true, soldOut: false, sortOrder: 2 },
|
|
];
|
|
|
|
function EventBanner() {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="kxp-ticket__banner">
|
|
<div
|
|
className="kxp-ticket__poster"
|
|
style={{ background: 'linear-gradient(135deg, #0066b3 0%, #6d4aff 130%)' }}
|
|
role="img"
|
|
aria-label={t('ticket.posterAria')}
|
|
/>
|
|
<div>
|
|
<h1>{t('ticket.bannerTitle')}</h1>
|
|
<div className="kxp-ticket__facts">
|
|
<span>
|
|
<IconCalendar width={16} height={16} /> {t('ticket.bannerDate')}
|
|
</span>
|
|
<span>
|
|
<IconPin width={16} height={16} /> {t('ticket.bannerPlace')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface Selection {
|
|
id: string;
|
|
qty: number;
|
|
}
|
|
|
|
function PurchaseView({ eventId }: { eventId: string }) {
|
|
const { t } = useTranslation();
|
|
const [step, setStep] = useState(0);
|
|
const [sel, setSel] = useState<Selection | null>(null);
|
|
const [pay, setPay] = useState('card');
|
|
const [name, setName] = useState('');
|
|
const [contact, setContact] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [agreePrivacy, setAgreePrivacy] = useState(false);
|
|
const [formError, setFormError] = useState('');
|
|
|
|
const productsQ = useQuery({
|
|
queryKey: ['ticketProducts', eventId],
|
|
queryFn: () => ticketApi.listProducts(eventId),
|
|
retry: false,
|
|
});
|
|
|
|
const degraded = productsQ.isError && isDegradable(productsQ.error);
|
|
const products: TicketProduct[] = degraded ? SAMPLE_PRODUCTS : (productsQ.data ?? []);
|
|
|
|
const orderMut = useMutation({
|
|
mutationFn: () =>
|
|
ticketApi.createOrder(eventId, {
|
|
productId: sel!.id,
|
|
qty: sel!.qty,
|
|
buyerName: name.trim(),
|
|
buyerContact: contact.trim(),
|
|
buyerEmail: email.trim() || undefined,
|
|
agreePrivacy,
|
|
payMethod: pay,
|
|
}),
|
|
onSuccess: () => setStep(3),
|
|
});
|
|
|
|
const selProduct = sel ? products.find((p) => p.id === sel.id) ?? null : null;
|
|
const total = sel && selProduct ? selProduct.price * sel.qty : 0;
|
|
const count = sel ? sel.qty : 0;
|
|
const isFree = total === 0 && count > 0;
|
|
|
|
const setCount = (p: TicketProduct, delta: number) => {
|
|
if (p.soldOut || !p.onSale) return;
|
|
setSel((prev) => {
|
|
if (prev && prev.id === p.id) {
|
|
const q = prev.qty + delta;
|
|
if (q <= 0) return null;
|
|
return { id: p.id, qty: Math.min(q, p.maxPerOrder) };
|
|
}
|
|
if (delta > 0) return { id: p.id, qty: 1 };
|
|
return prev;
|
|
});
|
|
};
|
|
|
|
const proceed = () => {
|
|
setFormError('');
|
|
if (step === 0) {
|
|
if (!sel || count === 0) {
|
|
setFormError(t('ticket.selectPrompt'));
|
|
return;
|
|
}
|
|
setStep(1);
|
|
return;
|
|
}
|
|
if (step === 1) {
|
|
if (!name.trim()) {
|
|
setFormError(t('ticket.errName'));
|
|
return;
|
|
}
|
|
if (!contact.trim()) {
|
|
setFormError(t('ticket.errContact'));
|
|
return;
|
|
}
|
|
if (!agreePrivacy) {
|
|
setFormError(t('ticket.errPrivacy'));
|
|
return;
|
|
}
|
|
if (isFree) {
|
|
orderMut.mutate();
|
|
return;
|
|
}
|
|
setStep(2);
|
|
return;
|
|
}
|
|
if (step === 2) {
|
|
orderMut.mutate();
|
|
}
|
|
};
|
|
|
|
const back = () => {
|
|
setFormError('');
|
|
if (step === 3) {
|
|
setStep(1);
|
|
return;
|
|
}
|
|
setStep((s) => Math.max(0, s - 1));
|
|
};
|
|
|
|
const result = orderMut.data;
|
|
|
|
return (
|
|
<>
|
|
<EventBanner />
|
|
|
|
<div className="kxp-ticket__steps" role="list" aria-label={t('ticket.stepsAria')}>
|
|
{STEP_KEYS.map((l, i) => (
|
|
<span
|
|
key={l}
|
|
className={`kxp-ticket__step${i === step ? ' is-active' : ''}`}
|
|
role="listitem"
|
|
aria-current={i === step ? 'step' : undefined}
|
|
>
|
|
{`0${i + 1} `}
|
|
{t(l)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
<div className="kxp-loginbanner">
|
|
<IconInfo width={18} height={18} /> {t('ticket.loginBanner')}
|
|
<a href="#top">{t('ticket.loginLink')}</a>
|
|
</div>
|
|
|
|
<div className="kxp-ticket__grid">
|
|
<div className="kxp-ticket__main">
|
|
{/* Step 1: 권종 */}
|
|
{step === 0 && (
|
|
<section>
|
|
<h2 className="kxp-ticket__h2">
|
|
<IconTicket width={20} height={20} /> {t('ticket.h2Type')}
|
|
{degraded && (
|
|
<span className="kxp-sample" style={{ marginLeft: 'auto' }}>
|
|
{t('ticket.sample')}
|
|
</span>
|
|
)}
|
|
</h2>
|
|
|
|
{productsQ.isLoading ? (
|
|
<div className="kxp-tickrows">
|
|
{[0, 1, 2].map((k) => (
|
|
<div key={k} className="kxp-tickrow">
|
|
<div className="kxp-tickrow__info">
|
|
<Skeleton height={18} width="40%" />
|
|
<Skeleton height={12} width="70%" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : productsQ.isError && !degraded ? (
|
|
<ErrorState message={errorMessage(productsQ.error)} onRetry={() => void productsQ.refetch()} />
|
|
) : products.length === 0 ? (
|
|
<EmptyState title={t('ticket.emptyProducts')} icon={<IconTicket width={28} height={28} />} />
|
|
) : (
|
|
<div className="kxp-tickrows">
|
|
{products.map((p) => {
|
|
const disabled = p.soldOut || !p.onSale;
|
|
const q = sel && sel.id === p.id ? sel.qty : 0;
|
|
return (
|
|
<div
|
|
key={p.id}
|
|
className={`kxp-tickrow${disabled ? ' kxp-tickrow--soldout' : ''}`}
|
|
style={{ borderLeftColor: disabled ? undefined : '#0066b3' }}
|
|
>
|
|
<div className="kxp-tickrow__info">
|
|
<h3>
|
|
{p.name}
|
|
{p.soldOut && <span className="kxp-status kxp-status--soldout">{t('ticket.soldout')}</span>}
|
|
{!p.soldOut && !p.onSale && (
|
|
<span className="kxp-status kxp-status--soldout">{t('ticket.saleClosed')}</span>
|
|
)}
|
|
</h3>
|
|
{p.description && <p>{p.description}</p>}
|
|
<div className="kxp-tickrow__price">
|
|
{p.price === 0 ? t('ticket.free') : won(p.price)}
|
|
<span style={{ marginLeft: 10, fontSize: 11, color: 'var(--color-neutral-500)' }}>
|
|
{t('ticket.remaining', { n: Math.max(0, p.remaining) })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="kxp-stepper" aria-label={t('ticket.qtyAria', { name: p.name })}>
|
|
<button
|
|
type="button"
|
|
aria-label={t('ticket.decrease')}
|
|
disabled={disabled}
|
|
onClick={() => setCount(p, -1)}
|
|
>
|
|
<IconMinus width={16} height={16} />
|
|
</button>
|
|
<span>{q}</span>
|
|
<button
|
|
type="button"
|
|
aria-label={t('ticket.increase')}
|
|
disabled={disabled}
|
|
onClick={() => setCount(p, 1)}
|
|
>
|
|
<IconPlus width={16} height={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* Step 2: 예매자 */}
|
|
{step === 1 && (
|
|
<section>
|
|
<h2 className="kxp-ticket__h2">
|
|
<IconPerson width={20} height={20} /> {t('ticket.h2Booker')}
|
|
</h2>
|
|
<div className="kxp-formcard">
|
|
<div className="kxp-formgrid kxp-formgrid--2">
|
|
<div className="kxp-field">
|
|
<label htmlFor="tk-name">{t('ticket.bookerName')}</label>
|
|
<input
|
|
id="tk-name"
|
|
className="kxp-input"
|
|
placeholder={t('ticket.bookerNamePh')}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="kxp-field">
|
|
<label htmlFor="tk-tel">{t('ticket.bookerTel')}</label>
|
|
<input
|
|
id="tk-tel"
|
|
className="kxp-input"
|
|
type="tel"
|
|
placeholder="010-0000-0000"
|
|
value={contact}
|
|
onChange={(e) => setContact(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="kxp-field kxp-formgrid__full">
|
|
<label htmlFor="tk-email">{t('ticket.bookerEmail')}</label>
|
|
<input
|
|
id="tk-email"
|
|
className="kxp-input"
|
|
type="email"
|
|
placeholder="example@domain.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="kxp-consent">
|
|
<label className="kxp-check">
|
|
<input type="checkbox" checked={agreePrivacy} onChange={(e) => setAgreePrivacy(e.target.checked)} />
|
|
<span>
|
|
{t('ticket.consentPrivacy')} <span className="kxp-check__req">{t('ticket.required')}</span>
|
|
</span>
|
|
</label>
|
|
<label className="kxp-check">
|
|
<input type="checkbox" />
|
|
<span>{t('ticket.consentMarketing')}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Step 3: 결제 */}
|
|
{step === 2 && (
|
|
<section>
|
|
<h2 className="kxp-ticket__h2">
|
|
<IconShield width={20} height={20} /> {t('ticket.h2Pay')}
|
|
</h2>
|
|
<div className="kxp-paychips" role="group" aria-label={t('ticket.payGroupAria')}>
|
|
{[
|
|
['card', t('ticket.payCard')],
|
|
['easy', t('ticket.payEasy')],
|
|
['bank', t('ticket.payBank')],
|
|
].map(([k, label]) => (
|
|
<button
|
|
key={k}
|
|
type="button"
|
|
className={`kxp-paychip${pay === k ? ' is-active' : ''}`}
|
|
aria-pressed={pay === k}
|
|
onClick={() => setPay(k)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="kxp-pgnote">
|
|
<IconShield width={18} height={18} />
|
|
<span>{t('ticket.pgNote')}</span>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Step 4: 완료 */}
|
|
{step === 3 && result && (
|
|
<section className="kxp-tikdone">
|
|
<div className="kxp-done__ic" style={{ margin: '0 auto 16px' }}>
|
|
<IconCheckCircle width={40} height={40} />
|
|
</div>
|
|
<h2 className="kxp-regcard__title">{t('ticket.doneTitle')}</h2>
|
|
<div className="kxp-tikdone__no">{result.orderNo}</div>
|
|
<p className="kxp-regcard__sub">
|
|
{t('ticket.doneSub', { count: result.qty, total: won(result.totalAmount) })}
|
|
</p>
|
|
<div className="kxp-tikdone__qr">
|
|
<IconQr width={110} height={110} />
|
|
</div>
|
|
<div className="kxp-formcard" style={{ textAlign: 'left', maxWidth: 420, margin: '4px auto 0' }}>
|
|
<div className="kxp-sumrow">
|
|
<span>{t('ticket.colType')}</span>
|
|
<span>{result.productName}</span>
|
|
</div>
|
|
{result.approvalNo && (
|
|
<div className="kxp-sumrow">
|
|
<span>{t('ticket.approvalNo')}</span>
|
|
<span>{result.approvalNo}</span>
|
|
</div>
|
|
)}
|
|
<div className="kxp-sumrow">
|
|
<span>{t('ticket.ticketCodes')}</span>
|
|
<span style={{ textAlign: 'right' }}>
|
|
{result.tickets.map((tk) => (
|
|
<span key={tk.ticketCode} style={{ display: 'block', fontFamily: 'monospace' }}>
|
|
{tk.ticketCode}
|
|
</span>
|
|
))}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="kxp-tikdone__actions">
|
|
<a className="kxp-btn kxp-btn--outline" href="#top">
|
|
{t('ticket.walletBtn')}
|
|
</a>
|
|
<a className="kxp-btn kxp-btn--primary" href="/tickets/lookup">
|
|
{t('ticket.manageBtn')} <IconArrowRight width={18} height={18} />
|
|
</a>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
|
|
{/* 우측 요약 */}
|
|
{step < 3 && (
|
|
<aside className="kxp-ticket__summary" aria-label={t('ticket.summaryAria')}>
|
|
<h3>{t('ticket.summaryTitle')}</h3>
|
|
<div className="kxp-sumrow">
|
|
<span>{t('ticket.summaryCount')}</span>
|
|
<span>{t('ticket.count', { n: count })}</span>
|
|
</div>
|
|
<div className="kxp-sumrow">
|
|
<span>{t('ticket.subtotal')}</span>
|
|
<span>{won(total)}</span>
|
|
</div>
|
|
<div className="kxp-sumtotal">
|
|
<small>{t('ticket.finalTotal')}</small>
|
|
<strong>{isFree ? t('ticket.free') : won(total)}</strong>
|
|
</div>
|
|
|
|
{(formError || orderMut.isError) && (
|
|
<p className="kxp-formerror" role="alert" style={{ color: 'var(--color-danger-600, #d92d20)', fontSize: 12, margin: '10px 0 0' }}>
|
|
{formError || errorMessage(orderMut.error)}
|
|
</p>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
className="kxp-btn kxp-btn--primary kxp-btn--block kxp-btn--lg"
|
|
disabled={count === 0 || orderMut.isPending}
|
|
onClick={proceed}
|
|
>
|
|
{orderMut.isPending
|
|
? t('ticket.processing')
|
|
: step === 0
|
|
? t('ticket.nextStep')
|
|
: step === 1
|
|
? isFree
|
|
? t('ticket.freeDone')
|
|
: t('ticket.pay')
|
|
: t('ticket.payProceed')}
|
|
</button>
|
|
{step > 0 && (
|
|
<button
|
|
type="button"
|
|
className="kxp-btn kxp-btn--outline kxp-btn--block"
|
|
style={{ marginTop: 10 }}
|
|
onClick={back}
|
|
disabled={orderMut.isPending}
|
|
>
|
|
{t('common.actions.prev')}
|
|
</button>
|
|
)}
|
|
<p style={{ marginTop: 14, fontSize: 11, textAlign: 'center', color: 'var(--color-neutral-500)' }}>
|
|
{t('ticket.customerCenter')}
|
|
</p>
|
|
</aside>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 디바이스 식별자(스마트티켓 바인딩) — 최초 1회 생성 후 localStorage 보존. */
|
|
function useDeviceId(): string {
|
|
const [id] = useState(() => {
|
|
const KEY = 'kintex.deviceId';
|
|
let v = localStorage.getItem(KEY);
|
|
if (!v) {
|
|
v = 'dev-' + (crypto.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36));
|
|
localStorage.setItem(KEY, v);
|
|
}
|
|
return v;
|
|
});
|
|
return id;
|
|
}
|
|
|
|
function ManageView() {
|
|
const { t } = useTranslation();
|
|
const deviceId = useDeviceId();
|
|
const [orderNo, setOrderNo] = useState('');
|
|
const [contact, setContact] = useState('');
|
|
const [formError, setFormError] = useState('');
|
|
const [showRefund, setShowRefund] = useState(false);
|
|
const [activated, setActivated] = useState<Record<string, boolean>>({});
|
|
const [smartError, setSmartError] = useState('');
|
|
|
|
const lookupMut = useMutation({
|
|
mutationFn: () => ticketApi.lookup(orderNo.trim(), contact.trim()),
|
|
onSuccess: () => {
|
|
setShowRefund(false);
|
|
setActivated({});
|
|
},
|
|
});
|
|
|
|
// F-B2 서버 권위 예상 환불 산정(취소 전 조회).
|
|
const refundMut = useMutation({
|
|
mutationFn: () => ticketApi.refundQuote(orderNo.trim(), contact.trim()),
|
|
});
|
|
// F-B2 실제 취소.
|
|
const cancelMut = useMutation({
|
|
mutationFn: () => ticketApi.cancel(orderNo.trim(), contact.trim()),
|
|
onSuccess: () => void lookupMut.mutate(),
|
|
});
|
|
// F-B1 스마트티켓 활성화(디바이스 바인딩).
|
|
const activateMut = useMutation({
|
|
mutationFn: (ticketCode: string) =>
|
|
ticketApi.smartActivate(orderNo.trim(), contact.trim(), ticketCode, deviceId),
|
|
onSuccess: (res) => {
|
|
setSmartError('');
|
|
setActivated((m) => ({ ...m, [res.ticketCode]: true }));
|
|
},
|
|
onError: () => setSmartError('스마트티켓 활성화에 실패했습니다. 예매 정보를 확인해 주세요.'),
|
|
});
|
|
|
|
const submit = () => {
|
|
setFormError('');
|
|
if (!orderNo.trim() || !contact.trim()) {
|
|
setFormError(t('ticket.lookupHint'));
|
|
return;
|
|
}
|
|
lookupMut.mutate();
|
|
};
|
|
|
|
const openRefund = () => {
|
|
setShowRefund(true);
|
|
refundMut.mutate();
|
|
};
|
|
|
|
const r = lookupMut.data;
|
|
const quote = cancelMut.data ?? refundMut.data ?? null;
|
|
|
|
return (
|
|
<>
|
|
<header style={{ textAlign: 'center', marginBottom: 24 }}>
|
|
<h1 className="kxp-regcard__title">{t('ticket.manageTitle')}</h1>
|
|
<p className="kxp-regcard__sub" style={{ marginBottom: 0 }}>
|
|
{t('ticket.manageSub')}
|
|
</p>
|
|
</header>
|
|
|
|
<div className="kxp-manage__lookup">
|
|
<div className="kxp-manage__lookrow">
|
|
<div className="kxp-field">
|
|
<label htmlFor="mg-no">{t('ticket.lookNo')}</label>
|
|
<input
|
|
id="mg-no"
|
|
className="kxp-input"
|
|
placeholder={t('ticket.lookNoPh')}
|
|
value={orderNo}
|
|
onChange={(e) => setOrderNo(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="kxp-field">
|
|
<label htmlFor="mg-contact">{t('ticket.lookContact')}</label>
|
|
<input
|
|
id="mg-contact"
|
|
className="kxp-input"
|
|
type="tel"
|
|
placeholder={t('ticket.lookContactPh')}
|
|
value={contact}
|
|
onChange={(e) => setContact(e.target.value)}
|
|
/>
|
|
</div>
|
|
<button type="button" className="kxp-btn kxp-btn--primary" onClick={submit} disabled={lookupMut.isPending}>
|
|
{lookupMut.isPending ? t('ticket.processing') : t('ticket.lookBtn')}
|
|
</button>
|
|
</div>
|
|
{(formError || lookupMut.isError) && (
|
|
<p role="alert" style={{ textAlign: 'center', marginTop: 12, color: 'var(--color-danger-600, #d92d20)', fontSize: 13 }}>
|
|
{formError || (lookupMut.isError ? t('ticket.notFound') : '')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{!r ? (
|
|
<p className="kxp-empty">{t('ticket.lookupHint')}</p>
|
|
) : (
|
|
<div className="kxp-manage__grid">
|
|
<div>
|
|
<article className="kxp-bookcard">
|
|
<div className="kxp-bookcard__head">
|
|
<div>
|
|
<span className="kxp-status kxp-status--done">{t('ticket.booked')}</span>
|
|
<h2>{r.productName}</h2>
|
|
<span className="kxp-bookcard__no">
|
|
{t('ticket.bookNoLabel')} <b>{r.orderNo}</b>
|
|
</span>
|
|
</div>
|
|
<div className="kxp-bookcard__paid">
|
|
<small>{t('ticket.paidAt')}</small>
|
|
<br />
|
|
<b>{(r.paidAt ?? r.orderedAt ?? '').replace('T', ' ').slice(0, 16) || '-'}</b>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="kxp-ticktable-scroll">
|
|
<table className="kxp-ticktable">
|
|
<thead>
|
|
<tr>
|
|
<th>{t('ticket.colType')}</th>
|
|
<th>{t('ticket.colQty')}</th>
|
|
<th>{t('ticket.colAmount')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{r.tickets.map((tk) => (
|
|
<tr key={tk.ticketCode}>
|
|
<td>
|
|
<div className="kxp-ticktable__cell">
|
|
<span className="kxp-ticktable__qr">
|
|
<IconQr width={22} height={22} />
|
|
</span>
|
|
<span style={{ fontFamily: 'monospace' }}>{tk.ticketCode}</span>
|
|
</div>
|
|
</td>
|
|
<td>{t('ticket.count', { n: 1 })}</td>
|
|
<td>{won(r.unitPrice)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="kxp-booker">
|
|
<div className="kxp-booker__id">
|
|
<IconPerson width={20} height={20} />
|
|
<div>
|
|
<small>{t('ticket.bookerInfo')}</small>
|
|
<br />
|
|
<b>
|
|
{r.buyerNameMasked} · {r.buyerContactMasked}
|
|
{r.buyerEmailMasked ? ` · ${r.buyerEmailMasked}` : ''}
|
|
</b>
|
|
</div>
|
|
</div>
|
|
<div style={{ textAlign: 'right' }}>
|
|
<small style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)' }}>{t('ticket.totalPaid')}</small>
|
|
<div style={{ fontSize: 'var(--fs-h2)', fontWeight: 800, color: 'var(--color-primary-700)' }}>
|
|
{won(r.totalAmount)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* F-B1 스마트티켓 — 디바이스 바인딩 + 30초 회전 QR(캡처본 무효·양도 방지) */}
|
|
{r.status !== 'CANCELLED' && (
|
|
<div className="kxf-smartsec">
|
|
<h3 className="kxf-smartsec__title">
|
|
<IconShield width={18} height={18} /> 스마트티켓 (부정입장 방지)
|
|
</h3>
|
|
<p className="kxf-smartsec__desc">
|
|
30초마다 회전하는 QR로 화면 캡처·양도를 차단합니다. 이 기기에서 활성화 후 입장 시 제시하세요.
|
|
</p>
|
|
{smartError && <p className="kxp-refund__note" role="alert">{smartError}</p>}
|
|
<div className="kxf-smartsec__list">
|
|
{r.tickets.map((tk) => (
|
|
<div key={tk.ticketCode} className="kxf-smartsec__item">
|
|
<span className="kxf-smartsec__code">{tk.ticketCode}</span>
|
|
{activated[tk.ticketCode] ? (
|
|
<RotatingTicketQr ticketCode={tk.ticketCode} deviceId={deviceId} />
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="kxp-btn kxp-btn--outline"
|
|
disabled={activateMut.isPending}
|
|
onClick={() => activateMut.mutate(tk.ticketCode)}
|
|
>
|
|
{activateMut.isPending ? '활성화 중…' : '스마트티켓 활성화'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</article>
|
|
</div>
|
|
|
|
<aside>
|
|
{/* F-B2 다구간 취소·환불(서버 권위) */}
|
|
<div className="kxp-refund kxf-refund">
|
|
<h3>
|
|
<IconInfo width={18} height={18} /> 취소·환불 안내
|
|
</h3>
|
|
|
|
{r.status === 'CANCELLED' ? (
|
|
<p className="kxp-refund__note">이미 취소된 예매입니다.</p>
|
|
) : !showRefund ? (
|
|
<>
|
|
<p className="kxp-refund__note">
|
|
개막일까지 남은 기간에 따라 환불율이 달라집니다. 예상 환불액을 서버 규정으로 산정합니다.
|
|
</p>
|
|
<button type="button" className="kxp-btn kxp-btn--outline kxp-btn--block" onClick={openRefund}>
|
|
예상 환불액 조회
|
|
</button>
|
|
</>
|
|
) : refundMut.isPending ? (
|
|
<p className="kxp-refund__note">환불 규정을 계산하는 중…</p>
|
|
) : refundMut.isError ? (
|
|
<p className="kxp-refund__note" role="alert">환불 산정에 실패했습니다. 다시 시도해 주세요.</p>
|
|
) : quote ? (
|
|
<>
|
|
<ul className="kxf-refund__brackets" aria-label="취소 환불 규정">
|
|
{quote.brackets.map((b) => (
|
|
<li key={b.minDaysBefore} className={b.refundRatePercent >= quote.refundRatePercent && quote.daysBefore >= b.minDaysBefore ? 'is-current' : ''}>
|
|
<span>{b.label}</span>
|
|
<b>{b.refundRatePercent}%</b>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div className="kxf-refund__calc">
|
|
<div className="kxp-sumrow"><span>개막까지</span><b>D-{quote.daysBefore}</b></div>
|
|
<div className="kxp-sumrow"><span>적용 구간</span><b>{quote.bracketLabel}</b></div>
|
|
<div className="kxp-sumrow"><span>티켓 환불({quote.refundRatePercent}%)</span><b>{won(quote.ticketRefund)}</b></div>
|
|
<div className="kxp-sumrow"><span>취소 관리수수료</span><b>-{won(quote.adminFee)}</b></div>
|
|
<div className="kxp-sumrow"><span>예매수수료 환불</span><b>{won(quote.bookingFeeRefund)}</b></div>
|
|
<div className="kxf-refund__total">
|
|
<span>예상 환불액</span>
|
|
<strong>{won(quote.refundAmount)}</strong>
|
|
</div>
|
|
<div className="kxp-sumrow kxf-refund__forfeit"><span>공제(위약)액</span><span>{won(quote.forfeitAmount)}</span></div>
|
|
</div>
|
|
{quote.cancellable && (
|
|
<button
|
|
type="button"
|
|
className="kxp-btn kxp-btn--primary kxp-btn--block"
|
|
style={{ marginTop: 'var(--space-3, 12px)' }}
|
|
disabled={cancelMut.isPending}
|
|
onClick={() => {
|
|
if (window.confirm(`예매를 취소하시겠습니까? 예상 환불액 ${won(quote.refundAmount)}`)) {
|
|
cancelMut.mutate();
|
|
}
|
|
}}
|
|
>
|
|
{cancelMut.isPending ? '취소 처리 중…' : '이 예매 취소하기'}
|
|
</button>
|
|
)}
|
|
{cancelMut.isError && (
|
|
<p className="kxp-refund__note" role="alert">취소 처리에 실패했습니다.</p>
|
|
)}
|
|
<p className="kxp-refund__note">규정 버전 {quote.policyVersion} · 예매수수료는 별도 규정 적용</p>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function PublicTicketPage() {
|
|
const { t } = useTranslation();
|
|
const { eventId } = useParams<{ eventId: string }>();
|
|
const [view, setView] = useState<'purchase' | 'manage'>(eventId ? 'purchase' : 'manage');
|
|
|
|
return (
|
|
<PublicShell active="visit" cta={t('ticket.viewPurchase')} search={false}>
|
|
<div className={view === 'purchase' ? 'kxp-ticket' : 'kxp-manage'}>
|
|
<div className="kxp-daytabs" style={{ justifyContent: 'center', marginBottom: 24 }} role="tablist" aria-label={t('ticket.viewSwitchAria')}>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={view === 'purchase'}
|
|
className={`kxp-daytab${view === 'purchase' ? ' is-active' : ''}`}
|
|
onClick={() => setView('purchase')}
|
|
>
|
|
{t('ticket.viewPurchase')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={view === 'manage'}
|
|
className={`kxp-daytab${view === 'manage' ? ' is-active' : ''}`}
|
|
onClick={() => setView('manage')}
|
|
>
|
|
{t('ticket.viewManage')}
|
|
</button>
|
|
</div>
|
|
|
|
{view === 'purchase' ? <PurchaseView eventId={eventId ?? DEFAULT_EVENT_ID} /> : <ManageView />}
|
|
</div>
|
|
</PublicShell>
|
|
);
|
|
}
|