feat(dapp): add savings goals list UI and cards; wire deposit & archive#749
feat(dapp): add savings goals list UI and cards; wire deposit & archive#749EDOHWARES wants to merge 3 commits into
Conversation
|
@EDOHWARES Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
There was a problem hiding this comment.
Pull request overview
Adds a “My Goals” view to the Savings page that fetches and renders savings goals from the API, introduces a dedicated SavingsGoalCard UI component, and wires goal-level actions (deposit + archive) into existing page state.
Changes:
- Refactors the goals list to render via a new
SavingsGoalCardcomponent and adds client-side filter chips. - Adds archive support via a React Query mutation + query invalidation, and exposes a deposit callback so the page can open the existing
DepositModal. - Updates the Savings page to toggle between Products and My Goals views.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 12 comments.
| File | Description |
|---|---|
| apps/dapp/frontend/components/savings/SavingsGoalsSection.tsx | Adds filter chips, goal card rendering, and archive mutation/invalidation wiring. |
| apps/dapp/frontend/components/savings/SavingsGoalCard.tsx | New presentational goal card with progress + deadline display and action buttons. |
| apps/dapp/frontend/app/savings/page.tsx | Introduces Products/My Goals view toggle and wires goal deposit selection into existing modal state. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| import { format } from "date-fns"; | ||
| import { | ||
| Briefcase, | ||
| GraduationCap, | ||
| Heart, | ||
| Home, | ||
| Plane, | ||
| Shield, | ||
| Target, | ||
| type LucideIcon, | ||
| } from "lucide-react"; |
| const CATEGORY_ICONS: Record<string, LucideIcon> = { | ||
| emergency_fund: Shield, | ||
| education: GraduationCap, | ||
| housing: Home, | ||
| travel: Plane, | ||
| business: Briefcase, | ||
| health: Heart, | ||
| emergency_fund: Target, | ||
| education: Target, | ||
| housing: Target, | ||
| travel: Target, |
| const [filter, setFilter] = useState<"all" | "active" | "completed" | "archived">("all"); | ||
|
|
||
| const queryClient = useQueryClient(); | ||
| const archiveMutation = useMutation({ | ||
| mutationFn: async (id: string) => { | ||
| const res = await savingsGoals.delete(id); | ||
| if (!res.ok) throw new Error("Failed to archive savings goal"); | ||
| }, | ||
| onSuccess: async () => { | ||
| await queryClient.invalidateQueries({ queryKey: ["savings-goals"] }); | ||
| }, | ||
| }); | ||
|
|
||
| const filteredGoals = useMemo(() => { | ||
| if (!goals) return []; | ||
| if (filter === "all") return goals; | ||
| if (filter === "active") return goals.filter((g) => (g.progress_pct ?? 0) < 100); | ||
| if (filter === "completed") return goals.filter((g) => (g.progress_pct ?? 0) >= 100); | ||
| if (filter === "archived") return goals.filter((g: any) => !!g.archived); | ||
| return goals; | ||
| }, [goals, filter]); |
| {[ | ||
| { key: "all", label: "All" }, | ||
| { key: "active", label: "Active" }, | ||
| { key: "completed", label: "Completed" }, | ||
| { key: "archived", label: "Archived" }, | ||
| ].map((c) => ( |
| const filteredGoals = useMemo(() => { | ||
| if (!goals) return []; | ||
| if (filter === "all") return goals; | ||
| if (filter === "active") return goals.filter((g) => (g.progress_pct ?? 0) < 100); | ||
| if (filter === "completed") return goals.filter((g) => (g.progress_pct ?? 0) >= 100); | ||
| if (filter === "archived") return goals.filter((g: any) => !!g.archived); | ||
| return goals; | ||
| }, [goals, filter]); |
| <div className="flex gap-2"> | ||
| <button | ||
| type="button" | ||
| onClick={() => onDeposit(goal)} | ||
| className="flex-1 rounded-xl border border-black/10 px-3 py-2 text-sm font-semibold text-black hover:bg-black/5" | ||
| > | ||
| Deposit | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={() => onArchive(goal.id)} | ||
| className="rounded-xl bg-black px-3 py-2 text-sm font-semibold text-white hover:opacity-90" | ||
| > | ||
| Archive | ||
| </button> | ||
| </div> |
| onSuccess: (goal: any) => { | ||
| queryClient.invalidateQueries(["savings-goals"]); | ||
| addNotification({ title: "Goal created", message: `Goal '${goal.description ?? goal.id}' created!`, type: "info" }, { showToast: true }); | ||
| onClose(); | ||
| }, |
| const [step, setStep] = useState<1 | 2>(1); | ||
| const [name, setName] = useState(""); | ||
| const [category, setCategory] = useState("other"); | ||
| const [target, setTarget] = useState(""); | ||
| const [currency, setCurrency] = useState<"USDC" | "XLM">("USDC"); | ||
| const [deadline, setDeadline] = useState(""); | ||
| const [description, setDescription] = useState(""); | ||
| const [vaultId, setVaultId] = useState<string | undefined>(undefined); |
| createMutation.mutate({ | ||
| description: name, | ||
| category, | ||
| target_amount: parseFloat(target), | ||
| currency, | ||
| deadline: new Date(deadline).toISOString(), | ||
| vault_id: vaultId, | ||
| // description field used as name until API name added | ||
| }); |
| <div id="vault-grid" className="mb-6 flex gap-1.5 border-b border-black/8 pb-px overflow-x-auto scrollbar-hide" role="tablist" aria-label="Savings plan filters"> | ||
| {FILTERS.map((f) => ( | ||
| <button | ||
| key={f.value} | ||
| role="tab" | ||
| aria-selected={filter === f.value} | ||
| onClick={() => setFilter(f.value)} | ||
| className={cn( | ||
| "relative pb-3 px-1 mr-4 text-sm whitespace-nowrap transition-colors shrink-0 focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2", | ||
| filter === f.value | ||
| ? "text-black font-semibold" | ||
| : "text-black/60 hover:text-black/80 font-medium" | ||
| )} | ||
| > | ||
| {f.label} | ||
| {filter === f.value && ( | ||
| <motion.div | ||
| layoutId="savings-tab" | ||
| className="absolute bottom-0 left-0 right-0 h-0.5 bg-black rounded-full" | ||
| aria-hidden="true" | ||
| /> | ||
| )} | ||
| </button> | ||
| ))} | ||
| </div> |
0xDeon
left a comment
There was a problem hiding this comment.
CI blocking merge: Dapp Frontend (Next.js) build failed. Fix the frontend build errors before merging.
feat(dapp): build savings goals list page — fetch and render user goals from API
Summary
useSavingsGoals()to fetch/api/v1/users/savings-goals.All | Active | Completed | Archived).DepositModalpre-filled for the goal's linked vault.DELETE /api/v1/users/savings-goals/{id}and refreshes the list.What I changed
New component
SavingsGoalCard.tsxPresentational card for a savings goal displaying:
Updated component
SavingsGoalsSection.tsxEnhancements include:
SavingsGoalCarduseSavingsGoals()useMutationintegration for archive requestsImplemented UI states:
Exposed callbacks:
The parent component remains responsible for opening
DepositModal.Tests
Updated and added Vitest tests to verify:
Files touched
SavingsGoalCard.tsx(new)SavingsGoalsSection.tsx(modified)savings-goals-section.test.tsx(updated)Behavior / Acceptance Criteria Mapping
"My Goals" section renders a list of user goals
SavingsGoalsSectionGoals fetched from
GET /api/v1/users/savings-goalsuseSavingsGoals()Loading, empty, error, and not-connected states
Implemented with:
Filter chips filter the list
Deposit opens
DepositModalpre-filledSavingsGoalCardexposesonDepositDepositModalArchive action calls DELETE and removes the goal
savings-goalsquery after successTests
Notes & follow-ups
DepositModalimplementation.archivedfield, the Archived filter uses it directly.Testing instructions
Run unit tests:
yarn yarn testOr use the repository's standard test command.
Manual verification
Visit the
/savingspage and verify:Possible follow-ups
Closes #699
Closes #698
Closes #695
Closes #700