kintex/src/frontend/src/screens/public/PublicTicketPage.tsx
ythong 9b56196d85 fix(frontend): grid/card clipping sweep - G5 minmax(0,fr) on 7 split grids, table scroll wrappers on 6 screens
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>
2026-07-24 13:11:55 +09:00

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>
);
}