diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md index 23dde958..d0f846c3 100644 --- a/PR_DESCRIPTION.md +++ b/PR_DESCRIPTION.md @@ -1,102 +1,37 @@ -# fix: wire error/help text to inputs via aria-describedby on EventsGrid and FormControl - -Closes #534 - ---- - -## Summary - -This PR improves accessibility by properly wiring error and help/description text to form controls and buttons via `aria-describedby`, in compliance with **WCAG 2.1 AA** success criteria 3.3.1 (Error Identification), 3.3.2 (Labels or Instructions), and 4.1.2 (Name, Role, Value). - -## Changes - -### 1. `components/events/events-grid.tsx` — Error state `aria-describedby` - -- Generates a stable `errorMessageId` via `React.useId()` at the **component top level** (fixed Rules of Hooks violation — hook was originally inside a conditional block) -- Assigns `id={errorMessageId}` to the error message `

` element -- Links the "Try again" button to the error message via `aria-describedby={errorMessageId}`, ensuring screen readers announce the error context when the button receives focus - -**Before:** -```tsx -

- {error} -

- -``` - -**After:** -```tsx -

- {error} -

- -``` - -### 2. `components/ui/form.tsx` — `FormControl` `aria-describedby` refactor - -Replaced the ternary expression with a cleaner, more robust array-based pattern that avoids trailing whitespace and empty-string edge cases. - -**Before:** -```tsx -aria-describedby={ - !error - ? `${formDescriptionId}` - : `${formDescriptionId} ${formMessageId}` -} -``` - -**After:** -```tsx -aria-describedby={ - [formDescriptionId, error ? formMessageId : null] - .filter(Boolean) - .join(" ") -} -``` - -### 3. Tests - -- **`components/events/__tests__/events-grid.test.tsx`** — Added 2 tests: - - Verifies `aria-describedby` on the retry button resolves to an element containing the error text - - Verifies the error message ID is stable across re-renders (ensures `useId()` works correctly) - -- **`components/ui/__tests__/form.test.tsx`** (new file) — Added 7 tests: - - Includes form description ID when there is no error - - Includes both description and message IDs when there is an error - - No empty/extra spaces in `aria-describedby` - - Sets `aria-invalid` to `true` when there is an error - - Does not set `aria-invalid` when there is no error - - Only includes description ID when no `FormDescription` is rendered - - `aria-describedby` IDs are stable across re-renders - -### 4. Lint fix - -- Added `displayName` to the `next/link` mock in EventsGrid tests to resolve `react/display-name` lint warning - -## Verification - -| Check | Status | -|-------|--------| -| ESLint | ✅ Clean on all changed files | -| Form tests (7) | ✅ All passing | -| EventsGrid tests (2 new) | ✅ All passing | -| TypeScript | ✅ No type errors on changed files | - -## Files Changed - -``` - M components/events/__tests__/events-grid.test.tsx (+35 lines) - M components/events/events-grid.tsx (+8 lines) - M components/ui/form.tsx (+3/-3 lines) - A components/ui/__tests__/form.test.tsx (+209 lines) -``` - -## Accessibility Impact - -- **WCAG 2.1 AA SC 3.3.1 (Error Identification)**: Error messages are now programmatically linked to the corresponding retry action -- **WCAG 2.1 AA SC 3.3.2 (Labels or Instructions)**: Form field descriptions are correctly associated with inputs -- **WCAG 2.1 AA SC 4.1.2 (Name, Role, Value)**: `aria-invalid` is properly toggled alongside `aria-describedby` for error states +Title: Prevent claim retries from duplicating intent + +Summary: +- Add client-side intent deduplication to prevent duplicate transaction submissions when users retry claims. +- Persist minimal intent state in `localStorage` with a 24h TTL to allow retries to reuse signed XDR or detected submission hashes. +- Integrate intent handling into `useTransaction` to ensure deterministic behavior across retries and partial failures. + +Files changed: +- `lib/transaction/intent.ts` - intent store (get/upsert/remove, computeXdrHash) +- `hooks/useTransaction.hook.ts` - integrate intent deduplication and locking +- `lib/transaction/__tests__/intent.test.ts` - intent store tests +- `hooks/__tests__/useTransaction.test.tsx` - focused transaction flow tests + +Behavior and invariants: +- Intent key: `walletAddress:sha256(builtXdr)` ensures same wallet + same built XDR map to same intent. +- If an intent has `submissionHash`, retry polls for confirmation instead of re-submitting. +- If an intent has `signedXdr` but not submitted, retry will re-submit stored `signedXdr` (avoids re-signing in many cases). +- Signed XDRs are removed shortly after successful confirmation to reduce exposure. + +Security considerations: +- Signed XDRs are persisted temporarily in `localStorage`. If this is unacceptable, switch to storing only the `xdrHash` and require re-signing on retry. +- Avoid storing private keys or secrets; only XDR strings are stored. + +Testing: +- Unit tests cover intent store operations and transaction flows for sign-submit-confirm and retry scenarios. +- Run tests locally with `pnpm test`. + +Migration / compatibility: +- No server changes or DB migrations required. +- Public API to `useTransaction.executeTransaction(buildXdr)` unchanged. + +Observability: +- Intents are stored with timestamps and status; support can inspect localStorage under `predictify:intents:v1` for debugging. + +Next steps (optional): +- Consider encrypting signed XDR in localStorage or reducing persistence TTL. +- Add telemetry/metrics when intents transition to `submitted` and `success`. diff --git a/app/(dashboard)/activity-timeline-demo/page.tsx b/app/(dashboard)/activity-timeline-demo/page.tsx index d4956a98..94a7d822 100644 --- a/app/(dashboard)/activity-timeline-demo/page.tsx +++ b/app/(dashboard)/activity-timeline-demo/page.tsx @@ -363,7 +363,7 @@ export default function MyPage() { onLoadMore - {"() => void"} + {'() => void'} undefined Callback when user clicks load more diff --git a/app/(dashboard)/disputes/page.tsx b/app/(dashboard)/disputes/page.tsx index 69c67562..76d31091 100644 --- a/app/(dashboard)/disputes/page.tsx +++ b/app/(dashboard)/disputes/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { AlertTriangle, CheckCircle, Filter, Search } from "lucide-react" import { DisputePanel } from "@/components/disputes/DisputePanel" +import { DisputeEvidencePreview } from "@/components/disputes/DisputeEvidencePreview" import { mockDisputesByState } from "@/components/disputes/mock-data" import type { DisputeData, DisputeState } from "@/types/disputes" import { Button } from "@/components/ui/button" @@ -25,21 +26,21 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" -import { ExternalLink } from "@/components/ExternalLink" -// Type for local disputes mock data -interface LocalDispute { - id: string - eventId: string - eventTitle: string - category: string - submittedBy: string - submittedDate: string - reason: string - status: string - priority: string - evidence: string -} +type DisputePriority = "high" | "medium" | "low"; + +type PendingDispute = { + id: string; + eventId: string; + eventTitle: string; + category: string; + submittedBy: string; + submittedDate: string; + reason: string; + status: string; + priority: DisputePriority; + evidence: string; +}; // Mock data for disputes const disputes = [ @@ -123,7 +124,7 @@ export default function DisputesPage() { const [searchQuery, setSearchQuery] = useState("") const [statusFilter, setStatusFilter] = useState("all") const [priorityFilter, setPriorityFilter] = useState("all") - const [selectedDispute, setSelectedDispute] = useState(null) + const [selectedDispute, setSelectedDispute] = useState(null) const [resolution, setResolution] = useState("") const [resolutionNotes, setResolutionNotes] = useState("") const [selectedDisputeData, setSelectedDisputeData] = useState(mockDisputesByState.none) @@ -141,7 +142,7 @@ export default function DisputesPage() { return matchesSearch && matchesStatus && matchesPriority }) - const getPriorityBadge = (priority: string) => { + const getPriorityBadge = (priority: DisputePriority | string) => { switch (priority) { case "high": return High @@ -155,9 +156,11 @@ export default function DisputesPage() { } const handleResolve = () => { + if (!selectedDispute) return; + // In a real app, you would send this data to your API console.log({ - disputeId: selectedDispute?.id, + disputeId: selectedDispute.id, resolution, notes: resolutionNotes, }) @@ -275,13 +278,7 @@ export default function DisputesPage() {
-
- -
+
diff --git a/app/(dashboard)/events/new/page.tsx b/app/(dashboard)/events/new/page.tsx index 2d469f45..c20027b2 100644 --- a/app/(dashboard)/events/new/page.tsx +++ b/app/(dashboard)/events/new/page.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useState } from "react" +import { useState } from "react" import { useRouter } from "next/navigation" import { CalendarIcon, Plus, Trash2 } from "lucide-react" import { Button } from "@/components/ui/button" @@ -20,7 +20,7 @@ export default function NewEventPage() { const [title, setTitle] = useState("") const [description, setDescription] = useState("") const [category, setCategory] = useState("") - const [deadline, setDeadline] = useState(undefined) + const [deadline, setDeadline] = useState(null) const [options, setOptions] = useState([{ text: "", probability: "" }]) const [newOption, setNewOption] = useState("") const [isPublic, setIsPublic] = useState(true) @@ -39,7 +39,7 @@ export default function NewEventPage() { setOptions(updatedOptions) } - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() // In a real app, you would send this data to your API console.log({ @@ -64,20 +64,86 @@ export default function NewEventPage() {
- {/* Row 1 Left: Title */} -
- - setTitle(e.target.value)} - required - /> +
+
+ + setTitle(e.target.value)} + required + /> +
+ +
+ +