@playbasis-ai/qwikcard-sdk 2.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -0
- package/LICENSE +21 -0
- package/README.md +267 -0
- package/SDK_HANDOVER_GUIDE.md +129 -0
- package/dist/PlaybasisProvider.d.ts +19 -0
- package/dist/PlaybasisProvider.d.ts.map +1 -0
- package/dist/PlaybasisProvider.js +24 -0
- package/dist/QwikCardApp.d.ts +9 -0
- package/dist/QwikCardApp.d.ts.map +1 -0
- package/dist/QwikCardApp.js +210 -0
- package/dist/api/client.d.ts +66 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +196 -0
- package/dist/components/Badge.d.ts +8 -0
- package/dist/components/Badge.d.ts.map +1 -0
- package/dist/components/Badge.js +34 -0
- package/dist/components/BadgeIcon.d.ts +10 -0
- package/dist/components/BadgeIcon.d.ts.map +1 -0
- package/dist/components/BadgeIcon.js +51 -0
- package/dist/components/Button.d.ts +10 -0
- package/dist/components/Button.d.ts.map +1 -0
- package/dist/components/Button.js +40 -0
- package/dist/components/GradientCard.d.ts +9 -0
- package/dist/components/GradientCard.d.ts.map +1 -0
- package/dist/components/GradientCard.js +28 -0
- package/dist/components/PointsBalance.d.ts +8 -0
- package/dist/components/PointsBalance.d.ts.map +1 -0
- package/dist/components/PointsBalance.js +85 -0
- package/dist/components/ProgressBar.d.ts +8 -0
- package/dist/components/ProgressBar.d.ts.map +1 -0
- package/dist/components/ProgressBar.js +41 -0
- package/dist/components/QuestProgress.d.ts +10 -0
- package/dist/components/QuestProgress.d.ts.map +1 -0
- package/dist/components/QuestProgress.js +94 -0
- package/dist/components/RadialGauge.d.ts +8 -0
- package/dist/components/RadialGauge.d.ts.map +1 -0
- package/dist/components/RadialGauge.js +53 -0
- package/dist/components/RewardCard.d.ts +10 -0
- package/dist/components/RewardCard.d.ts.map +1 -0
- package/dist/components/RewardCard.js +64 -0
- package/dist/components/RulesModal.d.ts +7 -0
- package/dist/components/RulesModal.d.ts.map +1 -0
- package/dist/components/RulesModal.js +106 -0
- package/dist/hooks/useBadges.d.ts +12 -0
- package/dist/hooks/useBadges.d.ts.map +1 -0
- package/dist/hooks/useBadges.js +42 -0
- package/dist/hooks/usePoints.d.ts +13 -0
- package/dist/hooks/usePoints.d.ts.map +1 -0
- package/dist/hooks/usePoints.js +70 -0
- package/dist/hooks/useQuests.d.ts +12 -0
- package/dist/hooks/useQuests.d.ts.map +1 -0
- package/dist/hooks/useQuests.js +41 -0
- package/dist/hooks/useQwikApp.d.ts +16 -0
- package/dist/hooks/useQwikApp.d.ts.map +1 -0
- package/dist/hooks/useQwikApp.js +42 -0
- package/dist/hooks/useRewards.d.ts +12 -0
- package/dist/hooks/useRewards.d.ts.map +1 -0
- package/dist/hooks/useRewards.js +56 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/theme/context.d.ts +8 -0
- package/dist/theme/context.d.ts.map +1 -0
- package/dist/theme/context.js +8 -0
- package/dist/theme/tokens.d.ts +35 -0
- package/dist/theme/tokens.d.ts.map +1 -0
- package/dist/theme/tokens.js +33 -0
- package/dist/types/index.d.ts +119 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +4 -0
- package/dist/web/widgetAssets.d.ts +4 -0
- package/dist/web/widgetAssets.d.ts.map +1 -0
- package/dist/web/widgetAssets.js +5 -0
- package/dist/web/widgetHtml.d.ts +2 -0
- package/dist/web/widgetHtml.d.ts.map +1 -0
- package/dist/web/widgetHtml.js +299 -0
- package/dist/web/widgetTypes.d.ts +128 -0
- package/dist/web/widgetTypes.d.ts.map +1 -0
- package/dist/web/widgetTypes.js +1 -0
- package/package.json +86 -0
- package/src/PlaybasisProvider.tsx +72 -0
- package/src/QwikCardApp.tsx +302 -0
- package/src/api/client.ts +307 -0
- package/src/components/Badge.tsx +51 -0
- package/src/components/BadgeIcon.tsx +97 -0
- package/src/components/Button.tsx +70 -0
- package/src/components/GradientCard.tsx +49 -0
- package/src/components/PointsBalance.tsx +122 -0
- package/src/components/ProgressBar.tsx +65 -0
- package/src/components/QuestProgress.tsx +153 -0
- package/src/components/RadialGauge.tsx +101 -0
- package/src/components/RewardCard.tsx +123 -0
- package/src/components/RulesModal.tsx +171 -0
- package/src/hooks/useBadges.ts +59 -0
- package/src/hooks/usePoints.ts +91 -0
- package/src/hooks/useQuests.ts +60 -0
- package/src/hooks/useQwikApp.ts +49 -0
- package/src/hooks/useRewards.ts +74 -0
- package/src/index.ts +34 -0
- package/src/theme/context.tsx +17 -0
- package/src/theme/tokens.ts +68 -0
- package/src/types/index.ts +176 -0
- package/src/web/widgetAssets.d.ts +3 -0
- package/src/web/widgetAssets.ts +6 -0
- package/src/web/widgetHtml.ts +302 -0
- package/src/web/widgetTypes.ts +146 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { View, ActivityIndicator, Platform, Alert, TouchableOpacity, Text } from 'react-native';
|
|
3
|
+
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
|
4
|
+
|
|
5
|
+
import { PlaybasisProvider, usePlaybasis } from './PlaybasisProvider';
|
|
6
|
+
import { useTheme } from './theme/context';
|
|
7
|
+
import { getWidgetHtml } from './web/widgetHtml';
|
|
8
|
+
import type { WidgetBootstrapData, WidgetBridgeMessage } from './web/widgetTypes';
|
|
9
|
+
|
|
10
|
+
export interface QwikCardAppProps {
|
|
11
|
+
apiKey: string;
|
|
12
|
+
tenantId: string;
|
|
13
|
+
playerId: string;
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
leaderboardId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function AppContent({ leaderboardId }: { leaderboardId?: string }) {
|
|
19
|
+
const theme = useTheme();
|
|
20
|
+
const { client, tenantId, playerId, baseUrl } = usePlaybasis();
|
|
21
|
+
|
|
22
|
+
const webViewRef = useRef<WebView>(null);
|
|
23
|
+
const [activeGame, setActiveGame] = useState<{ url: string; title?: string } | null>(null);
|
|
24
|
+
const isQwikDark = theme.colors.background.toLowerCase() === '#0f1419';
|
|
25
|
+
const gameHeaderStyle = {
|
|
26
|
+
flexDirection: 'row' as const,
|
|
27
|
+
alignItems: 'center' as const,
|
|
28
|
+
paddingHorizontal: 16,
|
|
29
|
+
paddingVertical: 12,
|
|
30
|
+
borderBottomWidth: 1,
|
|
31
|
+
borderBottomColor: isQwikDark ? '#e2e8f0' : theme.colors.surface,
|
|
32
|
+
backgroundColor: isQwikDark ? '#ffffff' : theme.colors.background,
|
|
33
|
+
};
|
|
34
|
+
const gameHeaderTextColor = isQwikDark ? '#0f172a' : theme.colors.text.primary;
|
|
35
|
+
const closeGame = () => {
|
|
36
|
+
setActiveGame(null);
|
|
37
|
+
webViewRef.current?.injectJavaScript(
|
|
38
|
+
"window.PlaybasisWidget?.useWidgetStore?.getState?.()?.setActiveTab?.('play'); true;",
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const html = useMemo(() => getWidgetHtml(), []);
|
|
43
|
+
|
|
44
|
+
const buildBootstrapPayload = useCallback(async (): Promise<WidgetBootstrapData> => {
|
|
45
|
+
if (!playerId) {
|
|
46
|
+
throw new Error('Missing playerId for widget bootstrap');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [player, balances, quests, allBadges, playerBadges, rewards] = await Promise.all([
|
|
50
|
+
client.getPlayer(playerId),
|
|
51
|
+
client.getBalances(playerId),
|
|
52
|
+
client.getPlayerQuests(playerId),
|
|
53
|
+
client.getBadges(),
|
|
54
|
+
client.getPlayerBadges(playerId),
|
|
55
|
+
client.getRewards().catch(() => []), // Gracefully handle if rewards endpoint doesn't exist
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const xp = balances.find((b) => b.currency === 'xp')?.balance ?? 0;
|
|
59
|
+
const qwikCoins = balances.find((b) => b.currency === 'qwik-coins')?.balance ?? 0;
|
|
60
|
+
|
|
61
|
+
const leaderboardEntries = leaderboardId
|
|
62
|
+
? await (async () => {
|
|
63
|
+
try {
|
|
64
|
+
const res = await client.getLeaderboard(leaderboardId, { limit: 50 });
|
|
65
|
+
return res.entries.map((e) => ({
|
|
66
|
+
userId: e.playerId,
|
|
67
|
+
rank: e.rank,
|
|
68
|
+
displayName: e.displayName,
|
|
69
|
+
score: e.score,
|
|
70
|
+
isCurrentUser: e.playerId === player.id,
|
|
71
|
+
}));
|
|
72
|
+
} catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
})()
|
|
76
|
+
: [];
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
user: {
|
|
80
|
+
userId: player.id,
|
|
81
|
+
level: Math.max(1, Math.floor(xp / 1000) + 1),
|
|
82
|
+
exp: xp,
|
|
83
|
+
compositeScore: xp,
|
|
84
|
+
positiveChangePct: 0,
|
|
85
|
+
},
|
|
86
|
+
wallet: {
|
|
87
|
+
currency: 'XP',
|
|
88
|
+
balance: xp,
|
|
89
|
+
earnedDelta: 0,
|
|
90
|
+
qwikCoins,
|
|
91
|
+
},
|
|
92
|
+
goals: quests.map((q) => ({
|
|
93
|
+
goalId: q.id,
|
|
94
|
+
type: 'financial',
|
|
95
|
+
target: q.target,
|
|
96
|
+
current: q.progress,
|
|
97
|
+
progressPct: q.target > 0 ? Math.round((q.progress / q.target) * 100) : 0,
|
|
98
|
+
startDate: new Date().toISOString().slice(0, 10),
|
|
99
|
+
endDate: q.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
100
|
+
status:
|
|
101
|
+
q.status === 'completed' ? 'completed' : q.status === 'expired' ? 'at_risk' : 'on_track',
|
|
102
|
+
rewards: {
|
|
103
|
+
xp: q.rewards?.find((r) => r.type === 'points')?.amount || 100,
|
|
104
|
+
badges: (q.rewards
|
|
105
|
+
?.filter((r) => r.type === 'badge')
|
|
106
|
+
.map((r) => r.badgeId)
|
|
107
|
+
.filter(Boolean) || []) as string[],
|
|
108
|
+
},
|
|
109
|
+
})),
|
|
110
|
+
badges: allBadges.map((b) => ({
|
|
111
|
+
id: b.id,
|
|
112
|
+
name: b.name,
|
|
113
|
+
rarity: 'common',
|
|
114
|
+
unlocked: playerBadges.some((pb) => pb.id === b.id && pb.isEarned),
|
|
115
|
+
imageUrl: b.imageUrl,
|
|
116
|
+
})),
|
|
117
|
+
rewards: rewards.map((r) => ({
|
|
118
|
+
id: r.id,
|
|
119
|
+
name: r.name,
|
|
120
|
+
description: r.description || '',
|
|
121
|
+
cost: r.cost || 0,
|
|
122
|
+
imageUrl: r.imageUrl,
|
|
123
|
+
})),
|
|
124
|
+
activities: [],
|
|
125
|
+
leaderboardEntries,
|
|
126
|
+
tenantId,
|
|
127
|
+
};
|
|
128
|
+
}, [client, leaderboardId, playerId, tenantId]);
|
|
129
|
+
|
|
130
|
+
const handleMessage = useCallback(
|
|
131
|
+
async (event: WebViewMessageEvent) => {
|
|
132
|
+
let message: WidgetBridgeMessage;
|
|
133
|
+
try {
|
|
134
|
+
message = JSON.parse(event.nativeEvent.data) as WidgetBridgeMessage;
|
|
135
|
+
} catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (message.type === 'ERROR') {
|
|
140
|
+
// Handle errors from WebView
|
|
141
|
+
console.error('[QwikCardApp] WebView Error:', message.error);
|
|
142
|
+
Alert.alert('Widget Error', message.error || 'An unknown error occurred in the widget.');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (message.type === 'OPEN_GAME') {
|
|
147
|
+
setActiveGame({ url: message.url, title: message.title });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (message.type !== 'REQUEST') return;
|
|
152
|
+
|
|
153
|
+
const sendResponse = (payload: WidgetBridgeMessage) => {
|
|
154
|
+
const js = `window.__PB_HANDLE_NATIVE_MESSAGE__ && window.__PB_HANDLE_NATIVE_MESSAGE__(${JSON.stringify(
|
|
155
|
+
payload,
|
|
156
|
+
)}); true;`;
|
|
157
|
+
webViewRef.current?.injectJavaScript(js);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const requestId = message.requestId;
|
|
161
|
+
|
|
162
|
+
if (!playerId) {
|
|
163
|
+
sendResponse({
|
|
164
|
+
type: 'RESPONSE',
|
|
165
|
+
requestId,
|
|
166
|
+
ok: false,
|
|
167
|
+
error: 'PlayerId is required for widget data requests',
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
switch (message.resource) {
|
|
174
|
+
case 'bootstrap': {
|
|
175
|
+
const data = await buildBootstrapPayload();
|
|
176
|
+
sendResponse({ type: 'RESPONSE', requestId, ok: true, data });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
default: {
|
|
180
|
+
sendResponse({
|
|
181
|
+
type: 'RESPONSE',
|
|
182
|
+
requestId,
|
|
183
|
+
ok: false,
|
|
184
|
+
error: `Unsupported resource: ${String((message as any).resource)}`,
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
sendResponse({
|
|
191
|
+
type: 'RESPONSE',
|
|
192
|
+
requestId,
|
|
193
|
+
ok: false,
|
|
194
|
+
error: error instanceof Error ? error.message : 'Failed to process widget request',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
[buildBootstrapPayload, playerId],
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const injectedJavaScript = useMemo(() => {
|
|
202
|
+
// Seed initial config for the widget and establish a stable native->web message handler.
|
|
203
|
+
// The web layer will immediately request `bootstrap` from native.
|
|
204
|
+
const initPayload: WidgetBridgeMessage = {
|
|
205
|
+
type: 'INIT',
|
|
206
|
+
config: {
|
|
207
|
+
tenantId,
|
|
208
|
+
playerId: playerId ?? '',
|
|
209
|
+
baseUrl,
|
|
210
|
+
},
|
|
211
|
+
platform: Platform.OS,
|
|
212
|
+
};
|
|
213
|
+
return `window.__PB_INIT__ = ${JSON.stringify(initPayload)}; true;`;
|
|
214
|
+
}, [tenantId, playerId, baseUrl]);
|
|
215
|
+
|
|
216
|
+
if (!playerId) {
|
|
217
|
+
return (
|
|
218
|
+
<View
|
|
219
|
+
style={{
|
|
220
|
+
flex: 1,
|
|
221
|
+
alignItems: 'center',
|
|
222
|
+
justifyContent: 'center',
|
|
223
|
+
backgroundColor: theme.colors.background,
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<ActivityIndicator size="large" color={theme.colors.primary} />
|
|
227
|
+
</View>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<View style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
|
233
|
+
<View style={{ flex: 1, display: activeGame ? 'none' : 'flex' }}>
|
|
234
|
+
<WebView
|
|
235
|
+
ref={webViewRef}
|
|
236
|
+
originWhitelist={['*']}
|
|
237
|
+
source={{ html }}
|
|
238
|
+
onMessage={handleMessage}
|
|
239
|
+
injectedJavaScript={injectedJavaScript}
|
|
240
|
+
javaScriptEnabled
|
|
241
|
+
domStorageEnabled
|
|
242
|
+
allowsInlineMediaPlayback
|
|
243
|
+
startInLoadingState
|
|
244
|
+
renderLoading={() => (
|
|
245
|
+
<View
|
|
246
|
+
style={{
|
|
247
|
+
flex: 1,
|
|
248
|
+
alignItems: 'center',
|
|
249
|
+
justifyContent: 'center',
|
|
250
|
+
backgroundColor: theme.colors.background,
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
<ActivityIndicator size="large" color={theme.colors.primary} />
|
|
254
|
+
</View>
|
|
255
|
+
)}
|
|
256
|
+
// Reduce common RN/WebView UX papercuts.
|
|
257
|
+
keyboardDisplayRequiresUserAction={false as any}
|
|
258
|
+
setSupportMultipleWindows={false}
|
|
259
|
+
// Avoid white flashes on iOS.
|
|
260
|
+
style={{ backgroundColor: theme.colors.background }}
|
|
261
|
+
/>
|
|
262
|
+
</View>
|
|
263
|
+
{activeGame && (
|
|
264
|
+
<View style={{ flex: 1 }}>
|
|
265
|
+
<View style={gameHeaderStyle}>
|
|
266
|
+
<TouchableOpacity
|
|
267
|
+
onPress={closeGame}
|
|
268
|
+
style={{ paddingVertical: 4, paddingRight: 8 }}
|
|
269
|
+
>
|
|
270
|
+
<Text style={{ color: theme.colors.primary, fontWeight: '700' }}>← Arcade</Text>
|
|
271
|
+
</TouchableOpacity>
|
|
272
|
+
<Text
|
|
273
|
+
style={{
|
|
274
|
+
marginLeft: 4,
|
|
275
|
+
color: gameHeaderTextColor,
|
|
276
|
+
fontWeight: '700',
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
{activeGame.title || 'Play'}
|
|
280
|
+
</Text>
|
|
281
|
+
</View>
|
|
282
|
+
<WebView
|
|
283
|
+
originWhitelist={['*']}
|
|
284
|
+
source={{ uri: activeGame.url }}
|
|
285
|
+
javaScriptEnabled
|
|
286
|
+
domStorageEnabled
|
|
287
|
+
allowsInlineMediaPlayback
|
|
288
|
+
startInLoadingState
|
|
289
|
+
/>
|
|
290
|
+
</View>
|
|
291
|
+
)}
|
|
292
|
+
</View>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function QwikCardApp(props: QwikCardAppProps) {
|
|
297
|
+
return (
|
|
298
|
+
<PlaybasisProvider {...props}>
|
|
299
|
+
<AppContent leaderboardId={props.leaderboardId} />
|
|
300
|
+
</PlaybasisProvider>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Badge,
|
|
3
|
+
LeaderboardEntry,
|
|
4
|
+
Player,
|
|
5
|
+
PlayerRank,
|
|
6
|
+
PointBalance,
|
|
7
|
+
Quest,
|
|
8
|
+
RedemptionResult,
|
|
9
|
+
Reward,
|
|
10
|
+
TrackEventInput,
|
|
11
|
+
} from '../types';
|
|
12
|
+
|
|
13
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
// Configuration
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
interface ClientConfig {
|
|
18
|
+
apiKey: string;
|
|
19
|
+
tenantId: string;
|
|
20
|
+
baseUrl?: string;
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_BASE_URL = 'https://apim-pb-staging.azure-api.net/playbasis/v1';
|
|
25
|
+
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// Client
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export class PlaybasisClient {
|
|
31
|
+
private readonly baseUrl: string;
|
|
32
|
+
private readonly fetchImpl: typeof fetch;
|
|
33
|
+
private readonly apiKey: string;
|
|
34
|
+
private readonly tenantId: string;
|
|
35
|
+
|
|
36
|
+
constructor(config: ClientConfig) {
|
|
37
|
+
this.tenantId = config.tenantId;
|
|
38
|
+
this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
39
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
40
|
+
this.apiKey = config.apiKey;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate a unique ID for idempotency keys.
|
|
45
|
+
* Uses crypto.randomUUID when available, falls back to timestamp + random.
|
|
46
|
+
*/
|
|
47
|
+
private generateId(): string {
|
|
48
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
49
|
+
return crypto.randomUUID();
|
|
50
|
+
}
|
|
51
|
+
// Fallback for environments without crypto.randomUUID
|
|
52
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async request<T>(
|
|
56
|
+
method: 'GET' | 'POST' | 'PATCH',
|
|
57
|
+
path: string,
|
|
58
|
+
options?: { body?: unknown; headers?: Record<string, string> },
|
|
59
|
+
): Promise<T> {
|
|
60
|
+
const url = `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
|
61
|
+
|
|
62
|
+
// Build headers with Content-Type set before creating init
|
|
63
|
+
const headers: Record<string, string> = {
|
|
64
|
+
'Ocp-Apim-Subscription-Key': this.apiKey,
|
|
65
|
+
'X-Tenant-ID': this.tenantId,
|
|
66
|
+
...options?.headers,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Set Content-Type before assigning to init to avoid mutation after reference
|
|
70
|
+
if (options?.body !== undefined) {
|
|
71
|
+
headers['Content-Type'] = headers['Content-Type'] ?? 'application/json';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const init: RequestInit = {
|
|
75
|
+
method,
|
|
76
|
+
headers,
|
|
77
|
+
body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const response = await this.fetchImpl(url, init);
|
|
81
|
+
const text = await response.text();
|
|
82
|
+
|
|
83
|
+
// Safely parse JSON - server may return non-JSON (e.g., HTML error page)
|
|
84
|
+
let json: unknown;
|
|
85
|
+
if (text) {
|
|
86
|
+
try {
|
|
87
|
+
json = JSON.parse(text);
|
|
88
|
+
} catch {
|
|
89
|
+
// Response is not valid JSON - leave as undefined
|
|
90
|
+
json = undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const getErrorMessage = (value: unknown): string | undefined => {
|
|
95
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
96
|
+
if (!('message' in value)) return undefined;
|
|
97
|
+
const messageValue = (value as { message?: unknown }).message;
|
|
98
|
+
return typeof messageValue === 'string' ? messageValue : undefined;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
const errorMessage =
|
|
103
|
+
getErrorMessage(json) ||
|
|
104
|
+
(text && !json ? text.slice(0, 200) : null) ||
|
|
105
|
+
`Request failed: ${response.status} ${response.statusText}`;
|
|
106
|
+
throw new Error(String(errorMessage));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return json as T;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
113
|
+
// Players
|
|
114
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
async createPlayer(input: {
|
|
117
|
+
displayName: string;
|
|
118
|
+
email?: string;
|
|
119
|
+
metadata?: Record<string, unknown>;
|
|
120
|
+
}): Promise<Player> {
|
|
121
|
+
const data = await this.request<{ player: Player }>('POST', '/players', { body: input });
|
|
122
|
+
return data.player;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async getPlayer(playerId: string): Promise<Player> {
|
|
126
|
+
const data = await this.request<{ player: Player }>(
|
|
127
|
+
'GET',
|
|
128
|
+
`/players/${encodeURIComponent(playerId)}`,
|
|
129
|
+
);
|
|
130
|
+
return data.player;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async updatePlayer(
|
|
134
|
+
playerId: string,
|
|
135
|
+
updates: Partial<{ displayName: string; metadata: Record<string, unknown> }>,
|
|
136
|
+
): Promise<Player> {
|
|
137
|
+
const data = await this.request<{ player: Player }>(
|
|
138
|
+
'PATCH',
|
|
139
|
+
`/players/${encodeURIComponent(playerId)}`,
|
|
140
|
+
{
|
|
141
|
+
body: updates,
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
return data.player;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
148
|
+
// Points
|
|
149
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
async getBalances(playerId: string): Promise<PointBalance[]> {
|
|
152
|
+
const data = await this.request<{ balances: PointBalance[] }>(
|
|
153
|
+
'GET',
|
|
154
|
+
`/players/${encodeURIComponent(playerId)}/points`,
|
|
155
|
+
);
|
|
156
|
+
return data.balances;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async earnPoints(input: {
|
|
160
|
+
playerId: string;
|
|
161
|
+
currency: string;
|
|
162
|
+
amount: number;
|
|
163
|
+
reason?: string;
|
|
164
|
+
}): Promise<PointBalance> {
|
|
165
|
+
const idempotencyKey = `earn-${input.playerId}-${this.generateId()}`;
|
|
166
|
+
const data = await this.request<{ balance: PointBalance }>('POST', '/points/earn', {
|
|
167
|
+
body: input,
|
|
168
|
+
headers: { 'Idempotency-Key': idempotencyKey },
|
|
169
|
+
});
|
|
170
|
+
return data.balance;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async redeemPoints(input: {
|
|
174
|
+
playerId: string;
|
|
175
|
+
currency: string;
|
|
176
|
+
amount: number;
|
|
177
|
+
reason?: string;
|
|
178
|
+
}): Promise<PointBalance> {
|
|
179
|
+
const idempotencyKey = `redeem-${input.playerId}-${this.generateId()}`;
|
|
180
|
+
const data = await this.request<{ balance: PointBalance }>('POST', '/points/redeem', {
|
|
181
|
+
body: input,
|
|
182
|
+
headers: { 'Idempotency-Key': idempotencyKey },
|
|
183
|
+
});
|
|
184
|
+
return data.balance;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
188
|
+
// Events
|
|
189
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
async trackEvent(input: TrackEventInput): Promise<{ eventId: string }> {
|
|
192
|
+
const idempotencyKey = input.referenceId || `evt-${input.playerId}-${this.generateId()}`;
|
|
193
|
+
const event = {
|
|
194
|
+
id: idempotencyKey,
|
|
195
|
+
source: 'qwikcard-app',
|
|
196
|
+
type: input.type,
|
|
197
|
+
specversion: '1.0',
|
|
198
|
+
time: new Date().toISOString(),
|
|
199
|
+
subject: input.playerId,
|
|
200
|
+
data: input.data || {},
|
|
201
|
+
};
|
|
202
|
+
const data = await this.request<{ event?: { id?: string } }>('POST', '/events', {
|
|
203
|
+
body: event,
|
|
204
|
+
headers: { 'Idempotency-Key': idempotencyKey },
|
|
205
|
+
});
|
|
206
|
+
return { eventId: data.event?.id || idempotencyKey };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
210
|
+
// Quests
|
|
211
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
async getQuests(): Promise<Quest[]> {
|
|
214
|
+
const data = await this.request<{ quests?: Quest[] }>('GET', '/quests');
|
|
215
|
+
return data.quests || [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async getPlayerQuests(playerId: string): Promise<Quest[]> {
|
|
219
|
+
const data = await this.request<{ quests?: Quest[] }>(
|
|
220
|
+
'GET',
|
|
221
|
+
`/players/${encodeURIComponent(playerId)}/quests`,
|
|
222
|
+
);
|
|
223
|
+
return data.quests || [];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async getQuestProgress(playerId: string, questId: string): Promise<Quest> {
|
|
227
|
+
const data = await this.request<{ quest: Quest }>(
|
|
228
|
+
'GET',
|
|
229
|
+
`/players/${encodeURIComponent(playerId)}/quests/${encodeURIComponent(questId)}`,
|
|
230
|
+
);
|
|
231
|
+
return data.quest;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
235
|
+
// Badges
|
|
236
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
async getBadges(): Promise<Badge[]> {
|
|
239
|
+
const data = await this.request<{ badges?: Badge[] }>('GET', '/badges');
|
|
240
|
+
return data.badges || [];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async getPlayerBadges(playerId: string): Promise<Badge[]> {
|
|
244
|
+
const data = await this.request<{ badges?: Badge[] }>(
|
|
245
|
+
'GET',
|
|
246
|
+
`/players/${encodeURIComponent(playerId)}/badges`,
|
|
247
|
+
);
|
|
248
|
+
return data.badges || [];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
252
|
+
// Rewards
|
|
253
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
async getRewards(): Promise<Reward[]> {
|
|
256
|
+
const data = await this.request<{ rewards?: Reward[] }>('GET', '/rewards');
|
|
257
|
+
return data.rewards || [];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async redeemReward(playerId: string, rewardId: string): Promise<RedemptionResult> {
|
|
261
|
+
const idempotencyKey = `reward-${playerId}-${rewardId}-${this.generateId()}`;
|
|
262
|
+
const data = await this.request<RedemptionResult>('POST', '/rewards/redeem', {
|
|
263
|
+
body: { playerId, rewardId },
|
|
264
|
+
headers: { 'Idempotency-Key': idempotencyKey },
|
|
265
|
+
});
|
|
266
|
+
return data;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
270
|
+
// Leaderboards
|
|
271
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
async getLeaderboard(
|
|
274
|
+
leaderboardId: string,
|
|
275
|
+
options?: { limit?: number; cursor?: string },
|
|
276
|
+
): Promise<{ entries: LeaderboardEntry[]; total: number }> {
|
|
277
|
+
const params = new URLSearchParams();
|
|
278
|
+
if (options?.limit) params.set('limit', String(options.limit));
|
|
279
|
+
if (options?.cursor) params.set('cursor', options.cursor);
|
|
280
|
+
|
|
281
|
+
const data = await this.request<{
|
|
282
|
+
leaderboard?: { entries?: LeaderboardEntry[]; total?: number };
|
|
283
|
+
}>('GET', `/leaderboards/${encodeURIComponent(leaderboardId)}?${params}`);
|
|
284
|
+
return {
|
|
285
|
+
entries: data.leaderboard?.entries || [],
|
|
286
|
+
total: data.leaderboard?.total || 0,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async getPlayerRank(leaderboardId: string, playerId: string): Promise<PlayerRank> {
|
|
291
|
+
const data = await this.request<{ player: PlayerRank }>(
|
|
292
|
+
'GET',
|
|
293
|
+
`/leaderboards/${encodeURIComponent(leaderboardId)}/players/${encodeURIComponent(playerId)}`,
|
|
294
|
+
);
|
|
295
|
+
return data.player;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
299
|
+
// Health Check
|
|
300
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
async health(): Promise<{ status: string }> {
|
|
303
|
+
return this.request<{ status: string }>('GET', '/health');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export default PlaybasisClient;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, Text, StyleSheet } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useTheme } from '../theme/context';
|
|
5
|
+
|
|
6
|
+
interface BadgeProps {
|
|
7
|
+
label: string;
|
|
8
|
+
value?: string;
|
|
9
|
+
color?: string; // Optional override
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function StatBadge({ label, value, color }: BadgeProps) {
|
|
13
|
+
const theme = useTheme();
|
|
14
|
+
const accentColor = color || theme.colors.primary;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<View
|
|
18
|
+
style={[
|
|
19
|
+
styles.container,
|
|
20
|
+
{
|
|
21
|
+
backgroundColor: `${accentColor}1A`, // 10% opacity
|
|
22
|
+
borderColor: `${accentColor}33`, // 20% opacity
|
|
23
|
+
borderRadius: theme.borderRadius.m,
|
|
24
|
+
},
|
|
25
|
+
]}
|
|
26
|
+
>
|
|
27
|
+
<Text style={[styles.label, { color: theme.colors.text.secondary }]}>{label}</Text>
|
|
28
|
+
{value && <Text style={[styles.value, { color: accentColor }]}>{value}</Text>}
|
|
29
|
+
</View>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const styles = StyleSheet.create({
|
|
34
|
+
container: {
|
|
35
|
+
paddingVertical: 6,
|
|
36
|
+
paddingHorizontal: 10,
|
|
37
|
+
borderWidth: 1,
|
|
38
|
+
alignItems: 'center',
|
|
39
|
+
justifyContent: 'center',
|
|
40
|
+
},
|
|
41
|
+
label: {
|
|
42
|
+
fontSize: 10,
|
|
43
|
+
textTransform: 'uppercase',
|
|
44
|
+
letterSpacing: 0.5,
|
|
45
|
+
marginBottom: 2,
|
|
46
|
+
},
|
|
47
|
+
value: {
|
|
48
|
+
fontSize: 14,
|
|
49
|
+
fontWeight: '700',
|
|
50
|
+
},
|
|
51
|
+
});
|