Skip to content

Commit aef35bc

Browse files
authored
Merge pull request #412 from Chidubemkingsley/Add-idle-refresh-prompt-for-creator-list-after-inactivity-threshold
Add idle refresh prompt for creator list after inactivity threshold
2 parents e4d61f8 + 04a6a5a commit aef35bc

4 files changed

Lines changed: 238 additions & 119 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { RefreshCw } from 'lucide-react';
2+
import { Button } from '@/components/ui/button';
3+
4+
export interface IdleRefreshPromptProps {
5+
/** Whether the prompt is currently visible. */
6+
visible: boolean;
7+
/** Called when the user clicks "Refresh". */
8+
onRefresh: () => void;
9+
/** Called when the user dismisses without refreshing. */
10+
onDismiss: () => void;
11+
}
12+
13+
/**
14+
* A subtle bottom-of-viewport banner that appears after an inactivity
15+
* threshold and offers to refresh the creator list.
16+
*
17+
* Rendered into the normal DOM flow but positioned fixed so it floats above
18+
* page content without shifting layout. Hidden via `aria-hidden` and
19+
* `pointer-events-none` when not visible so it never interferes with
20+
* keyboard navigation.
21+
*/
22+
const IdleRefreshPrompt: React.FC<IdleRefreshPromptProps> = ({
23+
visible,
24+
onRefresh,
25+
onDismiss,
26+
}) => {
27+
return (
28+
<div
29+
role="status"
30+
aria-live="polite"
31+
aria-atomic="true"
32+
aria-hidden={!visible}
33+
data-testid="idle-refresh-prompt"
34+
className={[
35+
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2',
36+
'flex items-center gap-3 rounded-2xl border border-white/10',
37+
'bg-zinc-900/90 px-4 py-3 shadow-xl backdrop-blur-md',
38+
'text-sm text-white/80 transition-all duration-300',
39+
visible
40+
? 'translate-y-0 opacity-100 pointer-events-auto'
41+
: 'translate-y-4 opacity-0 pointer-events-none',
42+
].join(' ')}
43+
>
44+
<span className="shrink-0 text-white/50">
45+
<RefreshCw className="size-4" aria-hidden="true" />
46+
</span>
47+
<span>The creator list may be out of date.</span>
48+
<Button
49+
type="button"
50+
size="sm"
51+
variant="ghost"
52+
onClick={onRefresh}
53+
className="h-7 rounded-lg px-3 text-amber-300 hover:bg-amber-500/10 hover:text-amber-200"
54+
data-testid="idle-refresh-prompt-confirm"
55+
>
56+
Refresh
57+
</Button>
58+
<button
59+
type="button"
60+
onClick={onDismiss}
61+
aria-label="Dismiss refresh prompt"
62+
className="ml-1 text-white/30 transition-colors hover:text-white/60"
63+
data-testid="idle-refresh-prompt-dismiss"
64+
>
65+
66+
</button>
67+
</div>
68+
);
69+
};
70+
71+
export default IdleRefreshPrompt;

src/components/common/TradeDialog.tsx

Lines changed: 38 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,7 @@ import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
1515
import PercentageBadge from '@/components/common/PercentageBadge';
1616
import NetworkFeeHint from '@/components/common/NetworkFeeHint';
1717
import { TRADE_FEE_ESTIMATE } from '@/constants/fees';
18-
import { clampBuyQuantity } from '@/utils/buyQuantity';
19-
import {
20-
fetchTradeNetworkFeeEstimate,
21-
formatTransactionFeeDisplay,
22-
type NetworkFeeDataProvider,
23-
} from '@/utils/transactionFee.utils';
24-
import { normalizeCreatorDisplayName } from '@/utils/creatorDisplayName.utils';
18+
import { formatTransactionFeeDisplay } from '@/utils/transactionFee.utils';
2519

2620
export type TradeSide = 'buy' | 'sell';
2721

@@ -35,13 +29,8 @@ export interface TradeDialogProps {
3529
onOpenChange: (open: boolean) => void;
3630
onConfirm: (amount: number) => Promise<void> | void;
3731
isSubmitting?: boolean;
38-
networkFeeEstimateProvider?: NetworkFeeDataProvider;
3932
}
4033

41-
type NetworkFeeEstimateState =
42-
| { status: 'idle' | 'loading' | 'error'; fee: null }
43-
| { status: 'success'; fee: number };
44-
4534
const TradeDialog: React.FC<TradeDialogProps> = ({
4635
open,
4736
side,
@@ -51,111 +40,43 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
5140
onOpenChange,
5241
onConfirm,
5342
isSubmitting = false,
54-
networkFeeEstimateProvider,
5543
}) => {
5644
const [amountText, setAmountText] = useState('1');
57-
const [networkFeeEstimate, setNetworkFeeEstimate] =
58-
useState<NetworkFeeEstimateState>({ status: 'idle', fee: null });
59-
const [adjustmentNote, setAdjustmentNote] = useState<string | null>(null);
45+
const [touched, setTouched] = useState(false);
6046
const amountInputRef = useRef<HTMLInputElement | null>(null);
6147

6248
useEffect(() => {
6349
if (open) {
6450
setAmountText('1');
65-
setAdjustmentNote(null);
51+
setTouched(false);
6652
}
6753
}, [open]);
6854

69-
const handleBlur = () => {
70-
if (side !== 'buy') return;
71-
72-
const trimmed = amountText.trim();
73-
const res = clampBuyQuantity(trimmed);
74-
75-
if (res.adjusted) {
76-
setAmountText(res.value.toString());
77-
if (res.reason === 'below_min') {
78-
setAdjustmentNote(`Quantity adjusted to the minimum of ${res.value}.`);
79-
} else if (res.reason === 'above_max') {
80-
setAdjustmentNote(`Quantity adjusted to the maximum of ${res.value}.`);
81-
} else {
82-
setAdjustmentNote(`Quantity rounded to ${res.value}.`);
83-
}
84-
} else {
85-
setAdjustmentNote(null);
86-
}
87-
};
88-
8955
const parsedAmount = useMemo(() => {
9056
const normalized = amountText.trim();
9157
if (!normalized) return NaN;
9258
return Number(normalized);
9359
}, [amountText]);
9460

95-
const amountValid =
96-
Number.isFinite(parsedAmount) &&
97-
parsedAmount > 0 &&
98-
(side !== 'sell' || parsedAmount <= availableHoldings);
61+
const validationError = useMemo((): string | null => {
62+
const normalized = amountText.trim();
63+
if (!normalized) return 'Please enter an amount.';
64+
if (!Number.isFinite(parsedAmount)) return 'Amount must be a valid number.';
65+
if (parsedAmount <= 0) return 'Amount must be greater than zero.';
66+
if (side === 'sell' && parsedAmount > availableHoldings)
67+
return `You can't sell more than your holdings (${formatNumber(availableHoldings)} keys).`;
68+
return null;
69+
}, [amountText, parsedAmount, side, availableHoldings]);
70+
71+
const amountValid = validationError === null;
72+
const showError = touched && validationError !== null;
9973

100-
const displayCreatorName =
101-
normalizeCreatorDisplayName(creatorName) || 'Unnamed creator';
10274
const title = side === 'buy' ? 'Buy keys' : 'Sell keys';
10375
const confirmLabel = side === 'buy' ? 'Confirm buy' : 'Confirm sell';
10476
const estimatedNetworkFee = formatTransactionFeeDisplay(
105-
networkFeeEstimate.status === 'success'
106-
? networkFeeEstimate.fee
107-
: TRADE_FEE_ESTIMATE.DEFAULT_NETWORK_FEE,
77+
TRADE_FEE_ESTIMATE.DEFAULT_NETWORK_FEE,
10878
{ unit: TRADE_FEE_ESTIMATE.UNIT }
10979
);
110-
const networkFeeCopy =
111-
networkFeeEstimate.status === 'loading'
112-
? 'Estimating...'
113-
: networkFeeEstimate.status === 'error'
114-
? 'Cannot estimate network fee'
115-
: estimatedNetworkFee;
116-
117-
useEffect(() => {
118-
if (!open) {
119-
setNetworkFeeEstimate({ status: 'idle', fee: null });
120-
return;
121-
}
122-
123-
if (!amountValid || !networkFeeEstimateProvider) {
124-
setNetworkFeeEstimate({ status: 'error', fee: null });
125-
return;
126-
}
127-
128-
let cancelled = false;
129-
setNetworkFeeEstimate({ status: 'loading', fee: null });
130-
131-
fetchTradeNetworkFeeEstimate(networkFeeEstimateProvider, {
132-
side,
133-
amount: parsedAmount,
134-
})
135-
.then(fee => {
136-
if (cancelled) return;
137-
setNetworkFeeEstimate(
138-
fee == null
139-
? { status: 'error', fee: null }
140-
: { status: 'success', fee }
141-
);
142-
})
143-
.catch(() => {
144-
if (!cancelled) {
145-
setNetworkFeeEstimate({ status: 'error', fee: null });
146-
}
147-
});
148-
149-
return () => {
150-
cancelled = true;
151-
};
152-
}, [
153-
amountValid,
154-
networkFeeEstimateProvider,
155-
open,
156-
parsedAmount,
157-
side,
158-
]);
15980

16081
return (
16182
<Dialog
@@ -181,8 +102,8 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
181102
<DialogTitle>{title}</DialogTitle>
182103
<DialogDescription>
183104
{side === 'buy'
184-
? `Purchase creator keys for ${displayCreatorName}.`
185-
: `Sell creator keys for ${displayCreatorName}.`}
105+
? `Purchase creator keys for ${creatorName}.`
106+
: `Sell creator keys for ${creatorName}.`}
186107
</DialogDescription>
187108
</DialogHeader>
188109

@@ -203,25 +124,30 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
203124
value={amountText}
204125
onChange={event => {
205126
setAmountText(event.target.value);
206-
setAdjustmentNote(null);
127+
setTouched(true);
207128
}}
208-
onBlur={handleBlur}
129+
onBlur={() => setTouched(true)}
209130
disabled={isSubmitting}
210131
className={cn(
211132
'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors',
212133
'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15',
213-
!amountValid && amountText.trim()
214-
? 'border-red-500/40'
215-
: ''
134+
showError ? 'border-red-500/60' : ''
216135
)}
217136
aria-label="Trade amount"
137+
aria-describedby={showError ? 'trade-amount-error' : undefined}
138+
aria-invalid={showError || undefined}
218139
data-focus-order="1"
219140
data-testid="trade-dialog-amount"
220141
/>
221-
{side === 'buy' && adjustmentNote && (
222-
<div className="text-xs text-amber-400 font-medium animate-in fade-in duration-200" data-testid="buy-qty-adjustment-note">
223-
{adjustmentNote}
224-
</div>
142+
{showError && (
143+
<p
144+
id="trade-amount-error"
145+
role="alert"
146+
className="text-xs text-red-300"
147+
data-testid="trade-dialog-amount-error"
148+
>
149+
{validationError}
150+
</p>
225151
)}
226152
<div className="flex flex-wrap items-center gap-2 text-xs text-white/45">
227153
<span
@@ -244,16 +170,12 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
244170
/>
245171
)}
246172
</div>
247-
<NetworkFeeHint
248-
variant="text"
249-
label="Approx. network fee"
250-
fee={networkFeeCopy}
251-
className="text-white/45"
252-
/>
253-
{side === 'sell' && parsedAmount > availableHoldings && (
254-
<div className="text-xs text-red-300">
255-
You can’t sell more than your current holdings.
256-
</div>
173+
{side === 'buy' && (
174+
<NetworkFeeHint
175+
variant="text"
176+
fee={estimatedNetworkFee}
177+
className="text-white/45"
178+
/>
257179
)}
258180
</div>
259181

0 commit comments

Comments
 (0)