diff --git a/client/src/components/app.tsx b/client/src/components/app.tsx
index edfee8de..6472dc98 100644
--- a/client/src/components/app.tsx
+++ b/client/src/components/app.tsx
@@ -3,11 +3,16 @@ import { PlayerPage } from "./pages/player";
import { useProject } from "@/hooks/project";
import { MarketPage } from "./pages/market";
import { usePageTracking } from "@/hooks/usePageTracking";
+import { StarterpackClaimPage } from "./pages/starterpack-claim";
export function App() {
// Initialize page tracking
usePageTracking();
- const { player, collection } = useProject();
+ const { player, collection, starterpackId } = useProject();
+
+ if (starterpackId) {
+ return ;
+ }
if (player) {
return ;
diff --git a/client/src/components/pages/starterpack-claim.tsx b/client/src/components/pages/starterpack-claim.tsx
new file mode 100644
index 00000000..b564f5de
--- /dev/null
+++ b/client/src/components/pages/starterpack-claim.tsx
@@ -0,0 +1,30 @@
+import { Button } from "@cartridge/ui";
+import { useClaimViewModel } from "@/features/starterpack/useClaimViewModel";
+import { Loader2 } from "lucide-react";
+
+export function StarterpackClaimPage() {
+ const {
+ isConnected,
+ isLoading,
+ handleConnectAndClaim,
+ } = useClaimViewModel();
+
+ return (
+
+
+
+ );
+}
diff --git a/client/src/features/starterpack/useClaimViewModel.ts b/client/src/features/starterpack/useClaimViewModel.ts
new file mode 100644
index 00000000..1fa43a81
--- /dev/null
+++ b/client/src/features/starterpack/useClaimViewModel.ts
@@ -0,0 +1,166 @@
+import { useState, useCallback, useMemo } from "react";
+import { useAccount, useConnect } from "@starknet-react/core";
+import { useParams } from "@tanstack/react-router";
+import { useReferralAttribution } from "@/hooks/useReferralAttribution";
+
+/**
+ * Mock starterpack data structure
+ * TODO: Replace with actual data from contract in Phase 2
+ */
+interface StarterpackData {
+ id: string;
+ name: string;
+ description: string;
+ imageUri: string;
+ price: string;
+ paymentToken: string;
+}
+
+interface ClaimState {
+ isLoading: boolean;
+ error: string | null;
+ txHash?: string;
+ isSuccess: boolean;
+}
+
+/**
+ * View model hook for starterpack claiming page
+ *
+ * Manages:
+ * - Starterpack data fetching (mocked for now)
+ * - Wallet connection
+ * - Claim transaction flow
+ * - Referral attribution integration
+ */
+export function useClaimViewModel() {
+ const { starterpackId } = useParams({ from: "/starterpack/$starterpackId" });
+ const { account, isConnected, address } = useAccount();
+ const { connect, connectors } = useConnect();
+ const attribution = useReferralAttribution();
+
+ const [claimState, setClaimState] = useState({
+ isLoading: false,
+ error: null,
+ isSuccess: false,
+ });
+
+ // Mock starterpack data
+ // TODO: Phase 2 - Replace with actual contract query
+ const starterpack = useMemo((): StarterpackData | null => {
+ if (!starterpackId) return null;
+
+ return {
+ id: starterpackId,
+ name: `Starterpack #${starterpackId}`,
+ description:
+ "Get started with exclusive items, resources, and bonuses to jumpstart your adventure!",
+ imageUri: "https://via.placeholder.com/400x400?text=Starterpack",
+ price: "0.01",
+ paymentToken: "ETH",
+ };
+ }, [starterpackId]);
+
+ /**
+ * Handle wallet connection
+ */
+ const handleConnect = useCallback(async () => {
+ try {
+ setClaimState((prev) => ({ ...prev, isLoading: true, error: null }));
+
+ // Use the first available connector (typically Controller)
+ const connector = connectors[0];
+ if (!connector) {
+ throw new Error("No connector available");
+ }
+
+ await connect({ connector });
+
+ setClaimState((prev) => ({ ...prev, isLoading: false }));
+ } catch (error) {
+ console.error("Connection error:", error);
+ setClaimState({
+ isLoading: false,
+ error: error instanceof Error ? error.message : "Failed to connect wallet",
+ isSuccess: false,
+ });
+ }
+ }, [connect, connectors]);
+
+ /**
+ * Handle starterpack claim
+ * TODO: Phase 2 - Implement actual contract call
+ */
+ const handleClaim = useCallback(async () => {
+ if (!account || !starterpackId) {
+ console.error("Missing account or starterpack ID");
+ return;
+ }
+
+ try {
+ setClaimState({ isLoading: true, error: null, isSuccess: false });
+
+ // TODO: Phase 2 - Replace with actual contract call
+ // const { provider } = useArcade();
+ // const calls = provider.starterpack.issue({
+ // starterpackId: Number(starterpackId),
+ // recipient: address,
+ // referrer: attribution.referrer,
+ // referrerGroup: attribution.referrerGroup
+ // });
+ // const result = await account.execute(calls);
+
+ // Mock transaction delay
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ setClaimState({
+ isLoading: false,
+ error: null,
+ txHash: "0xmock_transaction_hash",
+ isSuccess: true,
+ });
+ } catch (error) {
+ console.error("Claim error:", error);
+ setClaimState({
+ isLoading: false,
+ error: error instanceof Error ? error.message : "Failed to claim starterpack",
+ isSuccess: false,
+ });
+ }
+ }, [account, starterpackId, address, attribution]);
+
+ /**
+ * Combined connect & claim action
+ */
+ const handleConnectAndClaim = useCallback(async () => {
+ if (!isConnected) {
+ await handleConnect();
+ // After connection, user needs to click claim again
+ // This prevents auto-claiming immediately after connection
+ return;
+ }
+
+ await handleClaim();
+ }, [isConnected, handleConnect, handleClaim]);
+
+ return {
+ // Data
+ starterpackId,
+ starterpack,
+ attribution: attribution.attribution,
+
+ // Connection state
+ isConnected,
+ address,
+
+ // Claim state
+ isLoading: claimState.isLoading,
+ error: claimState.error,
+ txHash: claimState.txHash,
+ isSuccess: claimState.isSuccess,
+
+ // Actions
+ handleConnect,
+ handleClaim,
+ handleConnectAndClaim,
+ };
+}
diff --git a/client/src/hooks/project.ts b/client/src/hooks/project.ts
index 6cef42b3..c6433ef2 100644
--- a/client/src/hooks/project.ts
+++ b/client/src/hooks/project.ts
@@ -26,6 +26,7 @@ interface RouteParams {
collection?: string;
tab?: string;
token?: string;
+ starterpack?: string;
}
export const parseRouteParams = (pathname: string): RouteParams => {
@@ -67,6 +68,12 @@ export const parseRouteParams = (pathname: string): RouteParams => {
index += 1;
}
break;
+ case "starterpack":
+ if (next) {
+ params.starterpack = next;
+ index += 1;
+ }
+ break;
default:
if (!params.tab && TAB_SEGMENTS.includes(segment as any)) {
params.tab = segment;
@@ -92,6 +99,7 @@ export const useProject = () => {
edition: editionParam,
player: playerParam,
collection: collectionParam,
+ starterpack: starterpackParam,
tab,
} = useMemo(
() => parseRouteParams(routerState.location.pathname),
@@ -177,6 +185,13 @@ export const useProject = () => {
return undefined;
}, [playerData, playerParam]);
+ const starterpackId = useMemo(() => {
+ if (!starterpackParam) return undefined;
+ // Parse starterpack ID as number
+ const id = Number.parseInt(starterpackParam, 10);
+ return Number.isNaN(id) ? undefined : starterpackParam;
+ }, [starterpackParam]);
+
return {
game,
edition,
@@ -184,5 +199,6 @@ export const useProject = () => {
filter,
collection,
tab,
+ starterpackId,
};
};
diff --git a/client/src/hooks/useReferralAttribution.ts b/client/src/hooks/useReferralAttribution.ts
new file mode 100644
index 00000000..5c4f85f6
--- /dev/null
+++ b/client/src/hooks/useReferralAttribution.ts
@@ -0,0 +1,44 @@
+import { useEffect, useState } from "react";
+import { useSearch } from "@tanstack/react-router";
+import {
+ captureAttribution,
+ getAttribution,
+ type ReferralAttribution,
+} from "@/lib/referral";
+
+/**
+ * Hook to manage referral attribution
+ *
+ * Auto-captures referral parameters from URL on mount and provides
+ * access to current attribution state.
+ *
+ * @returns Current attribution state and validity
+ */
+export function useReferralAttribution() {
+ const search = useSearch({ strict: false });
+ const [attribution, setAttribution] = useState(
+ null,
+ );
+
+ // Capture attribution on mount if URL params exist
+ useEffect(() => {
+ // Get search params from TanStack Router
+ const searchParams = new URLSearchParams(
+ typeof search === "object" ? (search as Record) : {},
+ );
+
+ // Attempt to capture attribution
+ captureAttribution(searchParams);
+
+ // Load current attribution state
+ const current = getAttribution();
+ setAttribution(current);
+ }, [search]);
+
+ return {
+ attribution,
+ hasAttribution: !!attribution,
+ referrer: attribution?.referrer,
+ referrerGroup: attribution?.referrerGroup,
+ };
+}
diff --git a/client/src/lib/referral.ts b/client/src/lib/referral.ts
new file mode 100644
index 00000000..dcc227d6
--- /dev/null
+++ b/client/src/lib/referral.ts
@@ -0,0 +1,111 @@
+/**
+ * Referral Attribution System
+ *
+ * Captures and manages referral attribution from URL parameters.
+ * Implements first-touch attribution with a 30-day window.
+ */
+
+const STORAGE_KEY = "arcade_referral_attribution";
+const ATTRIBUTION_WINDOW_DAYS = 30;
+const ATTRIBUTION_WINDOW_MS = ATTRIBUTION_WINDOW_DAYS * 24 * 60 * 60 * 1000;
+
+export interface ReferralAttribution {
+ referrer: string;
+ referrerGroup?: string;
+ attributionTimestamp: string;
+ attributionWindow: number;
+}
+
+/**
+ * Check if an attribution is still valid (within the attribution window)
+ */
+export function isAttributionValid(timestamp: string): boolean {
+ const attributionDate = new Date(timestamp);
+ const now = new Date();
+ const diff = now.getTime() - attributionDate.getTime();
+ return diff <= ATTRIBUTION_WINDOW_MS;
+}
+
+/**
+ * Get current referral attribution from localStorage
+ * Returns null if no attribution exists or if it has expired
+ */
+export function getAttribution(): ReferralAttribution | null {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (!stored) return null;
+
+ const attribution: ReferralAttribution = JSON.parse(stored);
+
+ // Check if attribution is still valid
+ if (!isAttributionValid(attribution.attributionTimestamp)) {
+ clearAttribution();
+ return null;
+ }
+
+ return attribution;
+ } catch (error) {
+ console.error("Error reading referral attribution:", error);
+ return null;
+ }
+}
+
+/**
+ * Capture referral attribution from URL search parameters
+ * Implements first-touch attribution - will not overwrite existing valid attribution
+ *
+ * @param searchParams - URLSearchParams from the current URL
+ * @returns true if attribution was captured, false if skipped (already exists)
+ */
+export function captureAttribution(searchParams: URLSearchParams): boolean {
+ try {
+ // Check if we already have valid attribution (first-touch model)
+ const existing = getAttribution();
+ if (existing) {
+ return false;
+ }
+
+ // Extract referral parameters
+ const referrer = searchParams.get("ref");
+ const referrerGroup = searchParams.get("ref_group");
+
+ // Only capture if ref parameter exists
+ if (!referrer) {
+ return false;
+ }
+
+ const attribution: ReferralAttribution = {
+ referrer,
+ referrerGroup: referrerGroup || undefined,
+ attributionTimestamp: new Date().toISOString(),
+ attributionWindow: ATTRIBUTION_WINDOW_MS,
+ };
+
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(attribution));
+ return true;
+ } catch (error) {
+ console.error("Error capturing referral attribution:", error);
+ return false;
+ }
+}
+
+/**
+ * Clear stored referral attribution
+ */
+export function clearAttribution(): void {
+ try {
+ localStorage.removeItem(STORAGE_KEY);
+ } catch (error) {
+ console.error("Error clearing referral attribution:", error);
+ }
+}
+
+/**
+ * Get attribution window information
+ */
+export function getAttributionWindowInfo() {
+ return {
+ windowDays: ATTRIBUTION_WINDOW_DAYS,
+ windowMs: ATTRIBUTION_WINDOW_MS,
+ };
+}
diff --git a/client/src/routeTree.gen.ts b/client/src/routeTree.gen.ts
index 1e68aafa..a6016878 100644
--- a/client/src/routeTree.gen.ts
+++ b/client/src/routeTree.gen.ts
@@ -15,6 +15,7 @@ import { Route as LeaderboardRouteImport } from './routes/leaderboard'
import { Route as GuildsRouteImport } from './routes/guilds'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as StarterpackStarterpackIdRouteImport } from './routes/starterpack/$starterpackId'
import { Route as PlayerPlayerRouteImport } from './routes/player/$player'
import { Route as GameGameRouteImport } from './routes/game/$game'
import { Route as CollectionCollectionRouteImport } from './routes/collection/$collection'
@@ -79,6 +80,12 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const StarterpackStarterpackIdRoute =
+ StarterpackStarterpackIdRouteImport.update({
+ id: '/starterpack/$starterpackId',
+ path: '/starterpack/$starterpackId',
+ getParentRoute: () => rootRouteImport,
+ } as any)
const PlayerPlayerRoute = PlayerPlayerRouteImport.update({
id: '/player/$player',
path: '/player/$player',
@@ -276,6 +283,7 @@ export interface FileRoutesByFullPath {
'/collection/$collection': typeof CollectionCollectionRouteWithChildren
'/game/$game': typeof GameGameRouteWithChildren
'/player/$player': typeof PlayerPlayerRouteWithChildren
+ '/starterpack/$starterpackId': typeof StarterpackStarterpackIdRoute
'/collection/$collection/activity': typeof CollectionCollectionActivityRoute
'/collection/$collection/holders': typeof CollectionCollectionHoldersRoute
'/collection/$collection/items': typeof CollectionCollectionItemsRoute
@@ -317,6 +325,7 @@ export interface FileRoutesByTo {
'/collection/$collection': typeof CollectionCollectionRouteWithChildren
'/game/$game': typeof GameGameRouteWithChildren
'/player/$player': typeof PlayerPlayerRouteWithChildren
+ '/starterpack/$starterpackId': typeof StarterpackStarterpackIdRoute
'/collection/$collection/activity': typeof CollectionCollectionActivityRoute
'/collection/$collection/holders': typeof CollectionCollectionHoldersRoute
'/collection/$collection/items': typeof CollectionCollectionItemsRoute
@@ -359,6 +368,7 @@ export interface FileRoutesById {
'/collection/$collection': typeof CollectionCollectionRouteWithChildren
'/game/$game': typeof GameGameRouteWithChildren
'/player/$player': typeof PlayerPlayerRouteWithChildren
+ '/starterpack/$starterpackId': typeof StarterpackStarterpackIdRoute
'/collection/$collection/activity': typeof CollectionCollectionActivityRoute
'/collection/$collection/holders': typeof CollectionCollectionHoldersRoute
'/collection/$collection/items': typeof CollectionCollectionItemsRoute
@@ -402,6 +412,7 @@ export interface FileRouteTypes {
| '/collection/$collection'
| '/game/$game'
| '/player/$player'
+ | '/starterpack/$starterpackId'
| '/collection/$collection/activity'
| '/collection/$collection/holders'
| '/collection/$collection/items'
@@ -443,6 +454,7 @@ export interface FileRouteTypes {
| '/collection/$collection'
| '/game/$game'
| '/player/$player'
+ | '/starterpack/$starterpackId'
| '/collection/$collection/activity'
| '/collection/$collection/holders'
| '/collection/$collection/items'
@@ -484,6 +496,7 @@ export interface FileRouteTypes {
| '/collection/$collection'
| '/game/$game'
| '/player/$player'
+ | '/starterpack/$starterpackId'
| '/collection/$collection/activity'
| '/collection/$collection/holders'
| '/collection/$collection/items'
@@ -526,6 +539,7 @@ export interface RootRouteChildren {
CollectionCollectionRoute: typeof CollectionCollectionRouteWithChildren
GameGameRoute: typeof GameGameRouteWithChildren
PlayerPlayerRoute: typeof PlayerPlayerRouteWithChildren
+ StarterpackStarterpackIdRoute: typeof StarterpackStarterpackIdRoute
GameGamePlayerPlayerRoute: typeof GameGamePlayerPlayerRouteWithChildren
GameGameEditionEditionCollectionCollectionRoute: typeof GameGameEditionEditionCollectionCollectionRouteWithChildren
}
@@ -574,6 +588,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/starterpack/$starterpackId': {
+ id: '/starterpack/$starterpackId'
+ path: '/starterpack/$starterpackId'
+ fullPath: '/starterpack/$starterpackId'
+ preLoaderRoute: typeof StarterpackStarterpackIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/player/$player': {
id: '/player/$player'
path: '/player/$player'
@@ -945,6 +966,7 @@ const rootRouteChildren: RootRouteChildren = {
CollectionCollectionRoute: CollectionCollectionRouteWithChildren,
GameGameRoute: GameGameRouteWithChildren,
PlayerPlayerRoute: PlayerPlayerRouteWithChildren,
+ StarterpackStarterpackIdRoute: StarterpackStarterpackIdRoute,
GameGamePlayerPlayerRoute: GameGamePlayerPlayerRouteWithChildren,
GameGameEditionEditionCollectionCollectionRoute:
GameGameEditionEditionCollectionCollectionRouteWithChildren,
diff --git a/client/src/routes/__root.tsx b/client/src/routes/__root.tsx
index 7fc968fa..b122e8fe 100644
--- a/client/src/routes/__root.tsx
+++ b/client/src/routes/__root.tsx
@@ -1,10 +1,28 @@
-import { Outlet, createRootRoute } from "@tanstack/react-router";
+import { Outlet, createRootRoute, useRouterState } from "@tanstack/react-router";
import { Template } from "@/components/template";
import { SonnerToaster } from "@cartridge/ui";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
+import { ThemeProvider } from "@/context/theme";
-export const Route = createRootRoute({
- component: () => (
+function RootComponent() {
+ const router = useRouterState();
+ const isStarterpackRoute = router.location.pathname.startsWith("/starterpack/");
+
+ if (isStarterpackRoute) {
+ return (
+ <>
+
+
+
+
+ {import.meta.env.DEV ? (
+
+ ) : null}
+ >
+ );
+ }
+
+ return (
<>
@@ -14,5 +32,9 @@ export const Route = createRootRoute({
) : null}
>
- ),
+ );
+}
+
+export const Route = createRootRoute({
+ component: RootComponent,
});
diff --git a/client/src/routes/starterpack/$starterpackId.tsx b/client/src/routes/starterpack/$starterpackId.tsx
new file mode 100644
index 00000000..a2733f07
--- /dev/null
+++ b/client/src/routes/starterpack/$starterpackId.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { StarterpackClaimPage } from "@/components/pages/starterpack-claim";
+
+export const Route = createFileRoute("/starterpack/$starterpackId")({
+ component: StarterpackClaimPage,
+});