import React, { useCallback, useMemo, useRef, useState } from 'react'; import { View, ActivityIndicator, Platform, Alert, TouchableOpacity, Text } from 'react-native'; import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { PlaybasisProvider, usePlaybasis } from './PlaybasisProvider'; import { useTheme } from './theme/context'; import { getQwikBadgeAssetUrl } from './data/qwik-badge-assets'; import { getWidgetHtml } from './web/widgetHtml'; import type { WidgetBootstrapData, WidgetBridgeMessage } from './web/widgetTypes'; export interface QwikCardAppProps { apiKey: string; tenantId: string; playerId: string; baseUrl?: string; leaderboardId?: string; requestTimeoutMs?: number; requestRetries?: number; } function AppContent({ leaderboardId }: { leaderboardId?: string }) { const theme = useTheme(); const { client, tenantId, playerId, baseUrl } = usePlaybasis(); const webViewRef = useRef(null); const [activeGame, setActiveGame] = useState<{ url: string; title?: string } | null>(null); const isQwikDark = theme.colors.background.toLowerCase() === '#0f1419'; const gameHeaderStyle = { flexDirection: 'row' as const, alignItems: 'center' as const, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: isQwikDark ? '#e2e8f0' : theme.colors.surface, backgroundColor: isQwikDark ? '#ffffff' : theme.colors.background, }; const gameHeaderTextColor = isQwikDark ? '#0f172a' : theme.colors.text.primary; const closeGame = () => { setActiveGame(null); webViewRef.current?.injectJavaScript( "window.PlaybasisWidget?.useWidgetStore?.getState?.()?.setActiveTab?.('play'); true;", ); }; const html = useMemo(() => getWidgetHtml(), []); const buildBootstrapPayload = useCallback(async (): Promise => { if (!playerId) { throw new Error('Missing playerId for widget bootstrap'); } const [player, balances, playerQuests, questCatalog, allBadges, playerBadges, rewards] = await Promise.all([ client.getPlayer(playerId), client.getBalances(playerId), client.getPlayerQuests(playerId), client.getQuests(), client.getBadges(), client.getPlayerBadges(playerId), client.getRewards().catch(() => []), // Gracefully handle if rewards endpoint doesn't exist ]); const xp = balances.find((b) => b.currency === 'xp')?.balance ?? 0; const qwikCoins = balances.find((b) => b.currency === 'qwik-coins')?.balance ?? 0; const leaderboardEntries = leaderboardId ? await (async () => { try { const res = await client.getLeaderboard(leaderboardId, { limit: 50 }); const normalized = res.entries.map((entry) => ({ userId: entry.playerId, rank: entry.rank, displayName: entry.displayName || 'Player', score: entry.score, isCurrentUser: entry.playerId === player.id, })); const hasCurrentUser = normalized.some((entry) => entry.isCurrentUser); if (!hasCurrentUser) { return [ { userId: player.id, rank: 0, displayName: player.displayName || 'You', score: xp, isCurrentUser: true, }, ...normalized, ]; } return normalized; } catch { return [ { userId: player.id, rank: 0, displayName: player.displayName || 'You', score: xp, isCurrentUser: true, }, ]; } })() : [ { userId: player.id, rank: 0, displayName: player.displayName || 'You', score: xp, isCurrentUser: true, }, ]; return { user: { userId: player.id, level: Math.max(1, Math.floor(xp / 1000) + 1), exp: xp, compositeScore: xp, positiveChangePct: 0, }, wallet: { currency: 'XP', balance: xp, earnedDelta: 0, qwikCoins, }, quests: (() => { const quests = playerQuests.length > 0 ? playerQuests : questCatalog; const nowIso = new Date().toISOString().slice(0, 10); return quests.map((q) => { const target = q.target ?? 1; const current = q.progress ?? 0; const progressPct = target > 0 ? Math.round((current / target) * 100) : 0; const status = q.status === 'completed' ? 'completed' : 'available'; return { questId: q.id, title: q.name, description: q.description, type: 'mission', target, current, progressPct, startDate: nowIso, endDate: q.expiresAt, status, rewards: { xp: q.rewards?.find((r) => r.type === 'points')?.amount || 100, badges: (q.rewards ?.filter((r) => r.type === 'badge') .map((r) => r.badgeId) .filter(Boolean) || []) as string[], }, }; }); })(), badges: allBadges.map((b) => ({ id: b.id, name: b.name, rarity: 'common', unlocked: playerBadges.some((pb) => pb.id === b.id && pb.isEarned), imageUrl: b.imageUrl || (b.slug ? getQwikBadgeAssetUrl(b.slug) : undefined), })), rewards: rewards.map((r) => ({ id: r.id, name: r.name, description: r.description || '', cost: r.cost || 0, imageUrl: r.imageUrl, })), activities: [], leaderboardEntries, tenantId, }; }, [client, leaderboardId, playerId, tenantId]); const handleMessage = useCallback( async (event: WebViewMessageEvent) => { let message: WidgetBridgeMessage; try { message = JSON.parse(event.nativeEvent.data) as WidgetBridgeMessage; } catch { return; } if (message.type === 'ERROR') { // Handle errors from WebView console.error('[QwikCardApp] WebView Error:', message.error); Alert.alert('Widget Error', message.error || 'An unknown error occurred in the widget.'); return; } if (message.type === 'OPEN_GAME') { setActiveGame({ url: message.url, title: message.title }); return; } if (message.type !== 'REQUEST') return; const sendResponse = (payload: WidgetBridgeMessage) => { const js = `window.__PB_HANDLE_NATIVE_MESSAGE__ && window.__PB_HANDLE_NATIVE_MESSAGE__(${JSON.stringify( payload, )}); true;`; webViewRef.current?.injectJavaScript(js); }; const requestId = message.requestId; if (!playerId) { sendResponse({ type: 'RESPONSE', requestId, ok: false, error: 'PlayerId is required for widget data requests', }); return; } try { switch (message.resource) { case 'bootstrap': { const data = await buildBootstrapPayload(); sendResponse({ type: 'RESPONSE', requestId, ok: true, data }); return; } default: { sendResponse({ type: 'RESPONSE', requestId, ok: false, error: `Unsupported resource: ${String((message as any).resource)}`, }); return; } } } catch (error) { sendResponse({ type: 'RESPONSE', requestId, ok: false, error: error instanceof Error ? error.message : 'Failed to process widget request', }); } }, [buildBootstrapPayload, playerId], ); const injectedJavaScript = useMemo(() => { // Seed initial config for the widget and establish a stable native->web message handler. // The web layer will immediately request `bootstrap` from native. const initPayload: WidgetBridgeMessage = { type: 'INIT', config: { tenantId, playerId: playerId ?? '', baseUrl, }, platform: Platform.OS, }; return `window.__PB_INIT__ = ${JSON.stringify(initPayload)}; true;`; }, [tenantId, playerId, baseUrl]); if (!playerId) { return ( ); } return ( ( )} // Reduce common RN/WebView UX papercuts. keyboardDisplayRequiresUserAction={false as any} setSupportMultipleWindows={false} // Avoid white flashes on iOS. style={{ backgroundColor: theme.colors.background }} /> {activeGame && ( ← Arcade {activeGame.title || 'Play'} )} ); } export function QwikCardApp(props: QwikCardAppProps) { return ( ); }