From e918fe071a235bae3e3cd93418f33c522f836e74 Mon Sep 17 00:00:00 2001 From: princeobeten Date: Thu, 16 Jul 2026 11:44:13 +0100 Subject: [PATCH 1/2] feat(dashboard): View Plan detail modals for locks and goals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the "View Plan" link to every lock and goal row, opening a read-only breakdown from the mock. - LockPlanModal: principal, term, APY, unlock date, projected yield, and the maturity total (amount + projected_yield). No performance-fee row — the contract charges none. - GoalPlanModal: goal, target, saved so far, deadline, frequency, per-deposit amount, and the withdraw action. Per the design, the goal row now shows Top Up + View Plan, and the destructive withdraw lives inside View Plan (which hands off to the existing payout/confirm modal). Withdrawing early still forfeits 1%, so that button stays red. --- .../locked/components/LockPlanModal.tsx | 85 +++++++++++++++ src/features/locked/components/LockRow.tsx | 23 +++- .../targets/components/GoalPlanModal.tsx | 103 ++++++++++++++++++ src/features/targets/components/TargetRow.tsx | 58 +++------- src/pages/dashboard/Locked.tsx | 7 ++ src/pages/dashboard/Targets.tsx | 16 ++- 6 files changed, 245 insertions(+), 47 deletions(-) create mode 100644 src/features/locked/components/LockPlanModal.tsx create mode 100644 src/features/targets/components/GoalPlanModal.tsx diff --git a/src/features/locked/components/LockPlanModal.tsx b/src/features/locked/components/LockPlanModal.tsx new file mode 100644 index 0000000..a58723b --- /dev/null +++ b/src/features/locked/components/LockPlanModal.tsx @@ -0,0 +1,85 @@ +import type { Lock } from "../hooks/useLocks"; +import { lockMonths } from "../lib/lockMath"; +import { formatUsdc, formatDate } from "../../../shared/lib/money"; +import DashModal from "../../../shared/dash/DashModal"; +import DashButton from "../../../shared/dash/DashButton"; + +interface Props { + lock: Lock; + onClose: () => void; +} + +/** + * Read-only details for a lock ("View Plan"). Mirrors the create-flow review: + * principal, term, APY, unlock date, projected yield, and the maturity total — + * which is `amount + projected_yield`, since the contract charges no fee. + */ +export default function LockPlanModal({ lock, onClose }: Props) { + const months = lockMonths(lock); + const receive = lock.amount + lock.projected_yield; + + return ( + +

+ View Plan +

+

+ Here are more details about your locked plan. +

+ +
+ + +
+
APY Tier
+
+ + {(lock.apy_basis_points / 100).toFixed( + lock.apy_basis_points % 100 === 0 ? 0 : 1, + )} + % APY + +
+
+ + +
+
+ You receive at maturity +
+
+ {formatUsdc(receive)} USDC +
+
+
+ + + Dismiss + +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/src/features/locked/components/LockRow.tsx b/src/features/locked/components/LockRow.tsx index ecab00d..6b3c13c 100644 --- a/src/features/locked/components/LockRow.tsx +++ b/src/features/locked/components/LockRow.tsx @@ -23,9 +23,15 @@ interface Props { lock: Lock; nowSecs: number; onUnlock: (lock: Lock) => void; + onViewPlan: (lock: Lock) => void; } -export default function LockRow({ lock, nowSecs, onUnlock }: Props) { +export default function LockRow({ + lock, + nowSecs, + onUnlock, + onViewPlan, +}: Props) { const status = lockStatus(lock, nowSecs); const months = lockMonths(lock); @@ -102,24 +108,31 @@ export default function LockRow({ lock, nowSecs, onUnlock }: Props) { -
+
{status === "matured" ? ( onUnlock(lock)} - className="w-full sm:w-auto" + className="max-sm:flex-1" > Unlock Now ) : status === "locked" ? ( - + Locked · {timeLeftLabel(lock, nowSecs)} ) : ( - + Withdrawn )} +
); diff --git a/src/features/targets/components/GoalPlanModal.tsx b/src/features/targets/components/GoalPlanModal.tsx new file mode 100644 index 0000000..2ebd3cf --- /dev/null +++ b/src/features/targets/components/GoalPlanModal.tsx @@ -0,0 +1,103 @@ +import type { TargetGoal } from "../hooks/useTargets"; +import { + frequencyOf, + isEarlyWithdrawal, + targetStatus, +} from "../lib/targetMath"; +import { + formatUsdc, + formatUsdcAdaptive, + formatDate, +} from "../../../shared/lib/money"; +import DashModal from "../../../shared/dash/DashModal"; +import DashButton from "../../../shared/dash/DashButton"; + +interface Props { + goal: TargetGoal; + nowSecs: number; + /** Open the withdraw payout modal for this goal. */ + onWithdraw: (goal: TargetGoal) => void; + onClose: () => void; +} + +/** + * Read-only details for a goal ("View Plan"), and the entry point for + * withdrawing — the row itself only offers Top Up. Withdrawing before the + * deadline forfeits 1%, so that button is red and leads to the payout modal. + */ +export default function GoalPlanModal({ + goal, + nowSecs, + onWithdraw, + onClose, +}: Props) { + const status = targetStatus(goal, nowSecs); + const early = isEarlyWithdrawal(goal, nowSecs); + const withdrawn = status === "withdrawn"; + + return ( + +

+ View Plan +

+

+ Here are more details about your target savings. +

+ +
+ + + + + +
+
+ Per deposit amount +
+
+ {formatUsdc(goal.period_amount)} USDC +
+
+
+ +
+ {!withdrawn && ( + onWithdraw(goal)} + className="w-full py-3.5" + > + {early ? "Withdraw early" : "Withdraw Now"} + + )} + + Dismiss + +
+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/src/features/targets/components/TargetRow.tsx b/src/features/targets/components/TargetRow.tsx index cdc8459..38be91d 100644 --- a/src/features/targets/components/TargetRow.tsx +++ b/src/features/targets/components/TargetRow.tsx @@ -1,7 +1,6 @@ import type { TargetGoal } from "../hooks/useTargets"; import { frequencyOf, - isEarlyWithdrawal, progressPct, targetStatus, type TargetStatus, @@ -31,7 +30,7 @@ interface Props { goal: TargetGoal; nowSecs: number; onTopUp: (goal: TargetGoal) => void; - onWithdraw: (goal: TargetGoal) => void; + onViewPlan: (goal: TargetGoal) => void; } /* public/dashboard/icons/target-02.svg, inlined so stroke follows currentColor @@ -98,18 +97,12 @@ export default function TargetRow({ goal, nowSecs, onTopUp, - onWithdraw, + onViewPlan, }: Props) { const status = targetStatus(goal, nowSecs); if (status === "withdrawn") return ; const pct = progressPct(goal); - const matured = status === "matured"; - - // A goal reaches its target before the deadline still forfeits 1% on - // withdrawal — the forfeit keys off `end_date`, not how full the goal is — so - // that case reads "Withdraw early" rather than "Withdraw Now". - const early = isEarlyWithdrawal(goal, nowSecs); return (
  • @@ -161,38 +154,21 @@ export default function TargetRow({
  • -
    - {matured ? ( - onWithdraw(goal)} - title={ - early - ? `The contract charges the 1% forfeit until ${formatDateShort(goal.end_date)}.` - : undefined - } - className="w-full sm:w-auto" - > - {early ? "Withdraw early" : "Withdraw Now"} - - ) : ( - <> - onTopUp(goal)} - className="w-full sm:w-auto" - > - Top Up - - onWithdraw(goal)} - className="w-full sm:w-auto" - > - Withdraw early - - - )} +
    + onTopUp(goal)} + className="max-sm:flex-1" + > + Top Up + +
    ); diff --git a/src/pages/dashboard/Locked.tsx b/src/pages/dashboard/Locked.tsx index 9e7eace..b444719 100644 --- a/src/pages/dashboard/Locked.tsx +++ b/src/pages/dashboard/Locked.tsx @@ -6,6 +6,7 @@ import { useUsdcBalance } from "../../hooks/useUsdcBalance"; import { lockStatus } from "../../features/locked/lib/lockMath"; import LockRow from "../../features/locked/components/LockRow"; import UnlockModal from "../../features/locked/components/UnlockModal"; +import LockPlanModal from "../../features/locked/components/LockPlanModal"; import DashButton from "../../shared/dash/DashButton"; import Spinner from "../../shared/dash/Spinner"; import FilterPills from "../../shared/dash/FilterPills"; @@ -24,6 +25,7 @@ export default function Locked() { const [filter, setFilter] = useState("All"); const [page, setPage] = useState(1); const [unlockTarget, setUnlockTarget] = useState(null); + const [planTarget, setPlanTarget] = useState(null); // Recompute maturity on a timer: a lock can cross its end_date while the page // is open, and nothing else would re-render the row. @@ -145,6 +147,7 @@ export default function Locked() { lock={l} nowSecs={nowSecs} onUnlock={setUnlockTarget} + onViewPlan={setPlanTarget} /> ))} @@ -168,6 +171,10 @@ export default function Locked() { onCancel={() => setUnlockTarget(null)} /> )} + + {planTarget && ( + setPlanTarget(null)} /> + )}
    ); } diff --git a/src/pages/dashboard/Targets.tsx b/src/pages/dashboard/Targets.tsx index 61f63ba..f471da4 100644 --- a/src/pages/dashboard/Targets.tsx +++ b/src/pages/dashboard/Targets.tsx @@ -13,6 +13,7 @@ import { import TargetRow from "../../features/targets/components/TargetRow"; import TopUpModal from "../../features/targets/components/TopUpModal"; import WithdrawModal from "../../features/targets/components/WithdrawModal"; +import GoalPlanModal from "../../features/targets/components/GoalPlanModal"; import DashButton from "../../shared/dash/DashButton"; import Spinner from "../../shared/dash/Spinner"; import FilterPills from "../../shared/dash/FilterPills"; @@ -52,6 +53,7 @@ export default function Targets() { const [page, setPage] = useState(1); const [topUpTarget, setTopUpTarget] = useState(null); const [withdrawTarget, setWithdrawTarget] = useState(null); + const [planTarget, setPlanTarget] = useState(null); const [goalYield, setGoalYield] = useState(null); // A goal can cross its deadline while the page is open; nothing else would @@ -197,7 +199,7 @@ export default function Targets() { goal={g} nowSecs={nowSecs} onTopUp={setTopUpTarget} - onWithdraw={openWithdraw} + onViewPlan={setPlanTarget} /> ))} @@ -224,6 +226,18 @@ export default function Targets() { /> )} + {planTarget && ( + { + setPlanTarget(null); + openWithdraw(g); + }} + onClose={() => setPlanTarget(null)} + /> + )} + {withdrawTarget && ( Date: Sat, 18 Jul 2026 05:35:22 +0100 Subject: [PATCH 2/2] fix(targets): hide Top Up on finished goals, show Withdraw instead Per review (#29): topping up a goal that's already met makes no sense. A goal is finished when its deadline has passed or the target is fully funded (targetStatus 'matured'); in that state the row now offers Withdraw rather than Top Up. It reads 'Withdraw early' (red) when the target was reached before the deadline, since the contract still forfeits 1% then, and 'Withdraw Now' (cyan) once the deadline has passed. In-progress goals keep Top Up. --- src/features/targets/components/TargetRow.tsx | 32 +++++++++++++++---- src/pages/dashboard/Targets.tsx | 1 + 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/features/targets/components/TargetRow.tsx b/src/features/targets/components/TargetRow.tsx index 38be91d..bc0b0a7 100644 --- a/src/features/targets/components/TargetRow.tsx +++ b/src/features/targets/components/TargetRow.tsx @@ -1,6 +1,7 @@ import type { TargetGoal } from "../hooks/useTargets"; import { frequencyOf, + isEarlyWithdrawal, progressPct, targetStatus, type TargetStatus, @@ -30,6 +31,7 @@ interface Props { goal: TargetGoal; nowSecs: number; onTopUp: (goal: TargetGoal) => void; + onWithdraw: (goal: TargetGoal) => void; onViewPlan: (goal: TargetGoal) => void; } @@ -97,12 +99,18 @@ export default function TargetRow({ goal, nowSecs, onTopUp, + onWithdraw, onViewPlan, }: Props) { const status = targetStatus(goal, nowSecs); if (status === "withdrawn") return ; const pct = progressPct(goal); + // "Finished" = deadline passed or target fully funded. Topping up a finished + // goal makes no sense, so it swaps Top Up for Withdraw (early ⇒ 1% forfeit, + // hence the red button). + const finished = status === "matured"; + const early = isEarlyWithdrawal(goal, nowSecs); return (
  • @@ -155,13 +163,23 @@ export default function TargetRow({
    - onTopUp(goal)} - className="max-sm:flex-1" - > - Top Up - + {finished ? ( + onWithdraw(goal)} + className="max-sm:flex-1" + > + {early ? "Withdraw early" : "Withdraw Now"} + + ) : ( + onTopUp(goal)} + className="max-sm:flex-1" + > + Top Up + + )}