Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion components/QrGenerator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export function QrGenerator() {
const [step2Initialized, setStep2Initialized] = useState(false);
// QR は即時表示せず「QRコードを表示する」ボタン → 全画面モーダルで提示。
const [qrModalOpen, setQrModalOpen] = useState(false);
// onClose は modal の focus effect の依存。残高更新や入力で再 focus させない。
const closeQrModal = useCallback(() => setQrModalOpen(false), []);
// 初回に QR モーダルを開いた「ピークモーメント」を latch。以降 A2HS hint を出す
// (毎日この QR を使う店主に、ホーム画面への追加を提案する)。閉じても latch は保持。
const [hasOpenedQr, setHasOpenedQr] = useState(false);
Expand Down Expand Up @@ -231,6 +233,7 @@ export function QrGenerator() {
// 旧 QR との互換性を最大化するため token=usdc 時のみ出力する。
crossChain: settings.token === 'usdc' ? crossChainAllowed(settings.chain, settings.crossChain) : undefined,
// convert 適用時のみ、期限と顧客への文脈表示 (元価格 + レート) を URL に乗せる。
// 期限は画面上の目安。期限切れでも QR / exp は保持し、明示的な再計算で更新する。
expiresAt: convert?.expiresAt,
priceRefAmount: convert?.anchorAmount,
fxRate: convert?.fxRate,
Expand Down Expand Up @@ -594,7 +597,7 @@ export function QrGenerator() {
{payUrl && (
<QrPreviewModal
open={qrModalOpen}
onClose={() => setQrModalOpen(false)}
onClose={closeQrModal}
labels={{
title: t('qrModalTitle'),
close: t('qrModalClose'),
Expand Down
127 changes: 68 additions & 59 deletions components/qr/QrAmountSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import {
useMemo,
useRef,
type ComponentProps,
type Dispatch,
type SetStateAction,
} from 'react';
import { flushSync } from 'react-dom';
import { useTranslations } from 'next-intl';
import { Coins } from 'lucide-react';
import { RecoverFeeNotice } from '../RecoverFeeNotice';
Expand Down Expand Up @@ -82,6 +84,7 @@ export function QrAmountSection({
recoverGasMode: GasMode;
}) {
const t = useTranslations('QrGenerator');
const amountInputRef = useRef<HTMLInputElement>(null);

// クイック金額は token (JPYC=円 / USDC=ドル) ごとに独立。エディタ・適用とも
// 現在の token のサブリストだけを操作する。
Expand Down Expand Up @@ -164,7 +167,13 @@ export function QrAmountSection({
key={m}
type="button"
onClick={() => {
setMode(m as Mode);
if (m === 'amount' && mode === 'static') {
// iOS のキーボードを開けるよう、表示を反映してからタップ中に focus する。
flushSync(() => setMode('amount'));
amountInputRef.current?.focus();
} else {
setMode(m);
}
resetConvert();
}}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition ${
Expand All @@ -177,59 +186,60 @@ export function QrAmountSection({
</button>
))}
</div>
{mode === 'amount' ? (
<div className="space-y-3">
{/* 金額ヒーロー: 入力欄を「表示器」化して数字を主役に。通貨記号は控えめな
接尾、その真下に参考円 (USDC のみ・JPYC は ¥ ペッグで冗長ゆえ非表示)。
枠線は container 側に寄せ、focus で brand + 浮き影。 */}
<div className="rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-white to-brand/[0.03] px-5 py-4 transition focus-within:border-brand focus-within:shadow-card">
<div className="flex items-baseline gap-2">
<input
type="text"
inputMode="decimal"
value={amount}
onChange={(e) => {
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
/>
<span className="shrink-0 text-xl font-semibold text-slate-500">
{deployment.displaySymbol}
</span>
</div>
{fiatHint && (
<div className="mt-1 text-right text-sm font-medium text-slate-500">
{fiatHint}
</div>
)}
{/* 非表示でも node を保持し、再表示で入力欄を作り直さない。 */}
<div className="space-y-3" hidden={mode !== 'amount'}>
{/* 金額ヒーロー: 入力欄を「表示器」化して数字を主役に。通貨記号は控えめな
接尾、その真下に参考円 (USDC のみ・JPYC は ¥ ペッグで冗長ゆえ非表示)。
枠線は container 側に寄せ、focus で brand + 浮き影。 */}
<div className="rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-white to-brand/[0.03] px-5 py-4 transition focus-within:border-brand focus-within:shadow-card">
<div className="flex items-baseline gap-2">
<input
ref={amountInputRef}
type="text"
inputMode="decimal"
value={amount}
onChange={(e) => {
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
/>
<span className="shrink-0 text-xl font-semibold text-slate-500">
{deployment.displaySymbol}
</span>
</div>
{activeQuickAmounts.length > 0 && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{activeQuickAmounts.map((q) => (
<button
key={q}
type="button"
onClick={() => {
setAmount(q);
resetConvert();
}}
className="rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm font-semibold text-slate-700 transition hover:-translate-y-0.5 hover:border-brand hover:text-brand-dark hover:shadow-card active:translate-y-0"
>
{q} {deployment.displaySymbol}
</button>
))}
{fiatHint && (
<div className="mt-1 text-right text-sm font-medium text-slate-500">
{fiatHint}
</div>
)}
</div>
) : (
{activeQuickAmounts.length > 0 && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{activeQuickAmounts.map((q) => (
<button
key={q}
type="button"
onClick={() => {
setAmount(q);
resetConvert();
}}
className="rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm font-semibold text-slate-700 transition hover:-translate-y-0.5 hover:border-brand hover:text-brand-dark hover:shadow-card active:translate-y-0"
>
{q} {deployment.displaySymbol}
</button>
))}
</div>
)}
</div>
{mode === 'static' && (
<p className="rounded-lg bg-slate-50 px-3 py-3 text-sm text-slate-500">
{t('staticHint')}
</p>
Expand All @@ -239,15 +249,14 @@ export function QrAmountSection({

{/* クイック金額の編集 (任意・token ごと独立)。金額入力の近くで設定できる
よう高度な設定から①へ移設。 */}
{mode === 'amount' && (
<QuickAmountEditor
items={tokenQuickAmounts}
max={QUICK_AMOUNT_MAX}
onUpdate={updateQuickAmount}
onAdd={addQuickAmount}
onRemove={removeQuickAmount}
/>
)}
<QuickAmountEditor
hidden={mode !== 'amount'}
items={tokenQuickAmounts}
max={QUICK_AMOUNT_MAX}
onUpdate={updateQuickAmount}
onAdd={addQuickAmount}
onRemove={removeQuickAmount}
/>

<ConvertPanel
canShowConvert={canShowConvert}
Expand Down
12 changes: 7 additions & 5 deletions components/qr/QrPreviewSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ export function QrPreviewSection({
);
}

// モバイル下部固定 会計バー。payUrl が無い間も mount したまま null を返す — 再描画 effect を
// バーの出現で発火させず、従来どおり金額 / モード / 通貨記号の変化時だけにするため。
// モバイル下部固定 会計バー。payUrl が無い間は null。URL の内容だけの変更では
// 再描画を促さず、バーの出現と表示金額 / モード / 通貨記号の変更に追従する。
export function QrMobileBar({
payUrl,
amount,
Expand All @@ -123,9 +123,11 @@ export function QrMobileBar({
}) {
const t = useTranslations('QrGenerator');
const bottomBarRef = useRef<HTMLDivElement>(null);
const visible = Boolean(payUrl);
// WebKit (モバイル Safari・SNS アプリ内ブラウザ) では position:sticky な下部バーの子テキストを
// JS で書き換えても合成レイヤーが再ラスタライズされず古い表示が残ることがある (RegisterMode
// と同根)。表示金額/通貨/モードが変わるたび transform を 1 フレーム入れて再描画を強制する。
// と同根)。バーの出現時と表示金額/通貨/モードが変わるたび transform を 1 フレーム
// 入れて再描画を強制する (金額を先に入力し、後から受取先が確定する場合も含む)。
useEffect(() => {
const el = bottomBarRef.current;
if (!el) return;
Expand All @@ -134,8 +136,8 @@ export function QrMobileBar({
if (el) el.style.transform = '';
});
return () => cancelAnimationFrame(id);
}, [amount, mode, deployment.displaySymbol]);
if (!payUrl) return null;
}, [visible, amount, mode, deployment.displaySymbol]);
if (!visible) return null;
return (
<div
ref={bottomBarRef}
Expand Down
4 changes: 3 additions & 1 deletion components/qr/QuickAmountEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ export function QuickAmountEditor({
onUpdate,
onAdd,
onRemove,
hidden,
}: {
items: string[];
max: number;
onUpdate: (idx: number, value: string) => void;
onAdd: () => void;
onRemove: (idx: number) => void;
hidden?: boolean;
}) {
const t = useTranslations('QrGenerator');
return (
<details className="group rounded-2xl border border-slate-200 bg-white p-4">
<details hidden={hidden} className="group rounded-2xl border border-slate-200 bg-white p-4">
<summary className="flex cursor-pointer list-none items-center justify-between text-sm font-medium text-slate-700">
<span>{t('quickAmountsLabel')}</span>
<ChevronRight
Expand Down
Loading
Loading