Skip to content

Commit 598bba6

Browse files
committed
fix: client-only page to avoid prerender
1 parent 5bfe833 commit 598bba6

2 files changed

Lines changed: 348 additions & 339 deletions

File tree

app/ClientPage.tsx

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
"use client";
2+
3+
import Image from "next/image";
4+
import { useCallback, useEffect, useState } from "react";
5+
import { STACKS_TESTNET } from "@stacks/network";
6+
import {
7+
cvToValue,
8+
fetchCallReadOnlyFunction,
9+
principalCV,
10+
} from "@stacks/transactions";
11+
import styles from "./page.module.css";
12+
13+
const APP_NAME = "StackUp";
14+
const APP_ICON_PATH = "/icons/icon.png";
15+
16+
const CONTRACT_ADDRESS = "ST2022VXQ3E384AAHQ15KFFXVN3CY5G57HX3W1GBJ";
17+
const CONTRACT_NAME = "streak";
18+
19+
export default function ClientPage() {
20+
const [walletAddress, setWalletAddress] = useState<string>("");
21+
const [status, setStatus] = useState<string>("Not connected");
22+
const [error, setError] = useState<string>("");
23+
const [lastTxId, setLastTxId] = useState<string>("");
24+
const [streak, setStreak] = useState<number | null>(null);
25+
const [lastClaimDay, setLastClaimDay] = useState<number | null>(null);
26+
const [hasBadge, setHasBadge] = useState<boolean | null>(null);
27+
const [isLoading, setIsLoading] = useState<boolean>(false);
28+
const [theme, setTheme] = useState<"dark" | "light">("dark");
29+
30+
useEffect(() => {
31+
document.documentElement.dataset.theme = theme;
32+
}, [theme]);
33+
34+
useEffect(() => {
35+
const storedAddress = localStorage.getItem("stackup_wallet_address");
36+
if (storedAddress) {
37+
setWalletAddress(storedAddress);
38+
setStatus("Wallet connected");
39+
}
40+
}, []);
41+
42+
useEffect(() => {
43+
if (walletAddress) {
44+
localStorage.setItem("stackup_wallet_address", walletAddress);
45+
} else {
46+
localStorage.removeItem("stackup_wallet_address");
47+
}
48+
}, [walletAddress]);
49+
50+
const address = walletAddress;
51+
const shortAddress = address
52+
? `${address.slice(0, 6)}...${address.slice(-4)}`
53+
: "Not connected";
54+
55+
const connectWallet = async () => {
56+
setError("");
57+
setStatus("Opening wallet...");
58+
try {
59+
const { connect, request } = await import("@stacks/connect");
60+
const result = await connect({
61+
network: "testnet",
62+
});
63+
let nextAddress =
64+
result.addresses?.find((entry) => entry.address.startsWith("ST"))
65+
?.address ?? "";
66+
if (!nextAddress) {
67+
const rpcResult = await request("stx_getAddresses", {
68+
network: "testnet",
69+
});
70+
nextAddress =
71+
rpcResult.addresses?.find((entry) => entry.address.startsWith("ST"))
72+
?.address ?? "";
73+
}
74+
setWalletAddress(nextAddress);
75+
if (nextAddress) {
76+
setStatus("Wallet connected");
77+
} else {
78+
setStatus("Connected");
79+
setError(
80+
"No STX address found. Make sure Leather is unlocked and on testnet."
81+
);
82+
}
83+
} catch (err) {
84+
const message =
85+
err instanceof Error ? err.message : "Wallet connect failed.";
86+
setError(message);
87+
setStatus("Not connected");
88+
}
89+
};
90+
91+
const disconnectWallet = async () => {
92+
const { disconnect } = await import("@stacks/connect");
93+
disconnect();
94+
setWalletAddress("");
95+
setStatus("Not connected");
96+
};
97+
98+
const fetchOnChain = useCallback(async () => {
99+
if (!CONTRACT_ADDRESS.startsWith("ST")) {
100+
setError("Set the contract address before fetching on-chain data.");
101+
return;
102+
}
103+
104+
setIsLoading(true);
105+
setError("");
106+
107+
const sender = address || CONTRACT_ADDRESS;
108+
109+
try {
110+
const [streakCV, lastDayCV, badgeCV] = await Promise.all([
111+
fetchCallReadOnlyFunction({
112+
contractAddress: CONTRACT_ADDRESS,
113+
contractName: CONTRACT_NAME,
114+
functionName: "get-streak",
115+
functionArgs: [principalCV(sender)],
116+
network: STACKS_TESTNET,
117+
senderAddress: sender,
118+
}),
119+
fetchCallReadOnlyFunction({
120+
contractAddress: CONTRACT_ADDRESS,
121+
contractName: CONTRACT_NAME,
122+
functionName: "get-last-claim-day",
123+
functionArgs: [principalCV(sender)],
124+
network: STACKS_TESTNET,
125+
senderAddress: sender,
126+
}),
127+
fetchCallReadOnlyFunction({
128+
contractAddress: CONTRACT_ADDRESS,
129+
contractName: CONTRACT_NAME,
130+
functionName: "has-badge",
131+
functionArgs: [principalCV(sender)],
132+
network: STACKS_TESTNET,
133+
senderAddress: sender,
134+
}),
135+
]);
136+
137+
const streakValue = cvToValue(streakCV) as unknown;
138+
const lastDayValue = cvToValue(lastDayCV) as unknown;
139+
const badgeValue = cvToValue(badgeCV) as unknown;
140+
setStreak(
141+
typeof streakValue === "bigint"
142+
? Number(streakValue)
143+
: (streakValue as number)
144+
);
145+
setLastClaimDay(
146+
typeof lastDayValue === "bigint"
147+
? Number(lastDayValue)
148+
: (lastDayValue as number)
149+
);
150+
setHasBadge(Boolean(badgeValue));
151+
setStatus("On-chain data refreshed");
152+
} catch {
153+
setError("Failed to fetch on-chain data.");
154+
} finally {
155+
setIsLoading(false);
156+
}
157+
}, [address]);
158+
159+
useEffect(() => {
160+
if (address) {
161+
fetchOnChain();
162+
}
163+
}, [address, fetchOnChain]);
164+
165+
const claimStreak = async () => {
166+
setError("");
167+
setStatus("Submitting claim...");
168+
169+
try {
170+
const { openContractCall } = await import("@stacks/connect");
171+
openContractCall({
172+
contractAddress: CONTRACT_ADDRESS,
173+
contractName: CONTRACT_NAME,
174+
functionName: "claim",
175+
functionArgs: [],
176+
network: STACKS_TESTNET,
177+
appDetails: {
178+
name: APP_NAME,
179+
icon: new URL(APP_ICON_PATH, window.location.origin).toString(),
180+
},
181+
onFinish: (data) => {
182+
setLastTxId(data.txId ?? "");
183+
setStatus("Claim submitted");
184+
},
185+
onCancel: () => {
186+
setStatus("Claim cancelled");
187+
},
188+
});
189+
} catch {
190+
setError("Failed to open contract call.");
191+
setStatus("Claim failed");
192+
}
193+
};
194+
195+
return (
196+
<div className={styles.page}>
197+
<div className={styles.shell}>
198+
<header className={styles.header}>
199+
<div className={styles.brand}>
200+
<Image
201+
className={styles.logo}
202+
src={theme === "dark" ? "/logo/logo-dark.png" : "/logo/logo-light.png"}
203+
alt="StackUp logo"
204+
width={400}
205+
height={140}
206+
sizes="(max-width: 720px) 160px, 200px"
207+
priority
208+
style={{ height: "auto" }}
209+
/>
210+
<div className={styles.brandText}>Daily streaks on Stacks.</div>
211+
</div>
212+
<div className={styles.actions}>
213+
<button
214+
className={`${styles.button} ${styles.ghostButton}`}
215+
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
216+
>
217+
{theme === "dark" ? "Light Mode" : "Dark Mode"}
218+
</button>
219+
{address ? (
220+
<button
221+
className={`${styles.button} ${styles.ghostButton}`}
222+
onClick={disconnectWallet}
223+
>
224+
Disconnect
225+
</button>
226+
) : (
227+
<button className={styles.button} onClick={connectWallet}>
228+
Connect Wallet
229+
</button>
230+
)}
231+
</div>
232+
</header>
233+
234+
<section className={styles.hero}>
235+
<div>
236+
<div className={styles.headline}>
237+
Build the streak.
238+
<span> Claim daily.</span>
239+
</div>
240+
<p className={styles.lede}>
241+
StackUp tracks your daily claim on Stacks testnet. Claim once per
242+
day to build momentum and unlock your 7-day NFT badge.
243+
</p>
244+
<div className={styles.heroActions}>
245+
<button className={styles.button} onClick={claimStreak}>
246+
Claim Now
247+
</button>
248+
<button
249+
className={`${styles.button} ${styles.ghostButton}`}
250+
onClick={fetchOnChain}
251+
disabled={isLoading}
252+
>
253+
{isLoading ? "Refreshing..." : "Refresh On-Chain"}
254+
</button>
255+
</div>
256+
</div>
257+
<div className={styles.panel}>
258+
<div className={styles.panelHeader}>
259+
<h2>Wallet status</h2>
260+
<span className={styles.pill}>Testnet</span>
261+
</div>
262+
<div className={styles.stack}>
263+
<div className={styles.status}>
264+
Status: <span>{status}</span>
265+
</div>
266+
<div className={styles.field}>
267+
Connected address
268+
<code>{shortAddress}</code>
269+
</div>
270+
<div className={styles.field}>
271+
Current streak
272+
<code>{streak ?? "Not loaded"}</code>
273+
</div>
274+
<div className={styles.field}>
275+
Last claim day
276+
<code>{lastClaimDay ?? "Not loaded"}</code>
277+
</div>
278+
{lastTxId ? (
279+
<div className={styles.field}>
280+
Last tx id
281+
<code>{lastTxId}</code>
282+
</div>
283+
) : null}
284+
{error ? <div className={styles.danger}>{error}</div> : null}
285+
</div>
286+
</div>
287+
</section>
288+
289+
<section className={styles.panelGrid}>
290+
<div className={styles.panel}>
291+
<div className={styles.panelHeader}>
292+
<h2>Claim your streak</h2>
293+
<span className={styles.pill}>Daily</span>
294+
</div>
295+
<div className={styles.stack}>
296+
<div className={styles.field}>
297+
Contract
298+
<code>
299+
{CONTRACT_ADDRESS}.{CONTRACT_NAME}
300+
</code>
301+
</div>
302+
<div className={styles.footnote}>
303+
Deployed on Stacks testnet via Hiro Platform.
304+
</div>
305+
</div>
306+
</div>
307+
308+
<div className={styles.panel}>
309+
<div className={styles.panelHeader}>
310+
<h2>Badge milestone</h2>
311+
<span className={styles.pill}>NFT</span>
312+
</div>
313+
<div className={styles.stack}>
314+
<div className={styles.badgeRow}>
315+
<div className={styles.badge}>
316+
<strong>7</strong> day streak badge
317+
</div>
318+
</div>
319+
<div className={styles.status}>
320+
Badge status:{" "}
321+
<span
322+
className={
323+
hasBadge === null
324+
? ""
325+
: hasBadge
326+
? styles.success
327+
: styles.warn
328+
}
329+
>
330+
{hasBadge === null ? "Not loaded" : hasBadge ? "Earned" : "Not yet"}
331+
</span>
332+
</div>
333+
<div className={styles.footnote}>
334+
Badge mints are triggered by the on-chain <code>claim</code>{" "}
335+
function once your streak hits 7.
336+
</div>
337+
</div>
338+
</div>
339+
</section>
340+
</div>
341+
</div>
342+
);
343+
}

0 commit comments

Comments
 (0)