Skip to content

Commit fdb863d

Browse files
`feat(frontend): add wallet referral links and first-bet attribution
1 parent 7fb9e2b commit fdb863d

13 files changed

Lines changed: 765 additions & 270 deletions

frontend/src/app/layout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Metadata, Viewport } from "next";
2+
import { Suspense } from "react";
23
import "./globals.css";
34
import { BettingSlipProvider } from "../context/BettingSlipContext";
45
import { WalletProvider } from "../context/WalletContext";
@@ -10,6 +11,7 @@ import ThemeScript from "../components/ThemeScript";
1011
import OfflineBanner from "../components/OfflineBanner";
1112
import KeyboardShortcutsProvider from "../components/KeyboardShortcutsProvider";
1213
import I18nProvider from "../components/I18nProvider";
14+
import ReferralTracker from "../components/ReferralTracker";
1315
import { appViewport } from "./viewportConfig";
1416

1517
export const metadata: Metadata = {
@@ -39,6 +41,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
3941
<body>
4042
<SkipLink />
4143
<OfflineBanner />
44+
<Suspense fallback={null}>
45+
<ReferralTracker />
46+
</Suspense>
4247
{/* I18nProvider initialises i18next with dynamic JSON loading and browser locale detection */}
4348
<I18nProvider>
4449
<ReduxProvider>

frontend/src/app/profile/page.tsx

Lines changed: 152 additions & 190 deletions
Large diffs are not rendered by default.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"use client";
2+
3+
import { useEffect, useMemo, useState } from "react";
4+
import { buildReferralLink, generateReferralCode } from "../lib/referral";
5+
import { useToast } from "./ToastProvider";
6+
7+
interface ReferralSectionProps {
8+
walletAddress: string;
9+
referredUsers: number;
10+
totalBonusEarned: number;
11+
}
12+
13+
function formatBonusXlm(value: number): string {
14+
return `${new Intl.NumberFormat("en-US", {
15+
minimumFractionDigits: 2,
16+
maximumFractionDigits: 2,
17+
}).format(value)} XLM`;
18+
}
19+
20+
export default function ReferralSection({
21+
walletAddress,
22+
referredUsers,
23+
totalBonusEarned,
24+
}: ReferralSectionProps) {
25+
const { success, error } = useToast();
26+
const [copied, setCopied] = useState(false);
27+
28+
const referralCode = useMemo(() => generateReferralCode(walletAddress), [walletAddress]);
29+
const referralLink = useMemo(() => {
30+
if (typeof window === "undefined") return "";
31+
return buildReferralLink(window.location.origin, walletAddress);
32+
}, [walletAddress]);
33+
34+
useEffect(() => {
35+
if (!copied) return undefined;
36+
37+
const timer = window.setTimeout(() => setCopied(false), 2000);
38+
return () => window.clearTimeout(timer);
39+
}, [copied]);
40+
41+
async function handleCopyReferralLink() {
42+
if (!referralLink) return;
43+
44+
try {
45+
await navigator.clipboard.writeText(referralLink);
46+
setCopied(true);
47+
success("Referral link copied to clipboard.");
48+
} catch (copyError) {
49+
error(
50+
copyError instanceof Error ? copyError.message : "Could not copy referral link."
51+
);
52+
}
53+
}
54+
55+
return (
56+
<section className="rounded-3xl border border-indigo-500/20 bg-gradient-to-br from-gray-900 via-gray-900 to-indigo-950/40 p-6 shadow-xl">
57+
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
58+
<div className="space-y-3">
59+
<div>
60+
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-indigo-300">
61+
Referral
62+
</p>
63+
<h2 className="mt-2 text-2xl font-semibold text-white">Invite traders, earn bonuses</h2>
64+
</div>
65+
66+
<div className="space-y-2">
67+
<p className="text-sm text-gray-400">Referral code</p>
68+
<div className="inline-flex items-center rounded-2xl border border-indigo-400/30 bg-indigo-500/10 px-4 py-3 font-mono text-lg tracking-[0.24em] text-indigo-100">
69+
{referralCode}
70+
</div>
71+
</div>
72+
73+
<div className="space-y-2">
74+
<p className="text-sm text-gray-400">Referral link</p>
75+
<p className="break-all rounded-2xl border border-gray-800 bg-gray-950/80 px-4 py-3 text-sm text-gray-200">
76+
{referralLink || "Connect from the browser to generate your referral link."}
77+
</p>
78+
</div>
79+
80+
<button
81+
type="button"
82+
onClick={handleCopyReferralLink}
83+
className="inline-flex items-center justify-center rounded-2xl bg-indigo-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-indigo-400"
84+
>
85+
{copied ? "Referral Link Copied" : "Copy Referral Link"}
86+
</button>
87+
</div>
88+
89+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:min-w-[264px]">
90+
<div className="rounded-2xl border border-gray-800 bg-gray-950/70 p-4">
91+
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-500">
92+
Referred Users
93+
</p>
94+
<p className="mt-2 text-3xl font-semibold text-white tabular-nums">{referredUsers}</p>
95+
</div>
96+
97+
<div className="rounded-2xl border border-gray-800 bg-gray-950/70 p-4">
98+
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-500">
99+
Bonus Earned
100+
</p>
101+
<p className="mt-2 text-3xl font-semibold text-green-400 tabular-nums">
102+
{formatBonusXlm(totalBonusEarned)}
103+
</p>
104+
</div>
105+
</div>
106+
</div>
107+
</section>
108+
);
109+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
import { useSearchParams } from "next/navigation";
5+
import { persistReferralCode, REFERRAL_QUERY_PARAM } from "../lib/referral";
6+
7+
export default function ReferralTracker() {
8+
const searchParams = useSearchParams();
9+
10+
useEffect(() => {
11+
persistReferralCode(searchParams.get(REFERRAL_QUERY_PARAM));
12+
}, [searchParams]);
13+
14+
return null;
15+
}

frontend/src/components/TradeModal.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useState } from "react";
1010
import { useBettingSlip } from "../context/BettingSlipContext";
1111
import { useFormPersistence } from "../hooks/useFormPersistence";
1212
import { useTrustline } from "../hooks/useTrustline";
13+
import { buildBetRequestBody, finalizeReferralAttribution } from "../lib/referral";
1314
import TrustlineModal from "./TrustlineModal";
1415
import WhatIfSimulator from "./WhatIfSimulator";
1516
import Toast from "./Toast";
@@ -84,18 +85,20 @@ export default function TradeModal({ market, walletAddress, onBetPlaced, onConne
8485
setLoading(true);
8586
setMessage("");
8687
try {
88+
const requestBody = buildBetRequestBody({
89+
marketId: market.id,
90+
outcomeIndex: selectedOutcome,
91+
amount: stroops.toString(),
92+
walletAddress,
93+
});
8794
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/bets`, {
8895
method: "POST",
8996
headers: { "Content-Type": "application/json" },
90-
body: JSON.stringify({
91-
marketId: market.id,
92-
outcomeIndex: selectedOutcome,
93-
amount: stroops.toString(),
94-
walletAddress,
95-
}),
97+
body: JSON.stringify(requestBody),
9698
});
9799
const data = await res.json();
98100
if (!res.ok) throw new Error(data.error);
101+
finalizeReferralAttribution(walletAddress);
99102
setMessage("Bet placed successfully!");
100103
clearForm();
101104
onBetPlaced?.();

frontend/src/components/__tests__/OptimisticBets.test.tsx

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ import optimisticBetsReducer, {
1515
clearBet,
1616
} from "../../store/optimisticBetsSlice";
1717
import { useOptimisticBet } from "../../hooks/useOptimisticBet";
18+
import {
19+
REFERRAL_STORAGE_KEY,
20+
generateReferralCode,
21+
} from "../../lib/referral";
22+
23+
jest.mock("../../lib/stellar", () => ({
24+
validateStellarAddress: jest.fn(() => true),
25+
}));
1826

1927
// ── Helpers ──────────────────────────────────────────────────────────────────
2028

@@ -99,6 +107,7 @@ describe("optimisticBetsSlice", () => {
99107

100108
function HookHarness({ onResult }: { onResult: (r: boolean) => void }) {
101109
const { submitBet, optimisticBets } = useOptimisticBet();
110+
const walletAddress = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
102111

103112
return (
104113
<div>
@@ -117,7 +126,7 @@ function HookHarness({ onResult }: { onResult: (r: boolean) => void }) {
117126
outcomeIndex: 0,
118127
outcomeName: "Yes",
119128
amount: 100,
120-
walletAddress: "GTEST",
129+
walletAddress,
121130
},
122131
(reason) => onResult(false)
123132
).then(onResult)
@@ -142,7 +151,10 @@ function renderHook(onResult = jest.fn()) {
142151
}
143152

144153
describe("useOptimisticBet hook", () => {
145-
beforeEach(() => jest.resetAllMocks());
154+
beforeEach(() => {
155+
jest.resetAllMocks();
156+
localStorage.clear();
157+
});
146158

147159
it("optimistic add: bet appears immediately as pending", async () => {
148160
global.fetch = jest.fn(() => new Promise(() => {})) as any; // hang
@@ -216,7 +228,14 @@ describe("useOptimisticBet hook", () => {
216228
<button
217229
onClick={() =>
218230
submitBet(
219-
{ marketId: 2, marketTitle: "Q", outcomeIndex: 0, outcomeName: "Yes", amount: 10, walletAddress: "G" },
231+
{
232+
marketId: 2,
233+
marketTitle: "Q",
234+
outcomeIndex: 0,
235+
outcomeName: "Yes",
236+
amount: 10,
237+
walletAddress: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
238+
},
220239
onError
221240
)
222241
}
@@ -245,8 +264,22 @@ describe("useOptimisticBet hook", () => {
245264
<div data-testid="m1">{market1Bets.length}</div>
246265
<div data-testid="m2">{market2Bets.length}</div>
247266
<button onClick={() => {
248-
submitBet({ marketId: 1, marketTitle: "M1", outcomeIndex: 0, outcomeName: "Yes", amount: 10, walletAddress: "G" });
249-
submitBet({ marketId: 2, marketTitle: "M2", outcomeIndex: 0, outcomeName: "No", amount: 20, walletAddress: "G" });
267+
submitBet({
268+
marketId: 1,
269+
marketTitle: "M1",
270+
outcomeIndex: 0,
271+
outcomeName: "Yes",
272+
amount: 10,
273+
walletAddress: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
274+
});
275+
submitBet({
276+
marketId: 2,
277+
marketTitle: "M2",
278+
outcomeIndex: 0,
279+
outcomeName: "No",
280+
amount: 20,
281+
walletAddress: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
282+
});
250283
}}>go</button>
251284
</div>
252285
);
@@ -261,4 +294,74 @@ describe("useOptimisticBet hook", () => {
261294
expect(screen.getByTestId("m2").textContent).toBe("1");
262295
});
263296
});
297+
298+
it("includes the stored referral code only on the first successful bet for a wallet", async () => {
299+
const walletAddress = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
300+
const ownReferralCode = generateReferralCode(walletAddress);
301+
expect(ownReferralCode).toHaveLength(8);
302+
303+
localStorage.setItem(REFERRAL_STORAGE_KEY, "REF12345");
304+
305+
global.fetch = jest
306+
.fn()
307+
.mockResolvedValueOnce({
308+
ok: true,
309+
json: async () => ({ bet: { id: 1 } }),
310+
})
311+
.mockResolvedValueOnce({
312+
ok: true,
313+
json: async () => ({ bet: { id: 2 } }),
314+
}) as any;
315+
316+
function AttributionHarness() {
317+
const { submitBet } = useOptimisticBet();
318+
319+
return (
320+
<button
321+
onClick={async () => {
322+
await submitBet({
323+
marketId: 1,
324+
marketTitle: "Market A",
325+
outcomeIndex: 0,
326+
outcomeName: "Yes",
327+
amount: 10,
328+
walletAddress,
329+
});
330+
await submitBet({
331+
marketId: 2,
332+
marketTitle: "Market B",
333+
outcomeIndex: 1,
334+
outcomeName: "No",
335+
amount: 12,
336+
walletAddress,
337+
});
338+
}}
339+
>
340+
attribute
341+
</button>
342+
);
343+
}
344+
345+
const store = configureStore({ reducer: { optimisticBets: optimisticBetsReducer } });
346+
render(
347+
<Provider store={store}>
348+
<AttributionHarness />
349+
</Provider>
350+
);
351+
352+
act(() => {
353+
screen.getByRole("button", { name: "attribute" }).click();
354+
});
355+
356+
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(2));
357+
358+
const firstRequest = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
359+
const secondRequest = JSON.parse((global.fetch as jest.Mock).mock.calls[1][1].body);
360+
361+
expect(firstRequest.referralCode).toBe("REF12345");
362+
expect(secondRequest.referralCode).toBeUndefined();
363+
expect(
364+
localStorage.getItem(`stella.referral.attributed:${walletAddress.toUpperCase()}`)
365+
).toBe("true");
366+
});
264367
});

0 commit comments

Comments
 (0)