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
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,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 @@ -84,6 +84,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 @@ -105,7 +106,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
8 changes: 7 additions & 1 deletion frontend/app/[locale]/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ function DashboardContent() {

return (
<ErrorBoundary sectionName={t("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 All @@ -75,3 +79,5 @@ export default function DashboardPage() {
</OnboardingProvider>
)
}

// re-trigger CI
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 @@ -298,8 +298,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 @@ -349,8 +351,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 @@ -373,8 +377,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 @@ -444,6 +450,9 @@ export function FlexibleForm({ prefill }: { prefill?: DuplicatePrefill }) {
placeholder={tc("addressPlaceholder")}
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 @@ -453,12 +462,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">
✓ {tc("validAddress")}
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 @@ -307,8 +307,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 @@ -358,8 +359,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 @@ -435,6 +438,9 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) {
placeholder={tc("addressPlaceholder")}
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 @@ -444,12 +450,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">
✓ {tc("validAddress")}
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 @@ -327,8 +327,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 @@ -378,8 +380,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 @@ -402,6 +406,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 @@ -414,7 +420,7 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) {
: t("fetchingLedger")}
</p>
)}
{touched.deadlineDays && <FieldError message={fieldErrors.deadlineDays} />}
{touched.deadlineDays && <FieldError id="deadline-error" message={fieldErrors.deadlineDays} />}
</div>
</div>

Expand Down Expand Up @@ -466,6 +472,9 @@ export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) {
placeholder={tc("addressPlaceholder")}
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 @@ -475,12 +484,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">
✓ {tc("validAddress")}
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 @@ -35,6 +35,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 @@ -100,7 +101,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 @@ -110,6 +113,7 @@ export function DashboardHeader() {
size="icon"
className="relative"
aria-label={t("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 @@ -39,64 +39,81 @@ export function DashboardTabs({
setActiveTab("create")
}, [setActiveTab])

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">{t("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">{t("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">{t("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">{t("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">{t("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">{t("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">{t("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
Loading
Loading