Skip to content

Commit ef6a8c2

Browse files
committed
feat: resolve mobile, FX rates, and a11y issues (#338, #337, #336, #335)
- Debounce announce() function to prevent screen reader flooding (500ms cooldown) - Add live FX rates to StepCurrency with recommended badge for NGN - Implement "More" menu in MobileBottomNav for missing routes (Settlement, FX Rates, Developers) - Enhance MobileNavDrawer with scrollable nav and user profile footer section Closes #338 #337 #336 #335
1 parent 274da0f commit ef6a8c2

4 files changed

Lines changed: 283 additions & 32 deletions

File tree

components/layout/MobileBottomNav.tsx

Lines changed: 111 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,126 @@ import Link from 'next/link';
44
import { usePathname } from 'next/navigation';
55
import { cn } from '@/lib/utils';
66
import { merchantNavItems } from '@/lib/navigation/merchantNav';
7+
import { useState } from 'react';
8+
import { MoreHorizontal, X } from 'lucide-react';
9+
import { Button } from '@/components/ui';
710

8-
const MOBILE_HREFS = ['/dashboard', '/payments', '/transactions', '/wallet', '/settings'] as const;
11+
const PRIMARY_HREFS = ['/dashboard', '/payments', '/transactions', '/wallet', '/settings'] as const;
12+
const ADDITIONAL_HREFS = ['/settlement', '/fx', '/developers'] as const;
913

10-
const mobileNavItems = MOBILE_HREFS.map((href) => {
14+
const primaryNavItems = PRIMARY_HREFS.map((href) => {
1115
const item = merchantNavItems.find((n) => n.href === href)!;
1216
return { ...item, label: item.shortLabel || item.label };
1317
});
1418

19+
const additionalNavItems = ADDITIONAL_HREFS.map((href) => {
20+
const item = merchantNavItems.find((n) => n.href === href)!;
21+
return { ...item, label: item.label };
22+
});
23+
1524
export const MobileBottomNav = () => {
1625
const pathname = usePathname();
26+
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
27+
28+
const isAdditionalActive = ADDITIONAL_HREFS.some(
29+
(href) => pathname === href || pathname.startsWith(href + '/')
30+
);
31+
32+
const handleMoreItemClick = () => {
33+
setMoreMenuOpen(false);
34+
};
1735

1836
return (
19-
<div className="fixed bottom-0 md:hidden left-0 right-0 z-40 bg-card border-t border-border px-2 pt-2 pb-safe sm:pb-3 flex items-center justify-around shadow-nav-bottom">
20-
{mobileNavItems.map((item) => {
21-
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
22-
const Icon = item.icon;
23-
24-
return (
25-
<Link
26-
key={item.href}
27-
href={item.href}
28-
aria-current={isActive ? 'page' : undefined}
29-
className={cn(
30-
"flex flex-col items-center justify-center w-[68px] gap-1 py-1.5 rounded-lg transition-all",
31-
isActive
32-
? "text-primary bg-primary/10"
33-
: "text-muted-foreground hover:bg-muted hover:text-foreground"
34-
)}
35-
>
36-
<Icon className={cn("w-5 h-5", isActive ? "text-primary" : "text-muted-foreground")} />
37-
<span className="text-[10px] font-medium tracking-tight">{item.label}</span>
38-
</Link>
39-
);
40-
})}
41-
</div>
37+
<>
38+
<div className="fixed bottom-0 md:hidden left-0 right-0 z-40 bg-card border-t border-border px-2 pt-2 pb-safe sm:pb-3 flex items-center justify-around shadow-nav-bottom">
39+
{primaryNavItems.map((item) => {
40+
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
41+
const Icon = item.icon;
42+
43+
return (
44+
<Link
45+
key={item.href}
46+
href={item.href}
47+
aria-current={isActive ? 'page' : undefined}
48+
className={cn(
49+
"flex flex-col items-center justify-center w-[68px] gap-1 py-1.5 rounded-lg transition-all",
50+
isActive
51+
? "text-primary bg-primary/10"
52+
: "text-muted-foreground hover:bg-muted hover:text-foreground"
53+
)}
54+
>
55+
<Icon className={cn("w-5 h-5", isActive ? "text-primary" : "text-muted-foreground")} />
56+
<span className="text-[10px] font-medium tracking-tight">{item.label}</span>
57+
</Link>
58+
);
59+
})}
60+
61+
<Button
62+
variant="ghost"
63+
size="icon"
64+
onClick={() => setMoreMenuOpen(!moreMenuOpen)}
65+
aria-expanded={moreMenuOpen}
66+
aria-label="More options"
67+
className={cn(
68+
"flex flex-col items-center justify-center w-[68px] gap-1 py-1.5 rounded-lg transition-all min-h-auto h-auto",
69+
isAdditionalActive
70+
? "text-primary bg-primary/10"
71+
: "text-muted-foreground hover:bg-muted hover:text-foreground"
72+
)}
73+
>
74+
<MoreHorizontal className={cn("w-5 h-5", isAdditionalActive ? "text-primary" : "text-muted-foreground")} />
75+
<span className="text-[10px] font-medium tracking-tight">More</span>
76+
</Button>
77+
</div>
78+
79+
{moreMenuOpen && (
80+
<>
81+
<div
82+
className="fixed inset-0 z-30 md:hidden"
83+
onClick={() => setMoreMenuOpen(false)}
84+
aria-hidden="true"
85+
/>
86+
<div className="fixed bottom-20 md:hidden left-0 right-0 z-40 mx-2 rounded-lg bg-card border border-border shadow-xl">
87+
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
88+
<h2 className="text-sm font-semibold">More routes</h2>
89+
<Button
90+
variant="ghost"
91+
size="icon"
92+
onClick={() => setMoreMenuOpen(false)}
93+
aria-label="Close menu"
94+
className="min-h-[44px] min-w-[44px]"
95+
>
96+
<X className="h-5 w-5" />
97+
</Button>
98+
</div>
99+
100+
<nav className="flex flex-col">
101+
{additionalNavItems.map((item) => {
102+
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
103+
const Icon = item.icon;
104+
105+
return (
106+
<Link
107+
key={item.href}
108+
href={item.href}
109+
onClick={handleMoreItemClick}
110+
aria-current={isActive ? 'page' : undefined}
111+
className={cn(
112+
"flex items-center gap-3 px-4 py-3 text-sm font-medium transition-colors border-b border-border last:border-b-0",
113+
isActive
114+
? "bg-primary/10 text-primary"
115+
: "text-muted-foreground hover:bg-muted hover:text-foreground"
116+
)}
117+
>
118+
<Icon className="w-5 h-5" aria-hidden="true" />
119+
{item.label}
120+
</Link>
121+
);
122+
})}
123+
</nav>
124+
</div>
125+
</>
126+
)}
127+
</>
42128
);
43129
};

components/layout/MobileNavDrawer.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { usePathname } from 'next/navigation';
66
import { cn } from '@/lib/utils';
77
import { X } from 'lucide-react';
88
import { Button } from '@/components/ui';
9+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
10+
import { useAuthStore } from '@/lib/store/authStore';
911

1012
interface NavItem {
1113
href: string;
@@ -114,7 +116,7 @@ export const MobileNavDrawer = ({
114116
</Button>
115117
</div>
116118

117-
<nav className="flex-1 px-4 py-4 space-y-1">
119+
<nav className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
118120
{navItems.map((item) => {
119121
const isActive = pathname.startsWith(item.href);
120122
const Icon = item.icon;
@@ -138,7 +140,62 @@ export const MobileNavDrawer = ({
138140
);
139141
})}
140142
</nav>
143+
144+
<UserProfileFooter onClose={onClose} />
141145
</div>
142146
</>
143147
);
144148
};
149+
150+
interface UserProfileFooterProps {
151+
onClose: () => void;
152+
}
153+
154+
function UserProfileFooter({ onClose }: UserProfileFooterProps) {
155+
const user = useAuthStore((s) => s.user);
156+
157+
const initials = user?.name
158+
? user.name
159+
.split(' ')
160+
.map((n) => n[0])
161+
.join('')
162+
.toUpperCase()
163+
.slice(0, 2)
164+
: 'U';
165+
166+
return (
167+
<div className="border-t border-sidebar-border px-4 py-4 space-y-3">
168+
<div className="flex items-center gap-3">
169+
<Avatar className="h-10 w-10 border border-sidebar-border">
170+
<AvatarImage src="/avatars/01.png" alt={user?.name ?? 'User'} />
171+
<AvatarFallback className="bg-primary text-primary-foreground text-xs font-bold">
172+
{initials}
173+
</AvatarFallback>
174+
</Avatar>
175+
<div className="flex-1 min-w-0">
176+
<p className="text-sm font-semibold text-sidebar-foreground truncate">
177+
{user?.name ?? 'User'}
178+
</p>
179+
<p className="text-xs text-muted-foreground truncate">
180+
{user?.email ?? 'user@example.com'}
181+
</p>
182+
</div>
183+
</div>
184+
185+
<Link
186+
href="/settings"
187+
onClick={onClose}
188+
className={cn(
189+
"flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors min-h-[44px]",
190+
"text-muted-foreground hover:bg-sidebar-accent/20 hover:text-sidebar-foreground"
191+
)}
192+
>
193+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
194+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
195+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
196+
</svg>
197+
Settings
198+
</Link>
199+
</div>
200+
);
201+
}
Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,99 @@
1-
import { Toggle } from "@/components/ui";
1+
"use client";
2+
3+
import { Toggle, Badge } from "@/components/ui";
4+
import { useRates } from "@/lib/api/hooks";
25
import type { OnboardingData } from "@/app/onboarding/page";
3-
type Props = { data: OnboardingData; errors: Record<string, string>; onChange: (data: Partial<OnboardingData>) => void };
6+
7+
type Props = {
8+
data: OnboardingData;
9+
errors: Record<string, string>;
10+
onChange: (data: Partial<OnboardingData>) => void;
11+
};
12+
413
const currencies = ["NGN", "USD", "USDC", "GHS", "KES", "ZAR"];
5-
export function StepCurrency({ data, errors, onChange }: Props) { return <section className="space-y-5"><div><h2 className="text-lg font-semibold">Default settlement currency</h2><p className="text-sm text-muted-foreground">Choose the currency you want to receive by default.</p></div><div className="grid grid-cols-2 gap-3 sm:grid-cols-3">{currencies.map((currency) => <button key={currency} type="button" onClick={() => onChange({ settlementCurrency: currency })} className={`rounded-lg border p-3 font-medium ${data.settlementCurrency === currency ? "border-primary bg-primary/5 text-primary" : "border-border"}`}>{currency}</button>)}</div>{errors.settlementCurrency && <p className="text-sm text-destructive">{errors.settlementCurrency}</p>}<div className="flex items-center justify-between rounded-lg border p-4"><div><p className="font-medium">Auto-convert payments</p><p className="text-sm text-muted-foreground">Convert incoming USDC to your default currency automatically.</p></div><Toggle checked={data.autoConvert} label="Auto-convert payments" onClick={() => onChange({ autoConvert: !data.autoConvert })} /></div></section>; }
14+
15+
export function StepCurrency({ data, errors, onChange }: Props) {
16+
const { data: rates, isLoading } = useRates();
17+
18+
const getExchangeRate = (currency: string): number | null => {
19+
if (currency === "NGN") return null;
20+
const rate = rates.find((r) => r.from === currency && r.to === "NGN");
21+
return rate?.rate ?? null;
22+
};
23+
24+
const formatRate = (rate: number | null): string => {
25+
if (rate === null) return "";
26+
return rate.toFixed(4);
27+
};
28+
29+
return (
30+
<section className="space-y-5">
31+
<div>
32+
<h2 className="text-lg font-semibold">Default settlement currency</h2>
33+
<p className="text-sm text-muted-foreground">
34+
Choose the currency you want to receive by default.
35+
</p>
36+
</div>
37+
38+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
39+
{currencies.map((currency) => {
40+
const rate = getExchangeRate(currency);
41+
const isSelected = data.settlementCurrency === currency;
42+
43+
return (
44+
<button
45+
key={currency}
46+
type="button"
47+
onClick={() => onChange({ settlementCurrency: currency })}
48+
className={`rounded-lg border p-3 text-left transition-colors ${
49+
isSelected
50+
? "border-primary bg-primary/5 text-primary"
51+
: "border-border hover:border-primary/50"
52+
}`}
53+
disabled={isLoading}
54+
>
55+
<div className="flex items-center justify-between gap-2 mb-2">
56+
<span className="font-medium">{currency}</span>
57+
{currency === "NGN" && (
58+
<Badge variant="default" className="text-xs">
59+
Recommended
60+
</Badge>
61+
)}
62+
</div>
63+
64+
{currency !== "NGN" && (
65+
<div className="text-xs text-muted-foreground">
66+
{isLoading ? (
67+
<span className="animate-pulse">Loading rate...</span>
68+
) : rate ? (
69+
<span>1 {currency} = {formatRate(rate)} NGN</span>
70+
) : (
71+
<span className="text-muted-foreground/50">Rate unavailable</span>
72+
)}
73+
</div>
74+
)}
75+
</button>
76+
);
77+
})}
78+
</div>
79+
80+
{errors.settlementCurrency && (
81+
<p className="text-sm text-destructive">{errors.settlementCurrency}</p>
82+
)}
83+
84+
<div className="flex items-center justify-between rounded-lg border p-4">
85+
<div>
86+
<p className="font-medium">Auto-convert payments</p>
87+
<p className="text-sm text-muted-foreground">
88+
Convert incoming USDC to your default currency automatically.
89+
</p>
90+
</div>
91+
<Toggle
92+
checked={data.autoConvert}
93+
label="Auto-convert payments"
94+
onClick={() => onChange({ autoConvert: !data.autoConvert })}
95+
/>
96+
</div>
97+
</section>
98+
);
99+
}

lib/utils/announce.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
1+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
2+
let latestMessage = '';
3+
14
export function announce(message: string): void {
25
if (typeof document === 'undefined') return;
36
const el = document.getElementById('announcer');
47
if (!el) return;
5-
el.textContent = '';
6-
// 100ms gives AT time to observe the cleared state before the new message.
7-
setTimeout(() => { el.textContent = message; }, 100);
8+
9+
latestMessage = message;
10+
11+
if (debounceTimer) {
12+
clearTimeout(debounceTimer);
13+
}
14+
15+
debounceTimer = setTimeout(() => {
16+
el.textContent = '';
17+
setTimeout(() => {
18+
el.textContent = latestMessage;
19+
}, 100);
20+
debounceTimer = null;
21+
}, 500);
822
}

0 commit comments

Comments
 (0)