Skip to content

Commit 308c690

Browse files
authored
Merge pull request #204 from zakkiyyat/feat/166-169
feat: ROI calculator, property badges, view toggle, tx retry
2 parents 78fdbfe + 11be35c commit 308c690

4 files changed

Lines changed: 257 additions & 0 deletions

File tree

src/components/PropertyBadge.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
const ONE_DAY_MS = 86_400_000;
2+
const SEVEN_DAYS_MS = 7 * ONE_DAY_MS;
3+
4+
export type BadgeType = "New" | "Hot" | "Limited" | "Sold Out" | "Verified";
5+
6+
interface BadgeProps {
7+
type: BadgeType;
8+
}
9+
10+
const BADGE_STYLES: Record<BadgeType, string> = {
11+
New: "bg-blue-100 text-blue-700",
12+
Hot: "bg-red-100 text-red-700",
13+
Limited: "bg-orange-100 text-orange-700",
14+
"Sold Out": "bg-gray-200 text-gray-600",
15+
Verified: "bg-green-100 text-green-700",
16+
};
17+
18+
export function PropertyBadge({ type }: BadgeProps) {
19+
return (
20+
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${BADGE_STYLES[type]}`}>
21+
{type}
22+
</span>
23+
);
24+
}
25+
26+
interface PropertyBadgesProps {
27+
listedAt: Date;
28+
purchaseVolume24h: number;
29+
tokensRemaining: number;
30+
totalTokens: number;
31+
isVerified: boolean;
32+
}
33+
34+
export function resolvePropertyBadges({
35+
listedAt,
36+
purchaseVolume24h,
37+
tokensRemaining,
38+
totalTokens,
39+
isVerified,
40+
}: PropertyBadgesProps): BadgeType[] {
41+
const badges: BadgeType[] = [];
42+
const age = Date.now() - listedAt.getTime();
43+
44+
if (tokensRemaining === 0) {
45+
badges.push("Sold Out");
46+
} else {
47+
if (age <= SEVEN_DAYS_MS) badges.push("New");
48+
if (purchaseVolume24h > 50) badges.push("Hot");
49+
if (tokensRemaining / totalTokens < 0.1) badges.push("Limited");
50+
}
51+
52+
if (isVerified) badges.push("Verified");
53+
return badges;
54+
}
55+
56+
export function PropertyBadgeList(props: PropertyBadgesProps) {
57+
const badges = resolvePropertyBadges(props);
58+
if (!badges.length) return null;
59+
return (
60+
<div className="flex flex-wrap gap-1">
61+
{badges.map((b) => <PropertyBadge key={b} type={b} />)}
62+
</div>
63+
);
64+
}

src/components/ROICalculator.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useState } from "react";
2+
3+
interface ROIResult {
4+
totalReturn: number;
5+
annualYield: number;
6+
breakEvenMonths: number;
7+
}
8+
9+
const SP500_ANNUAL_RATE = 0.1;
10+
11+
function calcROI(amount: number, months: number, annualRate = 0.08): ROIResult {
12+
const years = months / 12;
13+
const totalReturn = amount * Math.pow(1 + annualRate, years) - amount;
14+
const annualYield = annualRate * 100;
15+
const breakEvenMonths = Math.ceil(Math.log(2) / Math.log(1 + annualRate) * 12);
16+
return { totalReturn, annualYield, breakEvenMonths };
17+
}
18+
19+
export default function ROICalculator() {
20+
const [amount, setAmount] = useState(10000);
21+
const [months, setMonths] = useState(12);
22+
23+
const roi = calcROI(amount, months);
24+
const sp500 = calcROI(amount, months, SP500_ANNUAL_RATE);
25+
26+
return (
27+
<div className="p-4 border rounded-xl space-y-4">
28+
<h3 className="font-semibold text-lg">ROI Calculator</h3>
29+
30+
<div className="flex gap-4">
31+
<label className="flex flex-col text-sm">
32+
Investment (USD)
33+
<input
34+
type="number"
35+
value={amount}
36+
onChange={(e) => setAmount(Number(e.target.value))}
37+
className="border rounded px-2 py-1 mt-1"
38+
/>
39+
</label>
40+
<label className="flex flex-col text-sm">
41+
Holding Period (months)
42+
<input
43+
type="number"
44+
value={months}
45+
onChange={(e) => setMonths(Number(e.target.value))}
46+
className="border rounded px-2 py-1 mt-1"
47+
/>
48+
</label>
49+
</div>
50+
51+
<div className="grid grid-cols-3 gap-2 text-center text-sm">
52+
<div className="bg-green-50 rounded p-2">
53+
<p className="font-medium">Total Return</p>
54+
<p className="text-green-700">${roi.totalReturn.toFixed(2)}</p>
55+
</div>
56+
<div className="bg-blue-50 rounded p-2">
57+
<p className="font-medium">Annual Yield</p>
58+
<p className="text-blue-700">{roi.annualYield.toFixed(1)}%</p>
59+
</div>
60+
<div className="bg-yellow-50 rounded p-2">
61+
<p className="font-medium">Break-even</p>
62+
<p className="text-yellow-700">{roi.breakEvenMonths}mo</p>
63+
</div>
64+
</div>
65+
66+
<p className="text-xs text-gray-500">
67+
S&P 500 equivalent return: <strong>${sp500.totalReturn.toFixed(2)}</strong>
68+
</p>
69+
</div>
70+
);
71+
}

src/components/ViewToggle.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useState, useEffect } from "react";
2+
3+
export type ViewMode = "grid" | "list";
4+
5+
const STORAGE_KEY = "propchain:listing-view";
6+
7+
export function useViewMode() {
8+
const [mode, setMode] = useState<ViewMode>(() => {
9+
if (typeof window === "undefined") return "grid";
10+
return (localStorage.getItem(STORAGE_KEY) as ViewMode) ?? "grid";
11+
});
12+
13+
useEffect(() => {
14+
localStorage.setItem(STORAGE_KEY, mode);
15+
}, [mode]);
16+
17+
return { mode, setMode };
18+
}
19+
20+
interface ViewToggleProps {
21+
mode: ViewMode;
22+
onChange: (mode: ViewMode) => void;
23+
}
24+
25+
export function ViewToggle({ mode, onChange }: ViewToggleProps) {
26+
return (
27+
<div className="flex border rounded-lg overflow-hidden text-sm">
28+
<button
29+
onClick={() => onChange("grid")}
30+
className={`px-3 py-1.5 flex items-center gap-1 ${mode === "grid" ? "bg-indigo-600 text-white" : "text-gray-600 hover:bg-gray-100"}`}
31+
aria-pressed={mode === "grid"}
32+
>
33+
<GridIcon /> Grid
34+
</button>
35+
<button
36+
onClick={() => onChange("list")}
37+
className={`px-3 py-1.5 flex items-center gap-1 ${mode === "list" ? "bg-indigo-600 text-white" : "text-gray-600 hover:bg-gray-100"}`}
38+
aria-pressed={mode === "list"}
39+
>
40+
<ListIcon /> List
41+
</button>
42+
</div>
43+
);
44+
}
45+
46+
function GridIcon() {
47+
return (
48+
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
49+
<rect x="0" y="0" width="6" height="6" rx="1" />
50+
<rect x="8" y="0" width="6" height="6" rx="1" />
51+
<rect x="0" y="8" width="6" height="6" rx="1" />
52+
<rect x="8" y="8" width="6" height="6" rx="1" />
53+
</svg>
54+
);
55+
}
56+
57+
function ListIcon() {
58+
return (
59+
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
60+
<rect x="0" y="1" width="14" height="2" rx="1" />
61+
<rect x="0" y="6" width="14" height="2" rx="1" />
62+
<rect x="0" y="11" width="14" height="2" rx="1" />
63+
</svg>
64+
);
65+
}

src/hooks/useTxRetry.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useState, useCallback } from "react";
2+
3+
const MAX_RETRIES = 3;
4+
const RETRYABLE_CODES = new Set(["NETWORK_ERROR", "TIMEOUT", "UNPREDICTABLE_GAS_LIMIT"]);
5+
6+
type TxStatus = "idle" | "pending" | "success" | "failed";
7+
8+
interface UseTxRetryOptions {
9+
onSuccess?: (hash: string) => void;
10+
onFailure?: (error: Error) => void;
11+
}
12+
13+
export function useTxRetry(
14+
sendTx: (gasMultiplier: number) => Promise<string>,
15+
options: UseTxRetryOptions = {}
16+
) {
17+
const [status, setStatus] = useState<TxStatus>("idle");
18+
const [error, setError] = useState<string | null>(null);
19+
const [attempts, setAttempts] = useState(0);
20+
21+
const execute = useCallback(
22+
async (retryCount = 0) => {
23+
setStatus("pending");
24+
setError(null);
25+
const gasMultiplier = 1 + retryCount * 0.2; // bump gas 20% per retry
26+
27+
try {
28+
const hash = await sendTx(gasMultiplier);
29+
setStatus("success");
30+
setAttempts(0);
31+
options.onSuccess?.(hash);
32+
} catch (err: unknown) {
33+
const e = err as { code?: string; message?: string };
34+
const isRetryable = e.code ? RETRYABLE_CODES.has(e.code) : true;
35+
const nextAttempt = retryCount + 1;
36+
37+
if (isRetryable && nextAttempt < MAX_RETRIES) {
38+
setAttempts(nextAttempt);
39+
setStatus("failed");
40+
setError(`Transaction failed: ${e.message ?? "unknown error"}. Retry ${nextAttempt}/${MAX_RETRIES} available.`);
41+
} else {
42+
setStatus("failed");
43+
setAttempts(0);
44+
const finalError = new Error(e.message ?? "Transaction failed");
45+
setError(isRetryable ? "Max retries reached." : `Non-retryable error: ${e.message}`);
46+
options.onFailure?.(finalError);
47+
}
48+
}
49+
},
50+
[sendTx, options]
51+
);
52+
53+
const retry = useCallback(() => execute(attempts), [execute, attempts]);
54+
const canRetry = status === "failed" && attempts > 0 && attempts < MAX_RETRIES;
55+
56+
return { status, error, canRetry, attempts, execute: () => execute(0), retry };
57+
}

0 commit comments

Comments
 (0)