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 ( <>