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
85 changes: 85 additions & 0 deletions src/features/locked/components/LockPlanModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DashModal open onClose={onClose} labelledBy="lock-plan-title">
<h2
id="lock-plan-title"
className="text-center font-heading text-[26px] font-bold text-[#eef0f7]"
>
View Plan
</h2>
<p className="mt-2 text-center font-body text-[14.5px] text-muted">
Here are more details about your locked plan.
</p>

<dl className="mt-7 rounded-xl border border-[#ffffff14] bg-[#ffffff0a] px-6 py-2">
<Row
label="Principal locked"
value={`${formatUsdc(lock.amount)} USDC`}
/>
<Row label="Term" value={`${months} month${months === 1 ? "" : "s"}`} />
<div className="flex items-center justify-between py-5">
<dt className="font-body text-[14.5px] text-muted">APY Tier</dt>
<dd>
<span className="rounded-full border border-[#34E0A16b] bg-[#34E0A114] px-3 py-1.5 font-body text-[13.5px] font-semibold text-[#34E0A1]">
{(lock.apy_basis_points / 100).toFixed(
lock.apy_basis_points % 100 === 0 ? 0 : 1,
)}
% APY
</span>
</dd>
</div>
<Row label="Unlocks On" value={formatDate(lock.end_date)} />
<Row
label="Projected yield (gross)"
value={`+${formatUsdc(lock.projected_yield)} USDC`}
/>
<div className="flex items-center justify-between py-5">
<dt className="font-body text-[14.5px] text-muted">
You receive at maturity
</dt>
<dd className="font-body text-[15px] font-bold text-cyan">
{formatUsdc(receive)} USDC
</dd>
</div>
</dl>

<DashButton
variant="secondary"
onClick={onClose}
className="mt-7 w-full py-3.5"
>
Dismiss
</DashButton>
</DashModal>
);
}

function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between py-5">
<dt className="font-body text-[14.5px] text-muted">{label}</dt>
<dd className="font-body text-[15px] font-bold text-[#eef0f7]">
{value}
</dd>
</div>
);
}
23 changes: 18 additions & 5 deletions src/features/locked/components/LockRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -102,24 +108,31 @@ export default function LockRow({ lock, nowSecs, onUnlock }: Props) {
</div>
</div>

<div className="shrink-0">
<div className="flex shrink-0 items-center gap-4 max-sm:justify-between">
{status === "matured" ? (
<DashButton
variant="primary"
onClick={() => onUnlock(lock)}
className="w-full sm:w-auto"
className="max-sm:flex-1"
>
Unlock Now
</DashButton>
) : status === "locked" ? (
<DashButton variant="muted" disabled className="w-full sm:w-auto">
<DashButton variant="muted" disabled className="max-sm:flex-1">
Locked · {timeLeftLabel(lock, nowSecs)}
</DashButton>
) : (
<DashButton variant="muted" disabled className="w-full sm:w-auto">
<DashButton variant="muted" disabled className="max-sm:flex-1">
Withdrawn
</DashButton>
)}
<button
type="button"
onClick={() => onViewPlan(lock)}
className="shrink-0 font-body text-[14px] font-semibold text-cyan underline underline-offset-4 transition hover:brightness-110"
>
View Plan
</button>
</div>
</li>
);
Expand Down
103 changes: 103 additions & 0 deletions src/features/targets/components/GoalPlanModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DashModal open onClose={onClose} labelledBy="goal-plan-title">
<h2
id="goal-plan-title"
className="text-center font-heading text-[26px] font-bold text-[#eef0f7]"
>
View Plan
</h2>
<p className="mt-2 text-center font-body text-[14.5px] text-muted">
Here are more details about your target savings.
</p>

<dl className="mt-7 rounded-xl border border-[#ffffff14] bg-[#ffffff0a] px-6 py-2">
<Row label="Goal" value={goal.name} />
<Row
label="Goal Amount"
value={`${formatUsdc(goal.target_amount)} USDC`}
/>
<Row
label="Saved so far"
value={`${formatUsdcAdaptive(goal.deposited)} USDC`}
/>
<Row label="Deadline" value={formatDate(goal.end_date)} />
<Row label="Frequency" value={frequencyOf(goal.period_seconds)} />
<div className="flex items-center justify-between gap-4 py-5">
<dt className="font-body text-[14.5px] text-muted">
Per deposit amount
</dt>
<dd className="text-right font-body text-[15px] font-bold text-cyan">
{formatUsdc(goal.period_amount)} USDC
</dd>
</div>
</dl>

<div className="mt-7 flex flex-col gap-3">
{!withdrawn && (
<DashButton
variant={early ? "danger" : "primary"}
onClick={() => onWithdraw(goal)}
className="w-full py-3.5"
>
{early ? "Withdraw early" : "Withdraw Now"}
</DashButton>
)}
<DashButton
variant="secondary"
onClick={onClose}
className="w-full py-3.5"
>
Dismiss
</DashButton>
</div>
</DashModal>
);
}

function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-4 py-5">
<dt className="font-body text-[14.5px] text-muted">{label}</dt>
<dd className="text-right font-body text-[15px] font-bold break-words text-[#eef0f7]">
{value}
</dd>
</div>
);
}
52 changes: 23 additions & 29 deletions src/features/targets/components/TargetRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface Props {
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
Expand Down Expand Up @@ -99,16 +100,16 @@ export default function TargetRow({
nowSecs,
onTopUp,
onWithdraw,
onViewPlan,
}: Props) {
const status = targetStatus(goal, nowSecs);
if (status === "withdrawn") return <WithdrawnRow goal={goal} />;

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".
// "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 (
Expand Down Expand Up @@ -161,38 +162,31 @@ export default function TargetRow({
</div>
</div>

<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-center">
{matured ? (
<div className="flex shrink-0 items-center gap-4 max-sm:justify-between">
{finished ? (
<DashButton
variant={early ? "danger" : "primary"}
onClick={() => onWithdraw(goal)}
title={
early
? `The contract charges the 1% forfeit until ${formatDateShort(goal.end_date)}.`
: undefined
}
className="w-full sm:w-auto"
className="max-sm:flex-1"
>
{early ? "Withdraw early" : "Withdraw Now"}
</DashButton>
) : (
<>
<DashButton
variant="secondary"
onClick={() => onTopUp(goal)}
className="w-full sm:w-auto"
>
Top Up
</DashButton>
<DashButton
variant="danger"
onClick={() => onWithdraw(goal)}
className="w-full sm:w-auto"
>
Withdraw early
</DashButton>
</>
<DashButton
variant="secondary"
onClick={() => onTopUp(goal)}
className="max-sm:flex-1"
>
Top Up
</DashButton>
)}
<button
type="button"
onClick={() => onViewPlan(goal)}
className="shrink-0 font-body text-[14px] font-semibold text-cyan underline underline-offset-4 transition hover:brightness-110"
>
View Plan
</button>
</div>
</li>
);
Expand Down
7 changes: 7 additions & 0 deletions src/pages/dashboard/Locked.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { lockStatus } from "../../features/locked/lib/lockMath";
import LockRow from "../../features/locked/components/LockRow";
import ProjectedGrowth from "../../features/locked/components/ProjectedGrowth";
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";
Expand All @@ -25,6 +26,7 @@ export default function Locked() {
const [filter, setFilter] = useState<Filter>("All");
const [page, setPage] = useState(1);
const [unlockTarget, setUnlockTarget] = useState<Lock | null>(null);
const [planTarget, setPlanTarget] = useState<Lock | null>(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.
Expand Down Expand Up @@ -152,6 +154,7 @@ export default function Locked() {
lock={l}
nowSecs={nowSecs}
onUnlock={setUnlockTarget}
onViewPlan={setPlanTarget}
/>
))}
</ul>
Expand All @@ -175,6 +178,10 @@ export default function Locked() {
onCancel={() => setUnlockTarget(null)}
/>
)}

{planTarget && (
<LockPlanModal lock={planTarget} onClose={() => setPlanTarget(null)} />
)}
</div>
);
}
Expand Down
Loading
Loading