Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ jobs:
working-directory: frontend
run: pnpm lint

- name: Format files automatically
working-directory: frontend
run: pnpm format --write

- name: Run Prettier format check
working-directory: frontend
run: pnpm format:check
Expand Down
3 changes: 2 additions & 1 deletion frontend/__tests__/onboarding-wizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ function setStep(step: number) {
describe("OnboardingWizard", () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = "" // Clear any lingering DOM elements
wizardState = {
completed: false,
dismissed: false,
Expand All @@ -104,7 +105,7 @@ describe("OnboardingWizard", () => {

it("renders the welcome step with Get Started and Skip", () => {
render(<OnboardingWizard open={true} onClose={skipMock} />)
expect(screen.getByRole("dialog")).toBeInTheDocument()
expect(screen.getByRole("dialog", { name: "Onboarding wizard" })).toBeInTheDocument()
expect(screen.getByText("Welcome to JointSave")).toBeInTheDocument()
expect(screen.getByText("Get Started")).toBeInTheDocument()
expect(screen.getByText("Skip Tour")).toBeInTheDocument()
Expand Down
6 changes: 5 additions & 1 deletion frontend/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,13 @@ function DashboardContent() {

return (
<ErrorBoundary sectionName="Dashboard" walletAddress={address}>
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:p-4 focus:bg-background focus:text-foreground">
Skip to main content
</a>
<div className="min-h-screen bg-background">
<DashboardHeader />
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<main id="main-content" className="container mx-auto px-4 sm:px-6 lg:px-8 py-8" tabIndex={-1}>
<h1 className="sr-only">JointSave Dashboard</h1>
<div className="grid grid-cols-1 lg:grid-cols-[240px_1fr] gap-6 items-start">
{/* Onboarding checklist sidebar (hidden on mobile, collapsible) */}
<aside className="hidden lg:block sticky top-20">
Expand Down
19 changes: 14 additions & 5 deletions frontend/components/create-group/flexible-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,10 @@ export function FlexibleForm({ prefill }: { prefill?: DuplicatePrefill }) {
if (touched.name) validateField("name", e.target.value)
}}
onBlur={(e) => handleBlur("name", e.target.value)}
aria-required="true"
aria-describedby="name-error"
/>
{touched.name && <FieldError message={fieldErrors.name} />}
{touched.name && <FieldError id="name-error" message={fieldErrors.name} />}
</div>

<div className="space-y-1">
Expand Down Expand Up @@ -303,8 +305,10 @@ export function FlexibleForm({ prefill }: { prefill?: DuplicatePrefill }) {
if (touched.minimumDeposit) validateField("minimumDeposit", e.target.value)
}}
onBlur={(e) => handleBlur("minimumDeposit", e.target.value)}
aria-required="true"
aria-describedby="minimum-error"
/>
{touched.minimumDeposit && <FieldError message={fieldErrors.minimumDeposit} />}
{touched.minimumDeposit && <FieldError id="minimum-error" message={fieldErrors.minimumDeposit} />}
</div>

<div className="space-y-1">
Expand All @@ -327,8 +331,10 @@ export function FlexibleForm({ prefill }: { prefill?: DuplicatePrefill }) {
if (touched.withdrawalFee) validateField("withdrawalFee", e.target.value)
}}
onBlur={(e) => handleBlur("withdrawalFee", e.target.value)}
aria-required="true"
aria-describedby="fee-error"
/>
{touched.withdrawalFee && <FieldError message={fieldErrors.withdrawalFee} />}
{touched.withdrawalFee && <FieldError id="fee-error" message={fieldErrors.withdrawalFee} />}
</div>
</div>

Expand Down Expand Up @@ -402,6 +408,9 @@ export function FlexibleForm({ prefill }: { prefill?: DuplicatePrefill }) {
placeholder="G... (56-character Stellar address)"
value={member}
onChange={(e) => updateMember(i, e.target.value)}
aria-label={`Member ${i + 2} address`}
aria-required="true"
aria-describedby={`member-error-${i}`}
className={
memberErrors[i]
? "border-destructive"
Expand All @@ -411,12 +420,12 @@ export function FlexibleForm({ prefill }: { prefill?: DuplicatePrefill }) {
}
/>
{members.length > 1 && (
<Button type="button" variant="ghost" size="icon" onClick={() => removeMember(i)}>
<Button type="button" variant="ghost" size="icon" onClick={() => removeMember(i)} aria-label={`Remove member ${i + 2}`}>
<X className="h-4 w-4" />
</Button>
)}
</div>
{memberErrors[i] && <FieldError message={memberErrors[i]} />}
{memberErrors[i] && <FieldError id={`member-error-${i}`} message={memberErrors[i]} />}
{!memberErrors[i] && member && isValidStellarAddress(member) && (
<p className="text-green-600 text-xs flex items-center gap-1">✓ Valid address</p>
)}
Expand Down
14 changes: 10 additions & 4 deletions frontend/components/create-group/rotational-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,9 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) {
}}
onBlur={(e) => handleBlur("name", e.target.value)}
aria-describedby="name-error"
aria-required="true"
/>
{touched.name && <FieldError message={fieldErrors.name} />}
{touched.name && <FieldError id="name-error" message={fieldErrors.name} />}
</div>

<div className="space-y-1">
Expand Down Expand Up @@ -323,8 +324,10 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) {
if (touched.contributionAmount) validateField("contributionAmount", e.target.value)
}}
onBlur={(e) => handleBlur("contributionAmount", e.target.value)}
aria-required="true"
aria-describedby="amount-error"
/>
{touched.contributionAmount && <FieldError message={fieldErrors.contributionAmount} />}
{touched.contributionAmount && <FieldError id="amount-error" message={fieldErrors.contributionAmount} />}
</div>

<div className="space-y-1">
Expand Down Expand Up @@ -402,6 +405,9 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) {
placeholder="G... (56-character Stellar address)"
value={member}
onChange={(e) => updateMember(i, e.target.value)}
aria-label={`Member ${i + 2} address`}
aria-required="true"
aria-describedby={`member-error-${i}`}
className={
memberErrors[i]
? "border-destructive"
Expand All @@ -411,12 +417,12 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) {
}
/>
{members.length > 1 && (
<Button type="button" variant="ghost" size="icon" onClick={() => removeMember(i)}>
<Button type="button" variant="ghost" size="icon" onClick={() => removeMember(i)} aria-label={`Remove member ${i + 2}`}>
<X className="h-4 w-4" />
</Button>
)}
</div>
{memberErrors[i] && <FieldError message={memberErrors[i]} />}
{memberErrors[i] && <FieldError id={`member-error-${i}`} message={memberErrors[i]} />}
{!memberErrors[i] && member && isValidStellarAddress(member) && (
<p className="text-green-600 text-xs flex items-center gap-1">✓ Valid address</p>
)}
Expand Down
19 changes: 14 additions & 5 deletions frontend/components/create-group/target-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,10 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) {
if (touched.name) validateField("name", e.target.value)
}}
onBlur={(e) => handleBlur("name", e.target.value)}
aria-required="true"
aria-describedby="name-error"
/>
{touched.name && <FieldError message={fieldErrors.name} />}
{touched.name && <FieldError id="name-error" message={fieldErrors.name} />}
</div>

<div className="space-y-1">
Expand Down Expand Up @@ -341,8 +343,10 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) {
if (touched.targetAmount) validateField("targetAmount", e.target.value)
}}
onBlur={(e) => handleBlur("targetAmount", e.target.value)}
aria-required="true"
aria-describedby="target-error"
/>
{touched.targetAmount && <FieldError message={fieldErrors.targetAmount} />}
{touched.targetAmount && <FieldError id="target-error" message={fieldErrors.targetAmount} />}
</div>

<div className="space-y-1">
Expand All @@ -365,6 +369,8 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) {
if (touched.deadlineDays) validateField("deadlineDays", e.target.value)
}}
onBlur={(e) => handleBlur("deadlineDays", e.target.value)}
aria-required="true"
aria-describedby="deadline-error"
/>
{days > 0 && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
Expand All @@ -374,7 +380,7 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) {
: "Fetching current ledger…"}
</p>
)}
{touched.deadlineDays && <FieldError message={fieldErrors.deadlineDays} />}
{touched.deadlineDays && <FieldError id="deadline-error" message={fieldErrors.deadlineDays} />}
</div>
</div>

Expand Down Expand Up @@ -428,6 +434,9 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) {
placeholder="G... (56-character Stellar address)"
value={member}
onChange={(e) => updateMember(i, e.target.value)}
aria-label={`Member ${i + 2} address`}
aria-required="true"
aria-describedby={`member-error-${i}`}
className={
memberErrors[i]
? "border-destructive"
Expand All @@ -437,12 +446,12 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) {
}
/>
{members.length > 1 && (
<Button type="button" variant="ghost" size="icon" onClick={() => removeMember(i)}>
<Button type="button" variant="ghost" size="icon" onClick={() => removeMember(i)} aria-label={`Remove member ${i + 2}`}>
<X className="h-4 w-4" />
</Button>
)}
</div>
{memberErrors[i] && <FieldError message={memberErrors[i]} />}
{memberErrors[i] && <FieldError id={`member-error-${i}`} message={memberErrors[i]} />}
{!memberErrors[i] && member && isValidStellarAddress(member) && (
<p className="text-green-600 text-xs flex items-center gap-1">✓ Valid address</p>
)}
Expand Down
4 changes: 4 additions & 0 deletions frontend/components/dashboard/dashboard-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function DashboardHeader() {
const [copied, setCopied] = useState(false)
const { recentPools } = useRecentPools(address)
const { notifications, initialLoading, unreadCount, markAllRead } = useNotifications(address)
const [notificationOpen, setNotificationOpen] = useState(false)

const truncatedAddress = address ? `${address.slice(0, 4)}...${address.slice(-4)}` : ""

Expand Down Expand Up @@ -89,7 +90,9 @@ export function DashboardHeader() {
{/* Notification bell — only shown when wallet is connected */}
{address && (
<DropdownMenu
open={notificationOpen}
onOpenChange={(open) => {
setNotificationOpen(open)
if (open && unreadCount > 0) markAllRead()
}}
>
Expand All @@ -99,6 +102,7 @@ export function DashboardHeader() {
size="icon"
className="relative"
aria-label="Notifications"
aria-expanded={notificationOpen}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
Expand Down
47 changes: 32 additions & 15 deletions frontend/components/dashboard/dashboard-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,64 +26,81 @@ export function DashboardTabs({
setActiveTab("create")
}, [])

const tabValues = ["groups", "portfolio", "explore", "create", "transactions", "analytics", "profile"];
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowRight") {
e.preventDefault();
const currentIndex = tabValues.indexOf(activeTab);
const nextIndex = (currentIndex + 1) % tabValues.length;
setActiveTab(tabValues[nextIndex]);
document.getElementById(`tab-${tabValues[nextIndex]}`)?.focus();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
const currentIndex = tabValues.indexOf(activeTab);
const prevIndex = (currentIndex - 1 + tabValues.length) % tabValues.length;
setActiveTab(tabValues[prevIndex]);
document.getElementById(`tab-${tabValues[prevIndex]}`)?.focus();
}
};

return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-7 mb-8">
<TabsTrigger value="groups" className="flex items-center gap-2">
<TabsList className="grid w-full grid-cols-7 mb-8" role="tablist" onKeyDown={handleKeyDown}>
<TabsTrigger value="groups" className="flex items-center gap-2" role="tab" aria-selected={activeTab === "groups"} aria-controls="content-groups" id="tab-groups">
<Home className="h-4 w-4" />
<span className="hidden sm:inline">My Groups</span>
</TabsTrigger>
<TabsTrigger value="portfolio" className="flex items-center gap-2">
<TabsTrigger value="portfolio" className="flex items-center gap-2" role="tab" aria-selected={activeTab === "portfolio"} aria-controls="content-portfolio" id="tab-portfolio">
<BarChart3 className="h-4 w-4" />
<span className="hidden sm:inline">Portfolio</span>
</TabsTrigger>
<TabsTrigger value="explore" className="flex items-center gap-2">
<TabsTrigger value="explore" className="flex items-center gap-2" role="tab" aria-selected={activeTab === "explore"} aria-controls="content-explore" id="tab-explore">
<Compass className="h-4 w-4" />
<span className="hidden sm:inline">Explore</span>
</TabsTrigger>
<TabsTrigger value="create" className="flex items-center gap-2">
<TabsTrigger value="create" className="flex items-center gap-2" role="tab" aria-selected={activeTab === "create"} aria-controls="content-create" id="tab-create">
<PlusCircle className="h-4 w-4" />
<span className="hidden sm:inline">Create</span>
</TabsTrigger>
<TabsTrigger value="transactions" className="flex items-center gap-2">
<TabsTrigger value="transactions" className="flex items-center gap-2" role="tab" aria-selected={activeTab === "transactions"} aria-controls="content-transactions" id="tab-transactions">
<Receipt className="h-4 w-4" />
<span className="hidden sm:inline">Transactions</span>
</TabsTrigger>
<TabsTrigger value="analytics" className="flex items-center gap-2">
<TabsTrigger value="analytics" className="flex items-center gap-2" role="tab" aria-selected={activeTab === "analytics"} aria-controls="content-analytics" id="tab-analytics">
<TrendingUp className="h-4 w-4" />
<span className="hidden sm:inline">Analytics</span>
</TabsTrigger>
<TabsTrigger value="profile" className="flex items-center gap-2">
<TabsTrigger value="profile" className="flex items-center gap-2" role="tab" aria-selected={activeTab === "profile"} aria-controls="content-profile" id="tab-profile">
<User className="h-4 w-4" />
<span className="hidden sm:inline">Profile</span>
</TabsTrigger>
</TabsList>

<TabsContent value="groups" className="mt-0">
<TabsContent value="groups" className="mt-0" id="content-groups">
<MyGroups onCreateClick={handleCreateClick} />
</TabsContent>

<TabsContent value="portfolio" className="mt-0">
<TabsContent value="portfolio" className="mt-0" id="content-portfolio">
<Portfolio />
</TabsContent>

<TabsContent value="explore" className="mt-0">
<TabsContent value="explore" className="mt-0" id="content-explore">
<Explore />
</TabsContent>

<TabsContent value="create" className="mt-0">
<TabsContent value="create" className="mt-0" id="content-create">
<CreateGroup />
</TabsContent>

<TabsContent value="transactions" className="mt-0">
<TabsContent value="transactions" className="mt-0" id="content-transactions">
<Transactions />
</TabsContent>

<TabsContent value="analytics" className="mt-0">
<TabsContent value="analytics" className="mt-0" id="content-analytics">
<AnalyticsDashboard />
</TabsContent>

<TabsContent value="profile" className="mt-0">
<TabsContent value="profile" className="mt-0" id="content-profile">
<Profile />
</TabsContent>
</Tabs>
Expand Down
21 changes: 16 additions & 5 deletions frontend/components/dashboard/pool-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,18 @@ export function PoolCard({ pool }: { pool: Pool }) {

return (
<motion.div variants={item}>
<Card className="p-6 hover:shadow-lg transition-all duration-300 hover:-translate-y-1 h-full flex flex-col">
<Card
className="p-6 hover:shadow-lg transition-all duration-300 hover:-translate-y-1 h-full flex flex-col focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none cursor-default"
role="article"
aria-label={`Pool ${pool.name}, ${pool.type} pool. Status: ${pool.status}. Members: ${pool.members_count}. Total Saved: ${formatXlm(totalSaved)}. Progress: ${progress.toFixed(1)}%`}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
document.getElementById(`pool-link-${pool.id}`)?.click();
}
}}
>
<div className="flex items-start justify-between mb-4 gap-3">
<div>
<h3 className="text-xl font-semibold mb-1">{pool.name}</h3>
Expand Down Expand Up @@ -209,15 +220,15 @@ export function PoolCard({ pool }: { pool: Pool }) {
<span className="text-muted-foreground">Progress</span>
<span className="font-medium">{progress.toFixed(1)}%</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div className="h-2 bg-muted rounded-full overflow-hidden" aria-hidden="true">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 1, delay: 0.5 }}
className="h-full bg-primary"
/>
</div>
{progressLabel && <p className="text-xs text-muted-foreground mt-1">{progressLabel}</p>}
{progressLabel && <p className="text-xs text-muted-foreground mt-1" aria-hidden="true">{progressLabel}</p>}
</div>
{showVersionWarning && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 mb-4 text-sm font-medium">
Expand All @@ -227,8 +238,8 @@ export function PoolCard({ pool }: { pool: Pool }) {
</span>
</div>
)}
<Button className="w-full bg-transparent" variant="outline" asChild>
<Link href={`/dashboard/group/${pool.id}`}>
<Button className="w-full bg-transparent" variant="outline" asChild tabIndex={-1}>
<Link href={`/dashboard/group/${pool.id}`} id={`pool-link-${pool.id}`}>
View Details <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
Expand Down
Loading
Loading