diff --git a/components/QrGenerator.tsx b/components/QrGenerator.tsx index 3581851e..67cb2a10 100644 --- a/components/QrGenerator.tsx +++ b/components/QrGenerator.tsx @@ -4,38 +4,18 @@ import { crossChainAllowed } from '@/lib/url/shared'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; -import { env } from '@/lib/env'; import type { Address } from 'viem'; import { parseUnits } from 'viem'; -import { RecoverFeeNotice } from './RecoverFeeNotice'; -import { - ChevronDown, - Coins, - Fuel, - QrCode as QrCodeIcon, - Store, - Zap, -} from 'lucide-react'; -import { AddressInput } from './AddressInput'; -import { ReceiverWalletChip } from './ReceiverWalletChip'; import { AccountingSection } from './AccountingSection'; import { QrPreviewModal } from './QrPreviewModal'; import { PwaInstallHint } from './PwaInstallHint'; import { OfflineLastQr } from './OfflineLastQr'; -import { ChainChooser } from './ChainChooser'; -import { TokenChooser } from './TokenChooser'; -import { Field } from './Field'; -import { StepCard } from './StepCard'; -import { QuickAmountEditor } from './qr/QuickAmountEditor'; -import { ConvertPanel } from './qr/ConvertPanel'; -import { SplitEditor } from './qr/SplitEditor'; -import { - POSTER_NOTE_MAX, - QUICK_AMOUNT_MAX, - STORE_NAME_MAX, - rememberTokenPrefs, - useQrSettings, -} from '@/hooks/useQrSettings'; +import { QrAmountSection, type Mode } from './qr/QrAmountSection'; +import { QrReceiverSection } from './qr/QrReceiverSection'; +import { QrSettingsSection } from './qr/QrSettingsSection'; +import { QrMobileBar, QrPreviewSection } from './qr/QrPreviewSection'; +import { downloadPng, downloadSvg, fileSafe } from './qr/qrDownload'; +import { rememberTokenPrefs, useQrSettings } from '@/hooks/useQrSettings'; import { useReceiverAutofill, type ReceiverSource, @@ -49,9 +29,7 @@ import { buildPayUrl, DECIMAL_PATTERN, parseSplitDrafts, - SPLIT_MAX_ENTRIES, type PayParams, - type SplitDraft, } from '@/lib/url'; import { buildEip681TransferUri } from '@/lib/eip681'; import { @@ -67,71 +45,17 @@ import { rateIsSane } from '@/lib/fx'; import { useFxConvert } from '@/hooks/useFxConvert'; import { useMarketRates } from '@/hooks/useMarketRates'; import { - addressExplorerUrl, buyerUsdcChainNames, chainForSlug, - JPYC_CHAINS, - USDC_CHAINS, type ChainSlug, } from '@/lib/chains'; import type { GasMode, PayMode } from '@/lib/fee'; import { jpycForwarderFor } from '@/lib/relay/forwarderConfig'; import { recoverFeeValue } from '@/lib/relay/recoverFee'; import { resolveJpycGaslessProvider } from '@/lib/jpycGaslessProvider'; -import { isLikelyName } from '@/lib/nameDetection'; import { pickEffectiveAddress, shortAddress, formatTokenAmount } from '@/lib/format'; import { useIncomingPaymentWatch } from '@/hooks/useIncomingPaymentWatch'; -import { normalizeAmountList, truncateAmount } from '@/lib/amount'; -import { triggerDownload } from '@/lib/download'; - -type Mode = 'amount' | 'static'; - -const FILENAME_FALLBACK = 'openpay'; - -// 主要 OS (macOS APFS / Windows NTFS / Linux ext4) は UTF-8 ファイル名を許容する -// ため日本語店舗名 (例「神田珈琲」) もそのまま残す。除去対象は path separator・ -// Windows 予約文字・制御文字・空白・ダッシュ連続のみ。これを ASCII 限定の -// 正規化にすると日本語名が常に fallback に潰れて merchant が混乱する。 -function fileSafe(value: string): string { - const normalized = value - .replace(/[-\\/:*?"<>|\s\x00-\x1f\x7f]+/g, '-') - .replace(/^-+|-+$/g, ''); - return normalized || FILENAME_FALLBACK; -} - -function svgMarkup(ref: React.RefObject): string | null { - const svg = ref.current?.querySelector('svg'); - return svg ? new XMLSerializer().serializeToString(svg) : null; -} - -function downloadSvg(filename: string, ref: React.RefObject) { - const markup = svgMarkup(ref); - if (!markup) return; - const url = URL.createObjectURL( - new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }), - ); - triggerDownload(url, filename); - URL.revokeObjectURL(url); -} - -function downloadPng(filename: string, ref: React.RefObject) { - const markup = svgMarkup(ref); - if (!markup) return; - const img = new Image(); - img.onload = () => { - // QR は常に正方形 (qrcode.react 出力)、img.width = img.height - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.width; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, img.width, img.width); - ctx.drawImage(img, 0, 0); - triggerDownload(canvas.toDataURL('image/png'), filename); - }; - img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`; -} +import { truncateAmount } from '@/lib/amount'; export function QrGenerator() { const { settings: savedSettings, setSettings: saveSettings, hydrated } = useQrSettings(); @@ -145,7 +69,6 @@ export function QrGenerator() { useOfflineQrServiceWorker(); const { save: saveLastQr } = useLocalStorageRecord(LAST_QR_KEY, isLastQrRecord); const qrRef = useRef(null); - const bottomBarRef = useRef(null); // 高度な設定 (payMode / gas / split) は default 閉じる。 const [accordionOpen, setAccordionOpen] = useState(false); const [resolvedReceiver, setResolvedReceiver] = useState
(null); @@ -342,23 +265,6 @@ export function QrGenerator() { convert, ]); - function setSplits(next: SplitDraft[]) { - setSettings((s) => ({ ...s, splits: next })); - } - function addSplit() { - // UI 側 ({splits.length < MAX && ...}) で button が消えるため通常 unreachable。 - if (settings.splits.length >= SPLIT_MAX_ENTRIES) return; - setSplits([...settings.splits, { address: '', percent: '' }]); - } - function removeSplit(idx: number) { - setSplits(settings.splits.filter((_, i) => i !== idx)); - } - function updateSplit(idx: number, patch: Partial) { - setSplits( - settings.splits.map((s, i) => (i === idx ? { ...s, ...patch } : s)), - ); - } - const handleResolved = useCallback((addr: Address | null) => { setResolvedReceiver(addr); }, []); @@ -441,18 +347,6 @@ export function QrGenerator() { enabled: qrModalOpen && mode === 'amount' && receiverValid && amountValid, }); - // WebKit (モバイル Safari・SNS アプリ内ブラウザ) では position:sticky な下部バーの子テキストを - // JS で書き換えても合成レイヤーが再ラスタライズされず古い表示が残ることがある (RegisterMode - // と同根)。表示金額/通貨/モードが変わるたび transform を 1 フレーム入れて再描画を強制する。 - useEffect(() => { - const el = bottomBarRef.current; - if (!el) return; - el.style.transform = 'translateZ(0)'; - const id = requestAnimationFrame(() => { - if (el) el.style.transform = ''; - }); - return () => cancelAnimationFrame(id); - }, [amount, mode, deployment.displaySymbol]); const qrFilename = useMemo(() => { // storeName が空だと fileSafe は 'openpay' に倒れ、同一店舗の複数 QR (商品違い・ // 受取先違い) が全部同名 PNG になり DL フォルダで取り違える。productName があれば @@ -575,53 +469,6 @@ export function QrGenerator() { return t('fiatApprox', { yen: yen.toLocaleString('en-US') }); }, [mode, settings.token, marketRates, amount, t]); - // クイック金額は token (JPYC=円 / USDC=ドル) ごとに独立。エディタ・適用とも - // 現在の token のサブリストだけを操作する。 - const tokenQuickAmounts = settings.quickAmounts[settings.token]; - - function updateQuickAmount(idx: number, value: string) { - setSettings((s) => ({ - ...s, - quickAmounts: { - ...s.quickAmounts, - [s.token]: s.quickAmounts[s.token].map((q, i) => - i === idx ? truncateAmount(value, deployment.decimals) : q, - ), - }, - })); - } - - function addQuickAmount() { - setSettings((s) => ({ - ...s, - quickAmounts: { - ...s.quickAmounts, - [s.token]: [...s.quickAmounts[s.token], ''], - }, - })); - } - - function removeQuickAmount(idx: number) { - setSettings((s) => { - const next = s.quickAmounts[s.token].filter((_, i) => i !== idx); - return { - ...s, - quickAmounts: { - ...s.quickAmounts, - [s.token]: next.length > 0 ? next : [''], - }, - }; - }); - } - - // 現在の token decimals に合わせて truncate してから表示・適用する。truncate 後に - // 重複した値は除外 (例: 0.1234567890123 と 0.1234567890124 を保存 → USDC では - // どちらも 0.123456 に潰れるので片方のみ残す)。 - const activeQuickAmounts = useMemo( - () => normalizeAmountList(tokenQuickAmounts, deployment.decimals), - [tokenQuickAmounts, deployment.decimals], - ); - // モバイル: grid-cols-1 (= minmax(0,1fr)) を明示しないと単一列が auto track となり、下部固定バー // (nowrap の「QRコードを表示する」+ 金額) の max-content まで広がって横はみ出す。 // デスクトップ: 左列も raw 1fr だと minmax(auto,1fr) で content の min-content まで伸び、金額指定 @@ -637,237 +484,48 @@ export function QrGenerator() {
{/* ① 金額: 通貨 / 受取チェーン / 請求金額。店員が毎回触る金額を先頭に置く (受取先は初期設定後あまり変えないため ② へ・折りたたみ)。 */} - -
- {/* token + chain は金額のシンボル表示にも影響するので金額と同じ① に。 */} - - - - - - - - - -
-
- {( - [ - ['amount', t('modeAmount')], - ['static', t('modeStatic')], - ] as const - ).map(([m, label]) => ( - - ))} -
- {mode === 'amount' ? ( -
- {/* 金額ヒーロー: 入力欄を「表示器」化して数字を主役に。通貨記号は控えめな - 接尾、その真下に参考円 (USDC のみ・JPYC は ¥ ペッグで冗長ゆえ非表示)。 - 枠線は container 側に寄せ、focus で brand + 浮き影。 */} -
-
- { - setAmount( - truncateAmount(e.target.value, deployment.decimals), - ); - resetConvert(); - }} - placeholder={settings.token === 'jpyc' ? '1000' : '10.00'} - aria-label={t('amountLabel', { - symbol: deployment.displaySymbol, - })} - className="min-w-0 flex-1 bg-transparent text-right text-4xl font-bold tabular-nums tracking-tight text-slate-900 placeholder:text-slate-300 focus:outline-none sm:text-5xl" - autoFocus - /> - - {deployment.displaySymbol} - -
- {fiatHint && ( -
- {fiatHint} -
- )} -
- {activeQuickAmounts.length > 0 && ( -
- {activeQuickAmounts.map((q) => ( - - ))} -
- )} -
- ) : ( -

- {t('staticHint')} -

- )} -
-
- - {/* クイック金額の編集 (任意・token ごと独立)。金額入力の近くで設定できる - よう高度な設定から①へ移設。 */} - {mode === 'amount' && ( - - )} - - -
- -
+ {/* ② 受取先: 受取ウォレット / 店舗名 / ポスター補足文。初期設定後あまり 変えないので折りたたみ (未設定なら開く・設定済は閉じて1行サマリ)。 */} - setStep2Open((o) => !o)} - collapsedSummary={ - - } - > -
- - - - {settings.receiver && - !receiverValid && - !isLikelyName(settings.receiver) && ( -

- {t('addressInvalid')} -

- )} - {/* a1 利用料の注記: 受取先が接続ウォレットと違うとき、ガスレス利用料の請求/支払いは - その受取先アドレスでサインインする必要がある (請求は着金先に紐づくため)。a1 点灯時のみ。 */} - {env.enableUsageFee && - autofill.canUseConnected && - effectiveReceiver && ( -

- {tFee('receiverMismatchNote')} -

- )} - {/* 受取先確定後に Explorer の /address/ へ link (チェーン上が source of - truth であることを店主に毎回視認させる)。 */} - {effectiveReceiver && ( - - {t('merchantExplorerLink', { chainName: chain.name })} - - )} -
- - - - setSettings((s) => ({ ...s, storeName: e.target.value })) - } - placeholder={t('storeNamePlaceholder')} - className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand focus:outline-none" - maxLength={STORE_NAME_MAX} - /> - - - - - setSettings((s) => ({ ...s, posterNote: e.target.value })) - } - placeholder={t('posterNotePlaceholder')} - className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand focus:outline-none" - maxLength={POSTER_NOTE_MAX} - /> - -
-
+ {/* ▸ 記帳・会計 (任意): ② の後の独立折りたたみ。3 モード共通 AccountingSection。 QR は manual variant (商品名/メモ/税/管理番号を手入力・未入力なら URL 不変)。 */} @@ -898,163 +556,20 @@ export function QrGenerator() { }} /> - setAccordionOpen((o) => !o)} - summaryLabel={t('advancedSettings')} - summary={ - - } - > - -
- {(['gasless', 'standard'] as PayMode[]).map((pm) => { - const active = settings.payMode === pm; - const isGasless = pm === 'gasless'; - const ModeIcon = isGasless ? Zap : Fuel; - const iconColor = isGasless ? 'text-emerald-600' : 'text-amber-600'; - // gasless 非対応 chain (paymasterMode=unavailable) では gasless - // button を disable。click は selectChain 側で防御済だが、UI - // 上でも明示的に grey-out して standard 強制を視覚化する。 - const disabled = isGasless && !isGaslessSupported(deployment); - return ( - - ); - })} -
-
- - {isStandard ? ( -
- {settings.chain === 'arc' ? t('standardArcHint') : t('standardHint')} -
- ) : isJpycRecover ? ( - // JPYC recover は店舗が手数料を吸収する固定モデル (per-QR トグル撤去)。 - // 利用料の開示は直上 payModeGaslessDesc が担うため固定ヒントは出さない - // (2026-07 user 指示で撤去)。gas トグルを出さない分岐だけ維持する。 - null - ) : !hideGasMode ? ( - -
- {(['customer', 'merchant'] as GasMode[]).map((g) => { - const active = settings.gasMode === g; - return ( - - ); - })} -
-
- ) : null} - - {!isStandard && ( - - )} - - {/* Cross-chain 受信許可 toggle (USDC のみ意味あり、JPYC では disable)。 - Default ON。Off にすると PaymentForm が代替経路 hint を出さない - (店主が「同一 chain で受け取りたい」と明示する用途)。 */} - {settings.token === 'usdc' && crossChainAllowed(settings.chain) && ( - - - - )} - - {/* fee=0 のため徴収先 section は撤去 (Phase 1 alpha)。 */} -
+ {/* A2HS 導線: 初回 QR モーダルを開いた後 (ピークモーメント) にだけ出す。 standalone / dismiss 済では PwaInstallHint 側で自動非表示。 */} @@ -1066,73 +581,14 @@ export function QrGenerator() { )}
-
- -

- {t('qrDescription')} -

-
- {payUrl ? ( - // 即時表示せず、目立つボタン → 全画面モーダルで提示 (店員が金額を確認 - // してからお客様に画面を見せる対面フロー)。 - // ボタンは lg (右サイドバー) のみ表示。モバイルは下部固定バーが担うので - // ここでは出さず、「QRコードを表示する」が2つ並ぶのを防ぐ (誘導文だけ出す)。 - <> - -

- {t('qrMobileBarHint')} -

- - ) : receiverValid && amountValid ? ( - // receiver + amount valid だが payUrl 未確定の遷移状態 (origin 空 = SSR / - // hydrate 直後の数フレーム間)。「生成中」で混乱を回避する。 -

- {t('qrPlaceholderGenerating')} -

- ) : ( - - setAmount(settings.token === 'usdc' ? '5' : '1000'), - } - : undefined - } - /> - )} -
-
-
+ {/* 全画面プレビュー (ポスター調 + 印刷/コピー/SVG/PNG + × 閉じる)。決済QR/レジ共通。 */} {payUrl && ( @@ -1220,206 +676,16 @@ export function QrGenerator() { static モードは「金額を入力」表示)。グローバル BottomNav (fixed bottom-0 z-20・md:hidden) の上に重ねるため < md は bottom-14 で nav 分浮かせ、md 以上 (nav 非表示) は bottom-0。 lg は右サイドバー CTA を使うので非表示。payUrl 真のときのみ (Step3 と同じゲート)。 */} - {payUrl && ( -
-
-
- {t('bottomAmountLabel')} -
-
- - {amountLabelText} - - {fiatHint && ( - - {fiatHint} - - )} -
-
- -
- )} + ); } - -// ② 受取先を折りたたんだ時の 1 行サマリ。設定済なら「店舗名 · 0x1234…abcd」、 -// 未設定なら fallback 文言。 -function Step2Summary({ - storeName, - receiver, - fallback, -}: { - storeName: string; - receiver: Address | null; - fallback: string; -}) { - if (!receiver) return {fallback}; - const addr = shortAddress(receiver); - const name = storeName.trim(); - return {name ? `${name} · ${addr}` : addr}; -} - -// 必要な項目を checkmark 付きで明示する empty state。初見店主が「何を -// 入れれば QR が出るか」を一目で理解できるようにする (review #2 + #8)。 -function QrEmptyState({ - title, - needLabel, - items, - sample, -}: { - title: string; - needLabel: string; - items: { label: string; done: boolean }[]; - // 受取先は済だが金額未入力のとき、サンプル金額ワンタップで最初の QR を出す導線。 - sample?: { label: string; onUse: () => void }; -}) { - return ( -
-

{title}

-
-

- {needLabel} -

-
    - {items.map((item) => ( -
  • - - {item.done ? '✓' : ''} - - - {item.label} - -
  • - ))} -
-
- {sample && ( - - )} -
- ); -} - -function SettingsAccordion({ - open, - onToggle, - summary, - summaryLabel, - children, -}: { - open: boolean; - onToggle: () => void; - summary: React.ReactNode; - summaryLabel: string; - children: React.ReactNode; -}) { - return ( -
- - {open && ( -
- {children} -
- )} -
- ); -} - -function SettingsSummary({ - gasMode, - payMode, - showGasMode, - jpycRecover, -}: { - gasMode: GasMode; - payMode: PayMode; - // 負担者 (顧客/店主) を summary にトグル選択として出すのは USDC recover 等のみ。 - // free (概念なし) / JPYC recover (merchant 固定) では選択トグルを出さない。 - showGasMode: boolean; - // JPYC recover (確定モデルで店舗が手数料を吸収する固定) のとき true。固定であることを - // 明示する専用ラベルを出す (USDC のトグル選択 merchant とは文言を分ける)。 - jpycRecover: boolean; -}) { - // 高度な設定 accordion 内には payMode / gas / split のみ (quickAmount は Step ①、 - // 手数料徴収先は fee=0 のため撤去済)。summary では payMode (+ 負担者) を日本語/英語の - // 自然文で表示する。token / chain は Step 1、receiver は Step 2 summary に出るので - // ここでは重複させない。font-mono は外し、開発者向け内部値に見えないようにする。 - const t = useTranslations('QrGenerator'); - const label = - payMode === 'standard' - ? t('advancedSummary.standard') - : jpycRecover - ? t('advancedSummary.gaslessMerchantFixed') - : !showGasMode - ? t('advancedSummary.gaslessFree') - : gasMode === 'customer' - ? t('advancedSummary.gaslessCustomerGas') - : t('advancedSummary.gaslessMerchantGas'); - return {label}; -} - -function AdvancedSection({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) { - return ( -
-

- {label} -

- {children} -
- ); -} diff --git a/components/qr/QrAmountSection.tsx b/components/qr/QrAmountSection.tsx new file mode 100644 index 00000000..e738ffd8 --- /dev/null +++ b/components/qr/QrAmountSection.tsx @@ -0,0 +1,277 @@ +'use client'; + +import { + useMemo, + type ComponentProps, + type Dispatch, + type SetStateAction, +} from 'react'; +import { useTranslations } from 'next-intl'; +import { Coins } from 'lucide-react'; +import { RecoverFeeNotice } from '../RecoverFeeNotice'; +import { ChainChooser } from '../ChainChooser'; +import { TokenChooser } from '../TokenChooser'; +import { Field } from '../Field'; +import { StepCard } from '../StepCard'; +import { QuickAmountEditor } from './QuickAmountEditor'; +import { ConvertPanel } from './ConvertPanel'; +import { QUICK_AMOUNT_MAX, type QrSettings } from '@/hooks/useQrSettings'; +import type { TokenDeployment, TokenSymbol } from '@/lib/tokens'; +import { JPYC_CHAINS, USDC_CHAINS, type ChainSlug } from '@/lib/chains'; +import type { GasMode } from '@/lib/fee'; +import { normalizeAmountList, truncateAmount } from '@/lib/amount'; + +export type Mode = 'amount' | 'static'; + +type ConvertPanelProps = ComponentProps; + +// ① 金額 (通貨 / 受取チェーン / 請求金額 / クイック金額 / FX 換算 / recover 手数料開示)。 +// 金額・モード・FX・保存設定の状態と token/chain 切替は QrGenerator が持つ。ここは描画と、 +// 現在の token のクイック金額サブリストの編集だけを行う。 +export function QrAmountSection({ + settings, + setSettings, + deployment, + mode, + setMode, + amount, + setAmount, + resetConvert, + fiatHint, + selectToken, + selectChain, + canShowConvert, + rateOk, + convert, + convertExpired, + convertRemaining, + convertTargetDisplay, + convertAnchorDisplay, + applyConvert, + recalcConvert, + revertConvert, + fxWarning, + acknowledgeFxWarning, + recoverBillAmount, + recoverGasMode, +}: { + settings: QrSettings; + setSettings: Dispatch>; + deployment: TokenDeployment; + mode: Mode; + setMode: Dispatch>; + amount: string; + setAmount: Dispatch>; + resetConvert: () => void; + fiatHint: string | null; + selectToken: (tok: TokenSymbol) => void; + selectChain: (slug: ChainSlug) => void; + canShowConvert: boolean; + rateOk: boolean; + convert: ConvertPanelProps['convert']; + convertExpired: boolean; + convertRemaining: number; + convertTargetDisplay: string; + convertAnchorDisplay: string; + applyConvert: () => void; + recalcConvert: () => void; + revertConvert: () => void; + fxWarning: ConvertPanelProps['fxWarning']; + acknowledgeFxWarning: () => void; + recoverBillAmount: bigint | null; + recoverGasMode: GasMode; +}) { + const t = useTranslations('QrGenerator'); + + // クイック金額は token (JPYC=円 / USDC=ドル) ごとに独立。エディタ・適用とも + // 現在の token のサブリストだけを操作する。 + const tokenQuickAmounts = settings.quickAmounts[settings.token]; + + function updateQuickAmount(idx: number, value: string) { + setSettings((s) => ({ + ...s, + quickAmounts: { + ...s.quickAmounts, + [s.token]: s.quickAmounts[s.token].map((q, i) => + i === idx ? truncateAmount(value, deployment.decimals) : q, + ), + }, + })); + } + + function addQuickAmount() { + setSettings((s) => ({ + ...s, + quickAmounts: { + ...s.quickAmounts, + [s.token]: [...s.quickAmounts[s.token], ''], + }, + })); + } + + function removeQuickAmount(idx: number) { + setSettings((s) => { + const next = s.quickAmounts[s.token].filter((_, i) => i !== idx); + return { + ...s, + quickAmounts: { + ...s.quickAmounts, + [s.token]: next.length > 0 ? next : [''], + }, + }; + }); + } + + // 現在の token decimals に合わせて truncate してから表示・適用する。truncate 後に + // 重複した値は除外 (例: 0.1234567890123 と 0.1234567890124 を保存 → USDC では + // どちらも 0.123456 に潰れるので片方のみ残す)。 + const activeQuickAmounts = useMemo( + () => normalizeAmountList(tokenQuickAmounts, deployment.decimals), + [tokenQuickAmounts, deployment.decimals], + ); + + return ( + +
+ {/* token + chain は金額のシンボル表示にも影響するので金額と同じ① に。 */} + + + + + + + + + +
+
+ {( + [ + ['amount', t('modeAmount')], + ['static', t('modeStatic')], + ] as const + ).map(([m, label]) => ( + + ))} +
+ {mode === 'amount' ? ( +
+ {/* 金額ヒーロー: 入力欄を「表示器」化して数字を主役に。通貨記号は控えめな + 接尾、その真下に参考円 (USDC のみ・JPYC は ¥ ペッグで冗長ゆえ非表示)。 + 枠線は container 側に寄せ、focus で brand + 浮き影。 */} +
+
+ { + setAmount( + truncateAmount(e.target.value, deployment.decimals), + ); + resetConvert(); + }} + placeholder={settings.token === 'jpyc' ? '1000' : '10.00'} + aria-label={t('amountLabel', { + symbol: deployment.displaySymbol, + })} + className="min-w-0 flex-1 bg-transparent text-right text-4xl font-bold tabular-nums tracking-tight text-slate-900 placeholder:text-slate-300 focus:outline-none sm:text-5xl" + autoFocus + /> + + {deployment.displaySymbol} + +
+ {fiatHint && ( +
+ {fiatHint} +
+ )} +
+ {activeQuickAmounts.length > 0 && ( +
+ {activeQuickAmounts.map((q) => ( + + ))} +
+ )} +
+ ) : ( +

+ {t('staticHint')} +

+ )} +
+
+ + {/* クイック金額の編集 (任意・token ごと独立)。金額入力の近くで設定できる + よう高度な設定から①へ移設。 */} + {mode === 'amount' && ( + + )} + + +
+ +
+ ); +} diff --git a/components/qr/QrPreviewSection.tsx b/components/qr/QrPreviewSection.tsx new file mode 100644 index 00000000..35c457be --- /dev/null +++ b/components/qr/QrPreviewSection.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { + useEffect, + useRef, + type Dispatch, + type SetStateAction, +} from 'react'; +import { useTranslations } from 'next-intl'; +import { QrCode as QrCodeIcon } from 'lucide-react'; +import { StepCard } from '../StepCard'; +import type { QrSettings } from '@/hooks/useQrSettings'; +import type { TokenDeployment } from '@/lib/tokens'; +import type { Mode } from './QrAmountSection'; + +// ③ QR (右列): 表示ボタン / 生成中 / 空状態。モーダルの開閉状態・payUrl の導出・ +// モーダル本体 (qrRef・保存・印刷・前回 QR の保存) は QrGenerator に残す。 +export function QrPreviewSection({ + payUrl, + receiverValid, + amountValid, + settings, + setAmount, + setQrModalOpen, +}: { + payUrl: string; + receiverValid: boolean; + amountValid: boolean; + settings: QrSettings; + setAmount: Dispatch>; + setQrModalOpen: Dispatch>; +}) { + const t = useTranslations('QrGenerator'); + return ( +
+ +

+ {t('qrDescription')} +

+
+ {payUrl ? ( + // 即時表示せず、目立つボタン → 全画面モーダルで提示 (店員が金額を確認 + // してからお客様に画面を見せる対面フロー)。 + // ボタンは lg (右サイドバー) のみ表示。モバイルは下部固定バーが担うので + // ここでは出さず、「QRコードを表示する」が2つ並ぶのを防ぐ (誘導文だけ出す)。 + <> + +

+ {t('qrMobileBarHint')} +

+ + ) : receiverValid && amountValid ? ( + // receiver + amount valid だが payUrl 未確定の遷移状態 (origin 空 = SSR / + // hydrate 直後の数フレーム間)。「生成中」で混乱を回避する。 +

+ {t('qrPlaceholderGenerating')} +

+ ) : ( + + setAmount(settings.token === 'usdc' ? '5' : '1000'), + } + : undefined + } + /> + )} +
+
+
+ ); +} + +// モバイル下部固定 会計バー。payUrl が無い間も mount したまま null を返す — 再描画 effect を +// バーの出現で発火させず、従来どおり金額 / モード / 通貨記号の変化時だけにするため。 +export function QrMobileBar({ + payUrl, + amount, + mode, + deployment, + amountLabelText, + fiatHint, + setQrModalOpen, +}: { + payUrl: string; + amount: string; + mode: Mode; + deployment: TokenDeployment; + amountLabelText: string; + fiatHint: string | null; + setQrModalOpen: Dispatch>; +}) { + const t = useTranslations('QrGenerator'); + const bottomBarRef = useRef(null); + // WebKit (モバイル Safari・SNS アプリ内ブラウザ) では position:sticky な下部バーの子テキストを + // JS で書き換えても合成レイヤーが再ラスタライズされず古い表示が残ることがある (RegisterMode + // と同根)。表示金額/通貨/モードが変わるたび transform を 1 フレーム入れて再描画を強制する。 + useEffect(() => { + const el = bottomBarRef.current; + if (!el) return; + el.style.transform = 'translateZ(0)'; + const id = requestAnimationFrame(() => { + if (el) el.style.transform = ''; + }); + return () => cancelAnimationFrame(id); + }, [amount, mode, deployment.displaySymbol]); + if (!payUrl) return null; + return ( +
+
+
+ {t('bottomAmountLabel')} +
+
+ + {amountLabelText} + + {fiatHint && ( + + {fiatHint} + + )} +
+
+ +
+ ); +} + +// 必要な項目を checkmark 付きで明示する empty state。初見店主が「何を +// 入れれば QR が出るか」を一目で理解できるようにする (review #2 + #8)。 +function QrEmptyState({ + title, + needLabel, + items, + sample, +}: { + title: string; + needLabel: string; + items: { label: string; done: boolean }[]; + // 受取先は済だが金額未入力のとき、サンプル金額ワンタップで最初の QR を出す導線。 + sample?: { label: string; onUse: () => void }; +}) { + return ( +
+

{title}

+
+

+ {needLabel} +

+
    + {items.map((item) => ( +
  • + + {item.done ? '✓' : ''} + + + {item.label} + +
  • + ))} +
+
+ {sample && ( + + )} +
+ ); +} diff --git a/components/qr/QrReceiverSection.tsx b/components/qr/QrReceiverSection.tsx new file mode 100644 index 00000000..bedebcc0 --- /dev/null +++ b/components/qr/QrReceiverSection.tsx @@ -0,0 +1,138 @@ +'use client'; + +import type { Dispatch, SetStateAction } from 'react'; +import { useTranslations } from 'next-intl'; +import type { Address, Chain } from 'viem'; +import { Store } from 'lucide-react'; +import { env } from '@/lib/env'; +import { AddressInput } from '../AddressInput'; +import { ReceiverWalletChip } from '../ReceiverWalletChip'; +import { Field } from '../Field'; +import { StepCard } from '../StepCard'; +import { Step2Summary } from './QrSummaries'; +import { + POSTER_NOTE_MAX, + STORE_NAME_MAX, + type QrSettings, +} from '@/hooks/useQrSettings'; +import type { useReceiverAutofill } from '@/hooks/useReceiverAutofill'; +import type { TokenDeployment } from '@/lib/tokens'; +import { addressExplorerUrl } from '@/lib/chains'; +import { isLikelyName } from '@/lib/nameDetection'; + +// ② 受取先 (受取ウォレット / 店舗名 / ポスター補足文)。開閉状態・受取先の解決と +// 自動補完は QrGenerator が持ち、ここは描画と入力の反映だけを行う。 +export function QrReceiverSection({ + settings, + setSettings, + deployment, + chain, + step2Open, + setStep2Open, + effectiveReceiver, + receiverValid, + autofill, + handleResolved, +}: { + settings: QrSettings; + setSettings: Dispatch>; + deployment: TokenDeployment; + chain: Chain; + step2Open: boolean; + setStep2Open: Dispatch>; + effectiveReceiver: Address | null; + receiverValid: boolean; + autofill: ReturnType; + handleResolved: (addr: Address | null) => void; +}) { + const t = useTranslations('QrGenerator'); + const tFee = useTranslations('UsageFee'); + return ( + setStep2Open((o) => !o)} + collapsedSummary={ + + } + > +
+ + + + {settings.receiver && + !receiverValid && + !isLikelyName(settings.receiver) && ( +

+ {t('addressInvalid')} +

+ )} + {/* a1 利用料の注記: 受取先が接続ウォレットと違うとき、ガスレス利用料の請求/支払いは + その受取先アドレスでサインインする必要がある (請求は着金先に紐づくため)。a1 点灯時のみ。 */} + {env.enableUsageFee && + autofill.canUseConnected && + effectiveReceiver && ( +

+ {tFee('receiverMismatchNote')} +

+ )} + {/* 受取先確定後に Explorer の /address/ へ link (チェーン上が source of + truth であることを店主に毎回視認させる)。 */} + {effectiveReceiver && ( + + {t('merchantExplorerLink', { chainName: chain.name })} + + )} +
+ + + + setSettings((s) => ({ ...s, storeName: e.target.value })) + } + placeholder={t('storeNamePlaceholder')} + className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand focus:outline-none" + maxLength={STORE_NAME_MAX} + /> + + + + + setSettings((s) => ({ ...s, posterNote: e.target.value })) + } + placeholder={t('posterNotePlaceholder')} + className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand focus:outline-none" + maxLength={POSTER_NOTE_MAX} + /> + +
+
+ ); +} diff --git a/components/qr/QrSettingsSection.tsx b/components/qr/QrSettingsSection.tsx new file mode 100644 index 00000000..96586cb1 --- /dev/null +++ b/components/qr/QrSettingsSection.tsx @@ -0,0 +1,290 @@ +'use client'; + +import type { Dispatch, SetStateAction } from 'react'; +import { useTranslations } from 'next-intl'; +import { ChevronDown, Fuel, Zap } from 'lucide-react'; +import { Field } from '../Field'; +import { SplitEditor } from './SplitEditor'; +import { SettingsSummary } from './QrSummaries'; +import type { QrSettings } from '@/hooks/useQrSettings'; +import { crossChainAllowed } from '@/lib/url/shared'; +import { + SPLIT_MAX_ENTRIES, + type SplitDraft, + type SplitDraftsParseResult, + type SplitEntry, +} from '@/lib/url'; +import { isGaslessSupported, type TokenDeployment } from '@/lib/tokens'; +import type { GasMode, PayMode } from '@/lib/fee'; + +// 高度な設定 (決済モード / ガス負担者 / 売上の自動分配 / 他チェーンからの受取)。 +// 開閉状態と gas / split の導出 (URL に焼く値) は QrGenerator が持ち、ここは描画と +// 設定の編集だけを行う。 +export function QrSettingsSection({ + settings, + setSettings, + deployment, + accordionOpen, + setAccordionOpen, + effectiveGasMode, + payMode, + hideGasMode, + isJpycRecover, + isStandard, + splitParsed, + splitsForUrl, +}: { + settings: QrSettings; + setSettings: Dispatch>; + deployment: TokenDeployment; + accordionOpen: boolean; + setAccordionOpen: Dispatch>; + effectiveGasMode: GasMode; + payMode: PayMode; + hideGasMode: boolean; + isJpycRecover: boolean; + isStandard: boolean; + splitParsed: SplitDraftsParseResult; + splitsForUrl: SplitEntry[] | undefined; +}) { + const t = useTranslations('QrGenerator'); + + function setSplits(next: SplitDraft[]) { + setSettings((s) => ({ ...s, splits: next })); + } + function addSplit() { + // UI 側 ({splits.length < MAX && ...}) で button が消えるため通常 unreachable。 + if (settings.splits.length >= SPLIT_MAX_ENTRIES) return; + setSplits([...settings.splits, { address: '', percent: '' }]); + } + function removeSplit(idx: number) { + setSplits(settings.splits.filter((_, i) => i !== idx)); + } + function updateSplit(idx: number, patch: Partial) { + setSplits( + settings.splits.map((s, i) => (i === idx ? { ...s, ...patch } : s)), + ); + } + + return ( + setAccordionOpen((o) => !o)} + summaryLabel={t('advancedSettings')} + summary={ + + } + > + +
+ {(['gasless', 'standard'] as PayMode[]).map((pm) => { + const active = settings.payMode === pm; + const isGasless = pm === 'gasless'; + const ModeIcon = isGasless ? Zap : Fuel; + const iconColor = isGasless ? 'text-emerald-600' : 'text-amber-600'; + // gasless 非対応 chain (paymasterMode=unavailable) では gasless + // button を disable。click は selectChain 側で防御済だが、UI + // 上でも明示的に grey-out して standard 強制を視覚化する。 + const disabled = isGasless && !isGaslessSupported(deployment); + return ( + + ); + })} +
+
+ + {isStandard ? ( +
+ {settings.chain === 'arc' ? t('standardArcHint') : t('standardHint')} +
+ ) : isJpycRecover ? ( + // JPYC recover は店舗が手数料を吸収する固定モデル (per-QR トグル撤去)。 + // 利用料の開示は直上 payModeGaslessDesc が担うため固定ヒントは出さない + // (2026-07 user 指示で撤去)。gas トグルを出さない分岐だけ維持する。 + null + ) : !hideGasMode ? ( + +
+ {(['customer', 'merchant'] as GasMode[]).map((g) => { + const active = settings.gasMode === g; + return ( + + ); + })} +
+
+ ) : null} + + {!isStandard && ( + + )} + + {/* Cross-chain 受信許可 toggle (USDC のみ意味あり、JPYC では disable)。 + Default ON。Off にすると PaymentForm が代替経路 hint を出さない + (店主が「同一 chain で受け取りたい」と明示する用途)。 */} + {settings.token === 'usdc' && crossChainAllowed(settings.chain) && ( + + + + )} + + {/* fee=0 のため徴収先 section は撤去 (Phase 1 alpha)。 */} +
+ ); +} + +function SettingsAccordion({ + open, + onToggle, + summary, + summaryLabel, + children, +}: { + open: boolean; + onToggle: () => void; + summary: React.ReactNode; + summaryLabel: string; + children: React.ReactNode; +}) { + return ( +
+ + {open && ( +
+ {children} +
+ )} +
+ ); +} + +function AdvancedSection({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+

+ {label} +

+ {children} +
+ ); +} diff --git a/components/qr/QrSummaries.tsx b/components/qr/QrSummaries.tsx new file mode 100644 index 00000000..7d0f900e --- /dev/null +++ b/components/qr/QrSummaries.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import type { Address } from 'viem'; +import type { GasMode, PayMode } from '@/lib/fee'; +import { shortAddress } from '@/lib/format'; + +// ② 受取先を折りたたんだ時の 1 行サマリ。設定済なら「店舗名 · 0x1234…abcd」、 +// 未設定なら fallback 文言。 +export function Step2Summary({ + storeName, + receiver, + fallback, +}: { + storeName: string; + receiver: Address | null; + fallback: string; +}) { + if (!receiver) return {fallback}; + const addr = shortAddress(receiver); + const name = storeName.trim(); + return {name ? `${name} · ${addr}` : addr}; +} + +export function SettingsSummary({ + gasMode, + payMode, + showGasMode, + jpycRecover, +}: { + gasMode: GasMode; + payMode: PayMode; + // 負担者 (顧客/店主) を summary にトグル選択として出すのは USDC recover 等のみ。 + // free (概念なし) / JPYC recover (merchant 固定) では選択トグルを出さない。 + showGasMode: boolean; + // JPYC recover (確定モデルで店舗が手数料を吸収する固定) のとき true。固定であることを + // 明示する専用ラベルを出す (USDC のトグル選択 merchant とは文言を分ける)。 + jpycRecover: boolean; +}) { + // 高度な設定 accordion 内には payMode / gas / split のみ (quickAmount は Step ①、 + // 手数料徴収先は fee=0 のため撤去済)。summary では payMode (+ 負担者) を日本語/英語の + // 自然文で表示する。token / chain は Step 1、receiver は Step 2 summary に出るので + // ここでは重複させない。font-mono は外し、開発者向け内部値に見えないようにする。 + const t = useTranslations('QrGenerator'); + const label = + payMode === 'standard' + ? t('advancedSummary.standard') + : jpycRecover + ? t('advancedSummary.gaslessMerchantFixed') + : !showGasMode + ? t('advancedSummary.gaslessFree') + : gasMode === 'customer' + ? t('advancedSummary.gaslessCustomerGas') + : t('advancedSummary.gaslessMerchantGas'); + return {label}; +} diff --git a/components/qr/qrDownload.ts b/components/qr/qrDownload.ts new file mode 100644 index 00000000..318db673 --- /dev/null +++ b/components/qr/qrDownload.ts @@ -0,0 +1,51 @@ +// 決済 QR の SVG / PNG 保存 (QrGenerator から移設・処理は不変)。qrRef の所有と +// 呼び出し (モーダルの保存ボタン) は QrGenerator に残し、ここは渡された ref の +// を直列化してダウンロードさせるだけ。 +import { triggerDownload } from '@/lib/download'; + +const FILENAME_FALLBACK = 'openpay'; + +// 主要 OS (macOS APFS / Windows NTFS / Linux ext4) は UTF-8 ファイル名を許容する +// ため日本語店舗名 (例「神田珈琲」) もそのまま残す。除去対象は path separator・ +// Windows 予約文字・制御文字・空白・ダッシュ連続のみ。これを ASCII 限定の +// 正規化にすると日本語名が常に fallback に潰れて merchant が混乱する。 +export function fileSafe(value: string): string { + const normalized = value + .replace(/[-\\/:*?"<>|\s\x00-\x1f\x7f]+/g, '-') + .replace(/^-+|-+$/g, ''); + return normalized || FILENAME_FALLBACK; +} + +function svgMarkup(ref: React.RefObject): string | null { + const svg = ref.current?.querySelector('svg'); + return svg ? new XMLSerializer().serializeToString(svg) : null; +} + +export function downloadSvg(filename: string, ref: React.RefObject) { + const markup = svgMarkup(ref); + if (!markup) return; + const url = URL.createObjectURL( + new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }), + ); + triggerDownload(url, filename); + URL.revokeObjectURL(url); +} + +export function downloadPng(filename: string, ref: React.RefObject) { + const markup = svgMarkup(ref); + if (!markup) return; + const img = new Image(); + img.onload = () => { + // QR は常に正方形 (qrcode.react 出力)、img.width = img.height + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.width; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, img.width, img.width); + ctx.drawImage(img, 0, 0); + triggerDownload(canvas.toDataURL('image/png'), filename); + }; + img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`; +} diff --git a/tests/components/QrGenerator.extraction.test.tsx b/tests/components/QrGenerator.extraction.test.tsx new file mode 100644 index 00000000..6b2ae4e8 --- /dev/null +++ b/tests/components/QrGenerator.extraction.test.tsx @@ -0,0 +1,914 @@ +import { createHash } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, screen, within } from '@testing-library/react'; +import { renderWithIntl as render } from '../_helpers/i18n'; +import type { QrSettings } from '@/hooks/useQrSettings'; +import ja from '@/messages/ja.json'; +import en from '@/messages/en.json'; + +// R11b (QrGenerator の表示節を components/qr/* へ抽出) の pinning。 +// 期待値はすべて抽出前の main で記録・確認したもの。抽出に合わせて作り直さない +// (Phase 6 原則 2)。固定するもの: +// - 生成される決済 URL / EIP-681 URI の完全一致 (JPYC/USDC・チェーン・金額有無・会計メタ・split・FX) +// - 各節 (①金額 ②受取先 会計 高度な設定 ③QR 下部バー モーダル) の DOM (要素・属性・class・順序・文言) +// - 子 component への分割で remount しないこと (focus・
の開閉・入力 node の同一性) +// - モーダルの再表示とキャッシュ (コピー状態・前回 QR の保存・着金監視の再開) +// - FX の期限が子の mount/unmount をまたいで保たれること +// - SVG/PNG 保存が「今の」主 QR の ref を読むこと・印刷 +// - 下部バーの WebKit 再描画 effect の発火条件 (金額/モード/通貨記号の変化時だけ) + +const mocks = vi.hoisted(() => ({ + balance: undefined as bigint | undefined, + read: vi.fn(), + provider: vi.fn((): string => 'pimlico-7702'), + forwarder: vi.fn((): `0x${string}` | null => null), + origin: 'https://test.local', + account: { + address: undefined as `0x${string}` | undefined, + isConnected: false, + }, + usageFee: false, +})); +vi.mock('wagmi', () => ({ + useAccount: () => mocks.account, + useReadContract: (args: unknown) => { + mocks.read(args); + return { data: mocks.balance }; + }, +})); +vi.mock('@/hooks/useResolveAddress', () => ({ + useResolveAddress: () => ({ data: null, isFetching: false, error: null }), +})); +vi.mock('@/hooks/useOrigin', () => ({ useOrigin: () => mocks.origin })); +vi.mock('@/hooks/useMarketRates', () => ({ + useMarketRates: () => ({ + data: { usdcJpy: 150, updatedAt: '2026-09-24T00:00:00.000Z' }, + }), +})); +vi.mock('@/lib/jpycGaslessProvider', async (importOriginal) => ({ + ...(await importOriginal()), + resolveJpycGaslessProvider: mocks.provider, +})); +vi.mock('@/lib/relay/forwarderConfig', async (importOriginal) => ({ + ...(await importOriginal()), + jpycForwarderFor: mocks.forwarder, +})); +// a1 利用料の注記 (受取先 ≠ 接続ウォレット) だけを点灯できるよう enableUsageFee を差し替える。 +vi.mock('@/lib/env', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + env: new Proxy(actual.env, { + get: (target, key) => + key === 'enableUsageFee' ? mocks.usageFee : Reflect.get(target, key), + }), + }; +}); + +import { QrGenerator } from '@/components/QrGenerator'; + +type Locale = 'ja' | 'en'; + +const RECEIVER = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; +const CONNECTED = '0x1111111111111111111111111111111111111111'; +const SPLIT = '0x2222222222222222222222222222222222222222'; +const FORWARDER = '0x1234567890123456789012345678901234567890'; +const BASE_URL = `https://test.local/pay?to=${RECEIVER}`; +const NOW = Date.parse('2026-09-24T00:00:00.000Z'); +const SETTINGS_KEY = 'openpay:qr-settings:v2'; +const LAST_QR_KEY = 'openpay:lastQr:v1'; +const frames = new Map(); + +let locale: Locale = 'ja'; +let container: HTMLElement; +const msgs = () => (locale === 'ja' ? ja : en); +const labels = () => msgs().QrGenerator; + +function seed(settings: Partial = {}) { + localStorage.setItem( + SETTINGS_KEY, + JSON.stringify({ receiver: RECEIVER, receiverSource: 'manual', ...settings }), + ); +} + +function renderQr() { + const view = render(, { locale }); + container = view.container; + return view; +} + +async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(20); + }); +} + +function flushFrames() { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(0); +} + +function amountInput() { + const pattern = labels() + .amountLabel.replace(/[()]/g, '\\$&') + .replace('{symbol}', '[A-Z]+'); + return screen.getByRole('textbox', { name: new RegExp(`^${pattern}$`) }); +} + +function amount(value: string) { + fireEvent.change(amountInput(), { target: { value } }); +} + +function receiverInput() { + return screen.getByPlaceholderText(msgs().AddressInput.placeholder); +} + +function step2Toggle() { + return container.querySelector( + 'button[aria-controls="step-2-body"]', + )!; +} + +function advancedToggle() { + return screen.getByRole('button', { + name: new RegExp(labels().advancedSettings), + }); +} + +function openQr() { + fireEvent.click(screen.getAllByRole('button', { name: labels().showQr })[0]); + return screen.getByRole('dialog'); +} + +function closeQr() { + fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { + name: labels().qrModalClose, + }), + ); +} + +function displayedUrl() { + return within(screen.getByRole('dialog')).getByText( + /^https:\/\/test\.local\/pay\?/, + ).textContent; +} + +function detailsOf(text: string) { + return screen.getByText(text).closest('details')!; +} + +function mobileBar() { + return container.querySelector('div.sticky.bottom-14'); +} + +const h = (value: string) => + createHash('sha256').update(value).digest('hex').slice(0, 12); + +// この画面の はすべて第三者の出力 (lucide-react のアイコン・qrcode.react の QR)。 +// その中身 (path の d・QR の module 数で変わる viewBox 等) や lucide が icon 名から +// 付ける class (lucide / lucide-*) を digest に含めると、依存の更新 (renovate の +// lockFileMaintenance 等) だけで全 digest が落ちて無関係の PR を止めるので、digest の +// 前に外す。残すのは 要素自身の位置と、こちらが指定する配置・a11y 系の属性 +// (class の自前 utility・width/height・role・aria-*)。QR の中身は決済 URL の完全一致 +// (displayedUrl) で、SVG/PNG 保存は serialize 結果の一致で別に固定している。 +const SVG_KEPT_ATTR = /^(class|width|height|role|aria-.+)$/; +function withoutSvgInternals(el: Element): Element { + const clone = el.cloneNode(true) as Element; + for (const svg of Array.from(clone.querySelectorAll('svg'))) { + svg.replaceChildren(); + for (const { name } of Array.from(svg.attributes)) { + if (!SVG_KEPT_ATTR.test(name)) svg.removeAttribute(name); + } + const cls = svg.getAttribute('class'); + if (cls !== null) { + svg.setAttribute( + 'class', + cls + .split(/\s+/) + .filter((c) => c && c !== 'lucide' && !c.startsWith('lucide-')) + .join(' '), + ); + } + } + return clone; +} + +// 節ごとの DOM digest (outerHTML = 要素・属性・class・子の順序・文言をすべて含む。 +// ただし の中身は上の withoutSvgInternals で外す)。 +// どの節が変わったかが 1 行の diff で読めるよう、節名つきの 1 文字列にする。 +function sections(): string { + // 下部バーの再描画 frame を先に消化し、style 属性を確定させてから記録する。 + flushFrames(); + const [offline, grid, ...outside] = Array.from(container.children); + const [left, right, ...rest] = Array.from(grid.children); + const leftNames = ['amount', 'receiver', 'accounting', 'settings', 'pwa']; + const deep = (el: Element) => h(withoutSvgInternals(el).outerHTML); + const shallow = (el: Element) => h((el.cloneNode(false) as Element).outerHTML); + return [ + `shape=${container.children.length}/${grid.children.length}/${left.children.length}/${outside.length}`, + `offline=${deep(offline)}`, + `grid=${shallow(grid)}`, + `left=${shallow(left)}`, + ...Array.from(left.children).map( + (el, i) => `${leftNames[i] ?? `left${i}`}=${deep(el)}`, + ), + `preview=${deep(right)}`, + ...rest.map( + (el) => + `${el.getAttribute('role') === 'dialog' ? 'modal' : 'bar'}=${deep(el)}`, + ), + `full=${h(withoutSvgInternals(container).innerHTML)}`, + ].join(' '); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }); + vi.setSystemTime(NOW); + frames.clear(); + let frameId = 0; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + mocks.balance = undefined; + mocks.read.mockClear(); + mocks.provider.mockReturnValue('pimlico-7702'); + mocks.forwarder.mockReturnValue(null); + mocks.origin = 'https://test.local'; + mocks.account = { address: undefined, isConnected: false }; + mocks.usageFee = false; + locale = 'ja'; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + Reflect.deleteProperty(navigator, 'clipboard'); +}); + +describe('R11b: generated payment URL fixtures', () => { + it.each([ + { name: 'default JPYC', settings: {}, value: '1000', suffix: '&token=jpyc&amount=1000' }, + { name: 'static JPYC', settings: {}, value: null, suffix: '&token=jpyc' }, + { name: 'JPYC on Kaia', settings: { chain: 'kaia' }, value: '300', suffix: '&token=jpyc&chain=kaia&amount=300' }, + { name: 'JPYC split with merchant gas', settings: { gasMode: 'merchant', splits: [{ address: SPLIT, percent: '30' }] }, value: '1000', suffix: `&token=jpyc&gas=merchant&amount=1000&split=${SPLIT}%3A30` }, + { name: 'USDC Base default', settings: { token: 'usdc', chain: 'base' }, value: '12.5', suffix: '&token=usdc&amount=12.5' }, + { name: 'static USDC Optimism', settings: { token: 'usdc', chain: 'optimism' }, value: null, suffix: '&token=usdc&chain=optimism' }, + { name: 'USDC split and opt-out', settings: { token: 'usdc', chain: 'arbitrum', gasMode: 'merchant', crossChain: false, splits: [{ address: SPLIT, percent: '30' }] }, value: '1.23456789', suffix: `&token=usdc&chain=arbitrum&gas=merchant&amount=1.234567&split=${SPLIT}%3A30&crossChain=false` }, + { name: 'standard ignores saved gas and split', settings: { token: 'usdc', chain: 'base', payMode: 'standard', gasMode: 'merchant', splits: [{ address: SPLIT, percent: '30' }] }, value: '5', suffix: '&token=usdc&amount=5&mode=standard' }, + { name: 'disabled Arc selection falls back to Base', settings: { token: 'usdc', chain: 'arc' }, value: '5', suffix: '&token=usdc&amount=5' }, + { name: 'free overrides saved merchant', settings: { gasMode: 'merchant' }, value: '500', relay: true, suffix: '&token=jpyc&amount=500' }, + { name: 'recover forces merchant', settings: { gasMode: 'customer' }, value: '500', relay: true, recover: true, suffix: '&token=jpyc&gas=merchant&amount=500' }, + { name: 'encoded accounting fields and zero tax', settings: { storeName: '神田 & Coffee', productName: '豆/袋', memo: 'A+B = 1', taxRate: 0, taxCategory: 'tax_free' }, value: '750', suffix: '&token=jpyc&amount=750&store=%E7%A5%9E%E7%94%B0+%26+Coffee&pname=%E8%B1%86%2F%E8%A2%8B&memo=A%2BB+%3D+1&tax=0&taxcat=tax_free' }, + ])('$name: exact generated URL bytes', async ({ settings, value, suffix, relay, recover }) => { + seed(settings as Partial); + if (relay) mocks.provider.mockReturnValue('eip3009-relay'); + if (recover) mocks.forwarder.mockReturnValue(FORWARDER); + renderQr(); + await settle(); + if (value === null) fireEvent.click(screen.getByRole('button', { name: labels().modeStatic })); + else amount(value); + openQr(); + expect(displayedUrl()).toBe(BASE_URL + suffix); + }); + + it('turning the cross-chain checkbox off via the UI bakes crossChain=false into the URL', async () => { + seed({ token: 'usdc', chain: 'arbitrum' }); + renderQr(); + await settle(); + amount('5'); + fireEvent.click(advancedToggle()); + fireEvent.click(screen.getByRole('checkbox')); + openQr(); + expect(displayedUrl()).toBe(BASE_URL + '&token=usdc&chain=arbitrum&amount=5&crossChain=false'); + closeQr(); + fireEvent.click(screen.getByRole('checkbox')); + openQr(); + expect(displayedUrl()).toBe(BASE_URL + '&token=usdc&chain=arbitrum&amount=5'); + }); + + it('receipt number (local state, never persisted) is appended after the saved accounting fields', async () => { + seed({ memo: 'レジ1' }); + renderQr(); + await settle(); + amount('300'); + fireEvent.change(screen.getByPlaceholderText(labels().receiptNoPlaceholder), { + target: { value: 'R-001' }, + }); + openQr(); + expect(displayedUrl()).toBe( + BASE_URL + '&token=jpyc&amount=300&memo=%E3%83%AC%E3%82%B81&rcpt=R-001', + ); + expect(localStorage.getItem(SETTINGS_KEY)).not.toContain('R-001'); + }); + + it('standard mode also issues the exact EIP-681 transfer URI', async () => { + seed({ token: 'usdc', chain: 'base', payMode: 'standard' }); + renderQr(); + await settle(); + amount('5'); + const dialog = openQr(); + expect(within(dialog).getByText(/^ethereum:/).textContent).toBe( + 'ethereum:0x036CbD53842c5426634e7929541eC2318f3dCF7e@84532/transfer?address=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913&uint256=5000000', + ); + }); +}); + +// 抽出前 main (444c30ce・QrGenerator は 3b205825 / 164f49c2 と同一) で記録した節ごとの +// DOM digest (sha256 先頭 12 桁・ の中身は除外)。shape = 子要素数 (container/grid/左列/grid 外)。 +// 意図して DOM を変えたとき (文言・class・構造の変更) の記録し直し方: この describe を +// 実行すると toEqual の diff に実際の `節名=digest` 文字列が出る。diff で変わった節名が +// 変更の意図と一致することを確かめてから、その文字列を下の該当キーへ写す。抽出・分割の +// 差分に合わせて書き換えてはならない (Phase 6 原則 2 — その場合は抽出前の code で記録する)。 +// lucide-react / qrcode.react の更新だけで digest が変わるなら除外 (withoutSvgInternals) の漏れ。 +const DOM_BASELINE: Record = { + 'fresh-ja/empty': + 'shape=2/2/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=58f78ef36af2 receiver=e7e8e741048e accounting=1ae21b00fd1a settings=52f60fef0496 preview=df6fdaa1b2ab full=87e6a26fdc72', + 'fresh-ja/receiver-typed': + 'shape=2/2/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=58f78ef36af2 receiver=53e63a799c4c accounting=1ae21b00fd1a settings=52f60fef0496 preview=ef5f97440853 full=b431de83bbbe', + 'fresh-ja/amount': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=f11c9c908a3d receiver=53e63a799c4c accounting=1ae21b00fd1a settings=52f60fef0496 preview=a2659703e1e8 bar=59ab00522b30 full=a03fc2472495', + 'fresh-ja/advanced': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=f11c9c908a3d receiver=53e63a799c4c accounting=1ae21b00fd1a settings=248c7149dc79 preview=a2659703e1e8 bar=59ab00522b30 full=da0a17195a81', + 'fresh-ja/static': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=05b177225a49 receiver=53e63a799c4c accounting=1ae21b00fd1a settings=248c7149dc79 preview=a2659703e1e8 bar=de07dcf3f38f full=af9234402c11', + 'fresh-ja/modal': + 'shape=2/4/5/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=05b177225a49 receiver=53e63a799c4c accounting=1ae21b00fd1a settings=248c7149dc79 pwa=f0506b8ca563 preview=a2659703e1e8 modal=bddf69eea475 bar=de07dcf3f38f full=17c26552dcff', + 'fresh-en/empty': + 'shape=2/2/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=63f2f3856490 receiver=df2279b703b8 accounting=74ed35d7215a settings=16c64a10b7aa preview=fe779a049a06 full=94d3a55cfcb0', + 'fresh-en/receiver-typed': + 'shape=2/2/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=63f2f3856490 receiver=fedfcd16f726 accounting=74ed35d7215a settings=16c64a10b7aa preview=0866874a49a0 full=1299d7bd940f', + 'fresh-en/amount': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=225a60671bb5 receiver=fedfcd16f726 accounting=74ed35d7215a settings=16c64a10b7aa preview=af1a40c48a66 bar=8fd7054ec7dc full=8bc0db723b39', + 'fresh-en/advanced': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=225a60671bb5 receiver=fedfcd16f726 accounting=74ed35d7215a settings=8d52fa6c9d78 preview=af1a40c48a66 bar=8fd7054ec7dc full=2ae0731b41e4', + 'fresh-en/static': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=b8b03420b536 receiver=fedfcd16f726 accounting=74ed35d7215a settings=8d52fa6c9d78 preview=af1a40c48a66 bar=7af51957c9d0 full=a74f28eaea86', + 'fresh-en/modal': + 'shape=2/4/5/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=b8b03420b536 receiver=fedfcd16f726 accounting=74ed35d7215a settings=8d52fa6c9d78 pwa=3e04971c2b53 preview=af1a40c48a66 modal=1f8b3ee8e853 bar=7af51957c9d0 full=c357d7c934b9', + 'seeded/collapsed': + 'shape=2/2/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=58f78ef36af2 receiver=a40a7c4b7637 accounting=1ae21b00fd1a settings=52f60fef0496 preview=ef5f97440853 full=16d1e09dc74d', + 'seeded/step2-open': + 'shape=2/2/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=58f78ef36af2 receiver=6402faeff77a accounting=1ae21b00fd1a settings=52f60fef0496 preview=ef5f97440853 full=d43b7b3b3bdc', + 'seeded/advanced-split': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=0c61aca9e357 receiver=6402faeff77a accounting=1ae21b00fd1a settings=a0ad50b9662c preview=a2659703e1e8 bar=59ab00522b30 full=609e5ee8741e', + 'seeded/quick-editor': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=8b58a06e7024 receiver=6402faeff77a accounting=1ae21b00fd1a settings=a0ad50b9662c preview=a2659703e1e8 bar=59ab00522b30 full=f1a7c0a82a87', + 'seeded/accounting': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=8b58a06e7024 receiver=6402faeff77a accounting=c1efa3a4b75e settings=a0ad50b9662c preview=a2659703e1e8 bar=59ab00522b30 full=1bc85f8c1ac9', + 'usdc/amount-fiat': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=b8ed42a5143e receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=52f60fef0496 preview=a2659703e1e8 bar=f03f88810f5b full=6b407e8598ad', + 'usdc/advanced-crosschain': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=b8ed42a5143e receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=481ca9776306 preview=a2659703e1e8 bar=f03f88810f5b full=59ffa146b244', + 'usdc/fx-applied': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=8246c076c52b receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=248c7149dc79 preview=a2659703e1e8 bar=6f06b167fcfe full=6ea0cb5a67de', + 'usdc/fx-modal': + 'shape=2/4/5/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=8246c076c52b receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=248c7149dc79 pwa=f0506b8ca563 preview=a2659703e1e8 modal=265a751d0adb bar=6f06b167fcfe full=c049fec9aa50', + 'usdc/fx-expired': + 'shape=2/3/5/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=905cb32ce2af receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=248c7149dc79 pwa=f0506b8ca563 preview=a2659703e1e8 bar=6f06b167fcfe full=b0d30d24b2f9', + 'standard/closed': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=e2cb9fc58f8e receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=e521d1b1f9d3 preview=a2659703e1e8 bar=f316e8a8778c full=ffc2d4731db3', + 'standard/advanced': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=e2cb9fc58f8e receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=587b06de2156 preview=a2659703e1e8 bar=f316e8a8778c full=c43510314f30', + 'standard/modal-eip681': + 'shape=2/4/5/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=e2cb9fc58f8e receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=587b06de2156 pwa=f0506b8ca563 preview=a2659703e1e8 modal=d91b96035e2b bar=f316e8a8778c full=2ba83c8278a5', + 'recover/closed': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=6e41c8856081 receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=58804c1d9ee2 preview=a2659703e1e8 bar=70c6ee9f9cf5 full=639a69d0c511', + 'recover/advanced': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=6e41c8856081 receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=852c863652e2 preview=a2659703e1e8 bar=70c6ee9f9cf5 full=673215778dbf', + 'free/closed': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=2957e6520b93 receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=a181c16167ab preview=a2659703e1e8 bar=70c6ee9f9cf5 full=6948bd29d66e', + 'free/advanced': + 'shape=2/3/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=2957e6520b93 receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=852c863652e2 preview=a2659703e1e8 bar=70c6ee9f9cf5 full=048079129c84', + 'invalid/invalid': + 'shape=2/2/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=58f78ef36af2 receiver=b71d06586735 accounting=1ae21b00fd1a settings=52f60fef0496 preview=df6fdaa1b2ab full=e6d53badc8f4', + 'generating/generating': + 'shape=2/2/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=f11c9c908a3d receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=52f60fef0496 preview=8c5d0f2ab6ef full=059e91c5a89c', + 'usage-fee/mismatch': + 'shape=2/2/4/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=58f78ef36af2 receiver=2419f4bc0986 accounting=1ae21b00fd1a settings=52f60fef0496 preview=ef5f97440853 full=36b6f4e55cc6', + 'watch/watching': + 'shape=2/4/5/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=e74441b2f95e receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=52f60fef0496 pwa=f0506b8ca563 preview=a2659703e1e8 modal=2a6584873b2b bar=5f5f11543bcb full=0243d244d431', + 'watch/received': + 'shape=2/4/5/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=e74441b2f95e receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=52f60fef0496 pwa=f0506b8ca563 preview=a2659703e1e8 modal=cfc4a026e8d1 bar=5f5f11543bcb full=d6ce194ac06e', + 'watch/closed-with-pwa-hint': + 'shape=2/3/5/0 offline=f77ebeefe1a1 grid=8c2858128be8 left=2b7c226170e0 amount=e74441b2f95e receiver=f06e1dbf118c accounting=1ae21b00fd1a settings=52f60fef0496 pwa=f0506b8ca563 preview=a2659703e1e8 bar=5f5f11543bcb full=5e5d9dc6cb7b', +}; + +type Snap = (name: string) => void; + +async function freshFlow(snap: Snap) { + renderQr(); + await settle(); + snap('empty'); + fireEvent.change(receiverInput(), { target: { value: RECEIVER } }); + await settle(); + snap('receiver-typed'); + amount('1000'); + await settle(); + snap('amount'); + fireEvent.click(advancedToggle()); + await settle(); + snap('advanced'); + fireEvent.click(screen.getByRole('button', { name: labels().modeStatic })); + await settle(); + snap('static'); + openQr(); + await settle(); + snap('modal'); +} + +const SCENARIOS: Record Promise }> = { + 'fresh-ja': { locale: 'ja', run: freshFlow }, + 'fresh-en': { locale: 'en', run: freshFlow }, + seeded: { + locale: 'ja', + run: async (snap) => { + seed({ storeName: ' 神田珈琲 ', posterNote: 'ありがとう', splits: [{ address: SPLIT, percent: '30' }] }); + renderQr(); + await settle(); + snap('collapsed'); + fireEvent.click(step2Toggle()); + await settle(); + snap('step2-open'); + amount('1000'); + fireEvent.click(advancedToggle()); + await settle(); + snap('advanced-split'); + detailsOf(labels().quickAmountsLabel).open = true; + await settle(); + snap('quick-editor'); + detailsOf(labels().accountingFieldsTitle).open = true; + fireEvent.change(screen.getByPlaceholderText(labels().productNamePlaceholder), { target: { value: '豆' } }); + fireEvent.change(screen.getByPlaceholderText(labels().memoPlaceholder), { target: { value: 'メモ' } }); + fireEvent.change(screen.getByPlaceholderText(labels().receiptNoPlaceholder), { target: { value: 'R-9' } }); + await settle(); + snap('accounting'); + }, + }, + usdc: { + locale: 'ja', + run: async (snap) => { + seed({ token: 'usdc', chain: 'base' }); + renderQr(); + await settle(); + amount('12.5'); + await settle(); + snap('amount-fiat'); + fireEvent.click(advancedToggle()); + await settle(); + snap('advanced-crosschain'); + fireEvent.click(screen.getByRole('button', { name: labels().convertButton.replace('{symbol}', 'JPYC') })); + await settle(); + snap('fx-applied'); + openQr(); + await settle(); + snap('fx-modal'); + closeQr(); + await act(async () => { + await vi.advanceTimersByTimeAsync(181_000); + }); + snap('fx-expired'); + }, + }, + standard: { + locale: 'ja', + run: async (snap) => { + seed({ token: 'usdc', chain: 'base', payMode: 'standard' }); + renderQr(); + await settle(); + amount('5'); + await settle(); + snap('closed'); + fireEvent.click(advancedToggle()); + await settle(); + snap('advanced'); + const dialog = openQr(); + fireEvent.click(within(dialog).getByText(labels().eip681Title)); + await settle(); + snap('modal-eip681'); + }, + }, + recover: { + locale: 'ja', + run: async (snap) => { + mocks.provider.mockReturnValue('eip3009-relay'); + mocks.forwarder.mockReturnValue(FORWARDER); + seed(); + renderQr(); + await settle(); + amount('500'); + await settle(); + snap('closed'); + fireEvent.click(advancedToggle()); + await settle(); + snap('advanced'); + }, + }, + free: { + locale: 'ja', + run: async (snap) => { + mocks.provider.mockReturnValue('eip3009-relay'); + seed(); + renderQr(); + await settle(); + amount('500'); + await settle(); + snap('closed'); + fireEvent.click(advancedToggle()); + await settle(); + snap('advanced'); + }, + }, + invalid: { + locale: 'ja', + run: async (snap) => { + renderQr(); + await settle(); + fireEvent.change(receiverInput(), { target: { value: '0x1234' } }); + await settle(); + snap('invalid'); + }, + }, + generating: { + locale: 'ja', + run: async (snap) => { + mocks.origin = ''; + seed(); + renderQr(); + await settle(); + amount('1000'); + await settle(); + snap('generating'); + }, + }, + 'usage-fee': { + locale: 'ja', + run: async (snap) => { + mocks.usageFee = true; + mocks.account = { address: CONNECTED, isConnected: true }; + seed(); + renderQr(); + await settle(); + fireEvent.click(step2Toggle()); + await settle(); + snap('mismatch'); + }, + }, + watch: { + locale: 'ja', + run: async (snap) => { + seed(); + const view = renderQr(); + await settle(); + amount('750'); + openQr(); + await settle(); + snap('watching'); + mocks.balance = 10n ** 21n; + view.rerender(); + mocks.balance += 750n * 10n ** 18n; + view.rerender(); + await settle(); + snap('received'); + closeQr(); + await settle(); + snap('closed-with-pwa-hint'); + }, + }, +}; + +describe('R11b: section DOM is unchanged', () => { + it.each(Object.keys(SCENARIOS))('%s', async (key) => { + const scenario = SCENARIOS[key]; + locale = scenario.locale; + const actual: Record = {}; + await scenario.run((name) => { + actual[`${key}/${name}`] = sections(); + }); + const expected = Object.fromEntries( + Object.keys(actual).map((name) => [name, DOM_BASELINE[name]]), + ); + expect(actual).toEqual(expected); + }); +}); + +describe('R11b: no remount when sections become child components', () => { + it('keeps focus, input nodes and uncontrolled
state across parent re-renders', async () => { + seed({ token: 'usdc', chain: 'base', splits: [{ address: SPLIT, percent: '30' }] }); + renderQr(); + await settle(); + const amountEl = amountInput(); + expect(document.activeElement).toBe(amountEl); + fireEvent.click(advancedToggle()); + fireEvent.click(step2Toggle()); + await settle(); + const quickDetails = detailsOf(labels().quickAmountsLabel); + const accountingDetails = detailsOf(labels().accountingFieldsTitle); + quickDetails.open = true; + accountingDetails.open = true; + const requery = () => ({ + amountEl: amountInput(), + quickDetails: detailsOf(labels().quickAmountsLabel), + accountingDetails: detailsOf(labels().accountingFieldsTitle), + quickInput: within(detailsOf(labels().quickAmountsLabel)).getAllByRole('textbox')[0], + storeName: screen.getByPlaceholderText(labels().storeNamePlaceholder), + posterNote: screen.getByPlaceholderText(labels().posterNotePlaceholder), + receiver: receiverInput(), + receipt: screen.getByPlaceholderText(labels().receiptNoPlaceholder), + crossChain: screen.getByRole('checkbox'), + splitAddress: screen.getByDisplayValue(SPLIT), + advanced: advancedToggle(), + step2: step2Toggle(), + modeAmount: screen.getByRole('button', { name: labels().modeAmount }), + modeStatic: screen.getByRole('button', { name: labels().modeStatic }), + }); + const nodes = requery(); + const rerenders: [string, () => void][] = [ + ['amount', () => amount('12')], + ['quick amount apply', () => fireEvent.click(screen.getByRole('button', { name: '20 USDC' }))], + ['quick amount edit', () => fireEvent.change(nodes.quickInput, { target: { value: '7' } })], + ['store name', () => fireEvent.change(nodes.storeName, { target: { value: 'A' } })], + ['poster note', () => fireEvent.change(nodes.posterNote, { target: { value: 'B' } })], + ['receipt', () => fireEvent.change(nodes.receipt, { target: { value: 'R' } })], + ['cross-chain', () => fireEvent.click(nodes.crossChain)], + ['split percent', () => fireEvent.change(screen.getByDisplayValue('30'), { target: { value: '40' } })], + ['chain', () => fireEvent.click(screen.getByRole('button', { name: /Arbitrum/ }))], + ['pay mode', () => fireEvent.click(screen.getByRole('button', { name: new RegExp(`^${labels().payModeGaslessTitle}`) }))], + ]; + for (const [what, action] of rerenders) { + action(); + await settle(); + expect({ what, same: requery() }).toEqual({ what, same: nodes }); + for (const [key, node] of Object.entries(nodes)) { + expect({ what, key, connected: node.isConnected }).toEqual({ what, key, connected: true }); + } + expect({ what, quick: quickDetails.open, accounting: accountingDetails.open }).toEqual({ + what, + quick: true, + accounting: true, + }); + expect({ what, focused: document.activeElement === amountEl }).toEqual({ what, focused: true }); + } + // モーダルは dialog に focus を移す (既存挙動)。閉じた後も節の node と開閉状態は同じ。 + openQr(); + await settle(); + expect(document.activeElement).toBe(screen.getByRole('dialog')); + closeQr(); + await settle(); + expect(requery()).toEqual(nodes); + expect([quickDetails.open, accountingDetails.open]).toEqual([true, true]); + // 据え置き → 金額指定は従来どおり金額欄を作り直し (autoFocus で再 focus)、 + // 金額指定時だけ出るクイック金額エディタも作り直す (開閉は閉じに戻る)。会計は保持。 + // モード切替ボタン自体は ① の節ごと作り直されない (同じ node のまま)。 + fireEvent.click(screen.getByRole('button', { name: labels().modeStatic })); + await settle(); + expect(screen.queryByText(labels().quickAmountsLabel)).toBeNull(); + expect(screen.getByRole('button', { name: labels().modeStatic })).toBe(nodes.modeStatic); + expect(screen.getByRole('button', { name: labels().modeAmount })).toBe(nodes.modeAmount); + fireEvent.click(screen.getByRole('button', { name: labels().modeAmount })); + await settle(); + expect(screen.getByRole('button', { name: labels().modeAmount })).toBe(nodes.modeAmount); + expect(screen.getByRole('button', { name: labels().modeStatic })).toBe(nodes.modeStatic); + const remounted = amountInput(); + expect(remounted).not.toBe(amountEl); + expect(document.activeElement).toBe(remounted); + expect(detailsOf(labels().quickAmountsLabel)).not.toBe(quickDetails); + expect(detailsOf(labels().quickAmountsLabel).open).toBe(false); + expect(detailsOf(labels().accountingFieldsTitle)).toBe(accountingDetails); + expect(accountingDetails.open).toBe(true); + expect(advancedToggle()).toBe(nodes.advanced); + expect(screen.getByRole('checkbox')).toBe(nodes.crossChain); + }); + + it('keeps the Step 2 and advanced-settings open state owned by the generator across modal and mode changes', async () => { + seed(); + renderQr(); + await settle(); + expect(step2Toggle()).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(step2Toggle()); + fireEvent.click(advancedToggle()); + amount('1000'); + openQr(); + closeQr(); + fireEvent.click(screen.getByRole('button', { name: labels().modeStatic })); + fireEvent.click(screen.getByRole('button', { name: labels().modeAmount })); + await settle(); + expect(step2Toggle()).toHaveAttribute('aria-expanded', 'true'); + expect(advancedToggle()).toHaveAttribute('aria-expanded', 'true'); + }); +}); + +describe('R11b: mobile bar repaint effect', () => { + it('flashes translateZ(0) only when amount, mode or the display symbol changes', async () => { + renderQr(); + await settle(); + amount('1000'); + await settle(); + // 受取先が未設定の間はバーが無い (effect は ref=null で素通り)。 + expect(mobileBar()).toBeNull(); + fireEvent.change(receiverInput(), { target: { value: RECEIVER } }); + await settle(); + // バーの出現自体は effect の依存 (金額/モード/記号) を変えないので style に触れない。 + const bar = mobileBar()!; + expect(bar.getAttribute('style')).toBeNull(); + expect(frames.size).toBe(0); + fireEvent.change(screen.getByPlaceholderText(labels().storeNamePlaceholder), { target: { value: '店' } }); + await settle(); + expect(bar.getAttribute('style')).toBeNull(); + amount('1200'); + expect(bar.style.transform).toBe('translateZ(0)'); + amount('1300'); + // 前の frame は cleanup で cancel され、保留は常に 1 つ。 + expect(frames.size).toBe(1); + flushFrames(); + expect(bar.getAttribute('style')).toBe(''); + fireEvent.click(screen.getByRole('button', { name: labels().modeStatic })); + expect(bar.style.transform).toBe('translateZ(0)'); + flushFrames(); + fireEvent.click(screen.getByRole('button', { name: labels().modeAmount })); + flushFrames(); + fireEvent.click(screen.getByRole('button', { name: 'USDC' })); + await settle(); + expect(mobileBar()).toBe(bar); + expect(bar.style.transform).toBe('translateZ(0)'); + flushFrames(); + expect(bar.style.transform).toBe(''); + }); +}); + +describe('R11b: modal reopen, FX expiry, downloads and print', () => { + it('reopens the modal with copy state, refreshes last-QR storage only when open, and renews the balance scope', async () => { + seed({ storeName: ' 神田珈琲 ' }); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + const view = renderQr(); + await settle(); + vi.setSystemTime(NOW); + amount('750'); + expect(localStorage.getItem(LAST_QR_KEY)).toBeNull(); + const firstDialog = openQr(); + expect(localStorage.getItem(LAST_QR_KEY)).toBe(JSON.stringify({ + payUrl: BASE_URL + '&token=jpyc&amount=750&store=%E7%A5%9E%E7%94%B0%E7%8F%88%E7%90%B2', + amountLabel: '750 JPYC', tokenChainLabel: 'JPYC · Polygon Amoy', storeName: '神田珈琲', ts: NOW, + })); + const firstScope = mocks.read.mock.lastCall![0].scopeKey; + mocks.balance = 10n ** 21n; + view.rerender(); + mocks.balance += 750n * 10n ** 18n; + view.rerender(); + expect(within(firstDialog).getByRole('status').textContent).toContain('残高の増加'); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: labels().qrCopy })); }); + expect(writeText).toHaveBeenCalledWith(displayedUrl()); + closeQr(); + expect(firstDialog).not.toBeInTheDocument(); + expect(mocks.read.mock.lastCall![0].query.enabled).toBe(false); + const saved = localStorage.getItem(LAST_QR_KEY); + vi.setSystemTime(NOW + 1000); + amount('900'); + expect(localStorage.getItem(LAST_QR_KEY)).toBe(saved); + mocks.balance = undefined; + const reopened = openQr(); + expect(reopened).not.toBe(firstDialog); + expect(screen.getByRole('button', { name: labels().qrCopied })).toBeInTheDocument(); + expect(displayedUrl()).toBe(BASE_URL + '&token=jpyc&amount=900&store=%E7%A5%9E%E7%94%B0%E7%8F%88%E7%90%B2'); + expect(JSON.parse(localStorage.getItem(LAST_QR_KEY)!)).toMatchObject({ amountLabel: '900 JPYC', ts: NOW + 1000 }); + expect(mocks.read.mock.lastCall![0].scopeKey).not.toBe(firstScope); + expect(mocks.read.mock.lastCall![0].query.enabled).toBe(true); + expect(within(reopened).getByRole('status')).toHaveTextContent(labels().paymentWatching); + // 開いている間に URL が変わると (金額欄は背面に残る) 前回 QR も追従して保存し直す。 + vi.setSystemTime(NOW + 2000); + amount('950'); + expect(JSON.parse(localStorage.getItem(LAST_QR_KEY)!)).toMatchObject({ amountLabel: '950 JPYC', ts: NOW + 2000 }); + }); + + it('keeps the original FX expiry through accordion and Step 2 child remounts, and recalculates only on request', async () => { + seed(); + renderQr(); + await settle(); + amount('1000'); + fireEvent.click(screen.getByRole('button', { name: labels().convertButton.replace('{symbol}', 'USDC') })); + const fxUrl = BASE_URL + '&token=usdc&chain=polygon&amount=6.666667&exp=1790208180&refAmt=1000&fxRate=150'; + openQr(); + expect(displayedUrl()).toBe(fxUrl); + closeQr(); + fireEvent.click(advancedToggle()); + fireEvent.click(step2Toggle()); + await act(async () => { await vi.advanceTimersByTimeAsync(179_000); }); + expect(screen.getByText(/残り 0:01/)).toBeInTheDocument(); + fireEvent.click(advancedToggle()); + fireEvent.click(step2Toggle()); + openQr(); + expect(displayedUrl()).toBe(fxUrl); + closeQr(); + await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); + expect(screen.getByText(/残り 0:00/)).toBeInTheDocument(); + expect(screen.queryByText(/再計算してください/)).toBeNull(); // Expiry is strictly after exp. + await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); + expect(screen.getByText(/再計算してください/)).toBeInTheDocument(); + openQr(); + expect(displayedUrl()).toBe(fxUrl); // Existing advisory expiry does not suppress the generator's QR. + closeQr(); + fireEvent.click(screen.getByRole('button', { name: labels().convertRecalc })); + expect(screen.getByText(/残り 3:00/)).toBeInTheDocument(); + openQr(); + expect(displayedUrl()).toBe(BASE_URL + '&token=usdc&chain=polygon&amount=6.666667&exp=1790208361&refAmt=1000&fxRate=150'); + expect(JSON.parse(localStorage.getItem(SETTINGS_KEY)!).token).toBe('jpyc'); + }); + + it('downloads the current main QR ref after reopen (not an icon or EIP-681 SVG) and prints the current poster', async () => { + seed({ payMode: 'standard', storeName: '神田珈琲', productName: '豆/袋' }); + renderQr(); + await settle(); + amount('750'); + const firstDialog = openQr(); + const firstSvg = firstDialog.querySelector('svg[width="340"]')!; + closeQr(); + amount('900'); + const dialog = openQr(); + fireEvent.click(screen.getByText(/互換 QR \(EIP-681\)/)); + const svg = dialog.querySelector('svg[width="340"]')!; + expect(firstSvg).not.toBeInTheDocument(); + expect(svg).not.toBe(firstSvg); + const markup = new XMLSerializer().serializeToString(svg); + expect(markup).not.toBe(new XMLSerializer().serializeToString(firstSvg)); + const blobs: Blob[] = []; + const createObjectURL = vi.fn((blob: Blob) => { blobs.push(blob); return 'blob:r11b'; }); + const revokeObjectURL = vi.fn(); + vi.stubGlobal('URL', class extends URL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + }); + const downloads: { href: string; filename: string }[] = []; + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { + downloads.push({ href: this.href, filename: this.download }); + }); + fireEvent.click(screen.getByRole('button', { name: labels().downloadSvg })); + expect(blobs).toHaveLength(1); + expect(blobs[0].type).toBe('image/svg+xml;charset=utf-8'); + const blobText = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.readAsText(blobs[0]); + }); + expect(blobText).toBe(markup); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:r11b'); + const fillRect = vi.fn(); + const drawImage = vi.fn(); + const ctx = { fillStyle: '', fillRect, drawImage }; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(ctx as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue('data:image/png;base64,pinned'); + const imageSources: string[] = []; + class TestImage { + width = 340; + onload: (() => void) | null = null; + set src(value: string) { imageSources.push(value); this.onload?.(); } + } + vi.stubGlobal('Image', TestImage); + fireEvent.click(screen.getByRole('button', { name: labels().downloadPng })); + expect(imageSources).toEqual([`data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`]); + expect(ctx.fillStyle).toBe('#ffffff'); + expect(fillRect).toHaveBeenCalledWith(0, 0, 340, 340); + expect(drawImage).toHaveBeenCalledWith(expect.any(TestImage), 0, 0); + expect(downloads).toEqual([ + { href: 'blob:r11b', filename: '神田珈琲-豆-袋-jpyc-polygon-900.svg' }, + { href: 'data:image/png;base64,pinned', filename: '神田珈琲-豆-袋-jpyc-polygon-900.png' }, + ]); + const print = vi.spyOn(window, 'print').mockImplementation(() => { + expect(dialog).toHaveClass('print:static', 'print:bg-white', 'print:p-0'); + const poster = svg.closest('section')!; + expect(poster).toHaveClass('print:fixed', 'print:inset-0', 'print:min-h-screen', 'print:p-10'); + expect(poster).toHaveTextContent('神田珈琲'); + expect(poster).toHaveTextContent('900 JPYC'); + expect(poster.querySelectorAll('ol > li')).toHaveLength(3); + expect(poster.querySelector('img[alt="OpenPay"]')).toBeInTheDocument(); + // 入力欄・下部バー・③ の列は印刷に出さない (print:hidden の祖先に居る)。 + for (const el of [amountInput(), mobileBar()!, screen.getByText(labels().qrDescription)]) { + expect(el.closest('.print\\:hidden')).not.toBeNull(); + } + }); + fireEvent.click(screen.getByRole('button', { name: labels().printPoster })); + expect(print).toHaveBeenCalledOnce(); + }); + + it('names downloads with the file-safe fallback and the open-amount segment', async () => { + seed({ token: 'usdc', chain: 'optimism', storeName: ' / ', productName: ' ' }); + renderQr(); + await settle(); + fireEvent.click(screen.getByRole('button', { name: labels().modeStatic })); + openQr(); + const downloads: string[] = []; + vi.stubGlobal('URL', class extends URL { + static createObjectURL = () => 'blob:r11b'; + static revokeObjectURL = () => undefined; + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { + downloads.push(this.download); + }); + fireEvent.click(screen.getByRole('button', { name: labels().downloadSvg })); + expect(downloads).toEqual(['openpay-usdc-optimism-open.svg']); + }); +});