@idosgames/mcp 0.1.20 → 0.1.22
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/package.json +1 -1
- package/registry/host.json +10 -10
- package/registry/index.json +141 -141
- package/registry/modules/ai.json +7 -7
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +7 -7
- package/registry/modules/chat.json +7 -7
- package/registry/modules/collections.json +7 -7
- package/registry/modules/coop-events.json +7 -7
- package/registry/modules/craft.json +7 -7
- package/registry/modules/deals.json +7 -7
- package/registry/modules/events.json +7 -7
- package/registry/modules/friends.json +7 -7
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +7 -7
- package/registry/modules/leaderboard.json +7 -7
- package/registry/modules/lootboxes.json +3 -3
- package/registry/modules/mailbox.json +3 -3
- package/registry/modules/marketplace.json +7 -7
- package/registry/modules/mini-game.json +7 -7
- package/registry/modules/multiplayer.json +7 -7
- package/registry/modules/premium.json +7 -7
- package/registry/modules/profile.json +7 -7
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +7 -7
- package/registry/modules/season.json +7 -7
- package/registry/modules/store.json +7 -7
- package/registry/modules/tutorials.json +7 -7
- package/registry/modules/voxelcraft.json +6 -6
- package/registry/modules/workshop.json +7 -7
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/idosgames-module-contract.json +1 -1
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
|
@@ -34,19 +34,19 @@
|
|
|
34
34
|
"name": "iDos Games",
|
|
35
35
|
"url": "https://idosgames.com"
|
|
36
36
|
},
|
|
37
|
-
"version": "0.1.
|
|
37
|
+
"version": "0.1.1"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@idosgames/core": "0.
|
|
41
|
-
"@idosgames/module-sdk": "0.
|
|
42
|
-
"@idosgames/react": "0.
|
|
40
|
+
"@idosgames/core": "0.20.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
42
|
+
"@idosgames/react": "0.4.2",
|
|
43
43
|
"react": "19.2.7"
|
|
44
44
|
},
|
|
45
|
-
"contentHash": "
|
|
45
|
+
"contentHash": "b0f644b550581b65f3d48122c732575e8e4e01bb0e1cfe4fcd22830704dae5e2",
|
|
46
46
|
"files": [
|
|
47
47
|
{
|
|
48
48
|
"path": "EventsScreen.tsx",
|
|
49
|
-
"content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveEventInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimableNow,\n claimedLines,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumIDsOf,\n tokenSources,\n type MilestoneRow,\n type PremiumView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Limited-time events (Unity Core/Window LimitedTimeEvent): each active event is a tab — its timer,\n// the token bar toward the next prize and the milestone track. A milestone pays its reward and, for\n// a VIP, a second prize; a VIP bought later tops up the milestones already claimed. How the tokens\n// are earned is a popup; the VIP pass is sold by the VIP system (the premium module).\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\n );\n return {\n get: () => events,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n const res = await client.timedEvent.getActiveEvents();\n events = res.ok ? (res.data.ActiveEvents ?? []) : [];\n badge();\n for (const l of listeners) l();\n },\n refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(e.Content?.DisplayName ?? e.TimedEventID),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n <EventView key={current.TimedEventID} event={current} store={store} />\n </div>\n );\n };\n}\n\n/** Keeps the badge current: loads the events once, then every few minutes and on premium changes. */\nexport function makeEventsWatcher(store: EventsStore) {\n return function EventsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n useEffect(() => store.refreshBadge(), [state?.Premium]);\n return null;\n };\n}\n\nfunction EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(event, premium, now);\n const goal = nextGoal(event, rows);\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const vipIDs = premiumIDsOf(\n Object.values(event.Content?.Milestones ?? {}).map((m) => m?.Rewards),\n );\n const vipID = vipIDs[0] ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, now) : false;\n const ready = rows.filter(claimableNow);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.timedEvent.claimAllMilestones();\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : ready.flatMap((r) => [\n ...(r.state === \"ready\" ? r.reward : []),\n ...(r.vipState === \"ready\" ? r.vip : []),\n ]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n background: \"linear-gradient(180deg, #e0672b 0%, #8a2c1a 100%)\",\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"flame\"}\n size={46}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(\n event.Content?.DisplayName ?? event.TimedEventID,\n )}\n </div>\n <Timer at={event.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n <Button size=\"sm\" tone=\"blue\" onClick={() => setSources(true)}>\n {t(\"howToEarn\")}\n </Button>\n </div>\n {event.Content?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: \"rgba(255,255,255,.85)\",\n }}\n >\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: 4 }}\n >\n <Icon glyph=\"star\" size={16} /> {formatAmount(goal.earned)}{\" \"}\n {token}\n </span>\n <span>\n {goal.earned >= goal.goal\n ? t(\"allDone\")\n : `${t(\"nextPrize\")} ${formatAmount(goal.goal)}`}\n </span>\n </div>\n <ProgressBar\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID && (vipHeld || store.vipOnSale()) ? (\n vipHeld ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.gold,\n }}\n >\n <Icon glyph=\"crown\" size={22} /> {t(\"vipActive\")}\n </div>\n ) : (\n <Card\n onClick={() => store.openVip(vipID)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n border: `2px solid ${v.gold}`,\n }}\n >\n <Icon glyph=\"crown\" size={30} />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 13 }}>\n {t(\"vipBenefit\")}\n </div>\n <Button size=\"sm\" tone=\"gold\" attract>\n {t(\"getVip\")}\n </Button>\n </Card>\n )\n ) : null}\n\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"events:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <MilestoneCard\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n </div>\n );\n}\n\nfunction MilestoneCard({\n event,\n row,\n index,\n earned,\n store,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claimable = claimableNow(row);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.timedEvent.claimMilestone(\n event.Type!,\n event.TimedEventID!,\n row.id,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : [\n ...(row.state === \"ready\" ? row.reward : []),\n ...(row.vipState === \"ready\" ? row.vip : []),\n ],\n );\n await store.load(client);\n };\n\n const reached = earned >= row.required;\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"grid\",\n gridTemplateColumns: \"auto minmax(0, 1fr) auto\",\n alignItems: \"center\",\n gap: 10,\n border: claimable\n ? `2px solid ${v.gold}`\n : row.featured\n ? `2px solid ${v.blue}`\n : panel.border,\n opacity: row.state === \"claimed\" && row.vipState !== \"ready\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 48 }}>\n <Icon glyph={reached ? \"star\" : \"lock\"} size={24} />\n <span style={{ ...outlined, fontSize: 14 }}>\n {formatAmount(row.required)}\n </span>\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(row.name)}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n {row.vip.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n opacity: row.vipState === \"locked\" ? 0.5 : 1,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n {row.vipState === \"granted\" ? (\n <Icon glyph=\"check\" size={16} />\n ) : null}\n </div>\n ) : null}\n </div>\n <div data-index={index}>\n {claimable ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 12,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </span>\n ) : (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"locked\")}\n </span>\n )}\n </div>\n </div>\n );\n}\n\nfunction SourcesPopup({\n event,\n onClose,\n}: {\n event: ActiveEventInfo;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const label = (kind: string) =>\n kind === \"CustomAction\"\n ? t(\"playRound\")\n : kind === \"StorePurchase\"\n ? t(\"shopping\")\n : kind === \"QuestComplete\"\n ? t(\"quests\")\n : kind === \"DailyLogin\"\n ? t(\"login\")\n : t(\"other\");\n return (\n <Popup title={t(\"howToEarn\")} onClose={onClose} width={420}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {tokenSources(event).map((s, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon\n glyph={\n s.kind === \"StorePurchase\"\n ? \"shop\"\n : s.kind === \"QuestComplete\"\n ? \"quest\"\n : s.kind === \"DailyLogin\"\n ? \"calendar\"\n : \"star\"\n }\n size={26}\n />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 14 }}>\n {label(s.kind)}\n </div>\n <div\n style={{\n textAlign: \"right\",\n ...outlined,\n fontSize: 14,\n color: v.gold,\n }}\n >\n +{formatAmount(s.amount)} {token}\n {s.dailyCap > 0 ? (\n <div\n style={{ fontSize: 11, color: v.textDim }}\n >{`≤ ${s.dailyCap} ${t(\"perDay\")}`}</div>\n ) : null}\n </div>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
|
|
49
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveEventInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimableNow,\n claimedLines,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumIDsOf,\n tokenSources,\n type MilestoneRow,\n type PremiumView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Limited-time events (Unity Core/Window LimitedTimeEvent): each active event is a tab — its timer,\n// the token bar toward the next prize and the milestone track. A milestone pays its reward and, for\n// a VIP, a second prize; a VIP bought later tops up the milestones already claimed. How the tokens\n// are earned is a popup; the VIP pass is sold by the VIP system (the premium module).\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\n );\n return {\n get: () => events,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n const res = await client.timedEvent.getActiveEvents();\n events = res.ok ? (res.data.ActiveEvents ?? []) : [];\n badge();\n for (const l of listeners) l();\n },\n refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(e.Content?.DisplayName ?? e.TimedEventID),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n <EventView key={current.TimedEventID} event={current} store={store} />\n </div>\n );\n };\n}\n\n/** Keeps the badge current: loads the events once, then every few minutes and on premium changes. */\nexport function makeEventsWatcher(store: EventsStore) {\n return function EventsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n useEffect(() => store.refreshBadge(), [state?.Premium]);\n return null;\n };\n}\n\nfunction EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(event, premium, now);\n const goal = nextGoal(event, rows);\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const vipIDs = premiumIDsOf(\n Object.values(event.Content?.Milestones ?? {}).map((m) => m?.Rewards),\n );\n const vipID = vipIDs[0] ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, now) : false;\n const ready = rows.filter(claimableNow);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.timedEvent.claimAllMilestones();\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : ready.flatMap((r) => [\n ...(r.state === \"ready\" ? r.reward : []),\n ...(r.vipState === \"ready\" ? r.vip : []),\n ]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"red\"),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"flame\"}\n size={46}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(\n event.Content?.DisplayName ?? event.TimedEventID,\n )}\n </div>\n <Timer at={event.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n <Button size=\"sm\" tone=\"blue\" onClick={() => setSources(true)}>\n {t(\"howToEarn\")}\n </Button>\n </div>\n {event.Content?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: v.textDim,\n }}\n >\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: 4 }}\n >\n <Icon glyph=\"star\" size={16} /> {formatAmount(goal.earned)}{\" \"}\n {token}\n </span>\n <span>\n {goal.earned >= goal.goal\n ? t(\"allDone\")\n : `${t(\"nextPrize\")} ${formatAmount(goal.goal)}`}\n </span>\n </div>\n <ProgressBar\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID && (vipHeld || store.vipOnSale()) ? (\n vipHeld ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.gold,\n }}\n >\n <Icon glyph=\"crown\" size={22} /> {t(\"vipActive\")}\n </div>\n ) : (\n <Card\n onClick={() => store.openVip(vipID)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n border: `2px solid ${v.gold}`,\n }}\n >\n <Icon glyph=\"crown\" size={30} />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 13 }}>\n {t(\"vipBenefit\")}\n </div>\n <Button size=\"sm\" tone=\"gold\" attract>\n {t(\"getVip\")}\n </Button>\n </Card>\n )\n ) : null}\n\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"events:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <MilestoneCard\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n </div>\n );\n}\n\nfunction MilestoneCard({\n event,\n row,\n index,\n earned,\n store,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claimable = claimableNow(row);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.timedEvent.claimMilestone(\n event.Type!,\n event.TimedEventID!,\n row.id,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : [\n ...(row.state === \"ready\" ? row.reward : []),\n ...(row.vipState === \"ready\" ? row.vip : []),\n ],\n );\n await store.load(client);\n };\n\n const reached = earned >= row.required;\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"grid\",\n gridTemplateColumns: \"auto minmax(0, 1fr) auto\",\n alignItems: \"center\",\n gap: 10,\n border: claimable\n ? `2px solid ${v.gold}`\n : row.featured\n ? `2px solid ${v.blue}`\n : panel.border,\n opacity: row.state === \"claimed\" && row.vipState !== \"ready\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 48 }}>\n <Icon glyph={reached ? \"star\" : \"lock\"} size={24} />\n <span style={{ ...outlined, fontSize: 14 }}>\n {formatAmount(row.required)}\n </span>\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(row.name)}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n {row.vip.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n opacity: row.vipState === \"locked\" ? 0.5 : 1,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n {row.vipState === \"granted\" ? (\n <Icon glyph=\"check\" size={16} />\n ) : null}\n </div>\n ) : null}\n </div>\n <div data-index={index}>\n {claimable ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 12,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </span>\n ) : (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"locked\")}\n </span>\n )}\n </div>\n </div>\n );\n}\n\nfunction SourcesPopup({\n event,\n onClose,\n}: {\n event: ActiveEventInfo;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const label = (kind: string) =>\n kind === \"CustomAction\"\n ? t(\"playRound\")\n : kind === \"StorePurchase\"\n ? t(\"shopping\")\n : kind === \"QuestComplete\"\n ? t(\"quests\")\n : kind === \"DailyLogin\"\n ? t(\"login\")\n : t(\"other\");\n return (\n <Popup title={t(\"howToEarn\")} onClose={onClose} width={420}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {tokenSources(event).map((s, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon\n glyph={\n s.kind === \"StorePurchase\"\n ? \"shop\"\n : s.kind === \"QuestComplete\"\n ? \"quest\"\n : s.kind === \"DailyLogin\"\n ? \"calendar\"\n : \"star\"\n }\n size={26}\n />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 14 }}>\n {label(s.kind)}\n </div>\n <div\n style={{\n textAlign: \"right\",\n ...outlined,\n fontSize: 14,\n color: v.gold,\n }}\n >\n +{formatAmount(s.amount)} {token}\n {s.dailyCap > 0 ? (\n <div\n style={{ fontSize: 11, color: v.textDim }}\n >{`≤ ${s.dailyCap} ${t(\"perDay\")}`}</div>\n ) : null}\n </div>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
"path": "i18n.ts",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
"path": "module.meta.json",
|
|
69
|
-
"content": "{\n \"id\": \"events\",\n \"type\": \"feature\",\n \"summary\": \"Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.\",\n \"description\": \"A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) is a popup; 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the mini-game module does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.\",\n \"provides\": [\n \"events screen with a tab per active event\",\n \"event timer and token progress bar\",\n \"milestone track with rewards\",\n \"VIP second prize and top-up after purchase\",\n \"claim all milestones\",\n \"how-to-earn popup with daily caps\",\n \"hand-off to the VIP system\",\n \"events badge\",\n \"demo weekly event\"\n ],\n \"tags\": [\n \"events\",\n \"live-ops\",\n \"limited-time\",\n \"milestones\",\n \"battle-pass\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
69
|
+
"content": "{\n \"id\": \"events\",\n \"type\": \"feature\",\n \"summary\": \"Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.\",\n \"description\": \"A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) is a popup; 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the mini-game module does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.\",\n \"provides\": [\n \"events screen with a tab per active event\",\n \"event timer and token progress bar\",\n \"milestone track with rewards\",\n \"VIP second prize and top-up after purchase\",\n \"claim all milestones\",\n \"how-to-earn popup with daily caps\",\n \"hand-off to the VIP system\",\n \"events badge\",\n \"demo weekly event\"\n ],\n \"tags\": [\n \"events\",\n \"live-ops\",\n \"limited-time\",\n \"milestones\",\n \"battle-pass\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
|
|
70
70
|
}
|
|
71
71
|
]
|
|
72
72
|
}
|
|
@@ -32,19 +32,19 @@
|
|
|
32
32
|
"name": "iDos Games",
|
|
33
33
|
"url": "https://idosgames.com"
|
|
34
34
|
},
|
|
35
|
-
"version": "0.1.
|
|
35
|
+
"version": "0.1.1"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@idosgames/core": "0.
|
|
39
|
-
"@idosgames/module-sdk": "0.
|
|
40
|
-
"@idosgames/react": "0.
|
|
38
|
+
"@idosgames/core": "0.20.0",
|
|
39
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
40
|
+
"@idosgames/react": "0.4.2",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
|
-
"contentHash": "
|
|
43
|
+
"contentHash": "f5c0187a70b1b22166f4ff70aa228339d4ef0a35acd2837bc3a1129e52576910",
|
|
44
44
|
"files": [
|
|
45
45
|
{
|
|
46
46
|
"path": "FriendsScreen.tsx",
|
|
47
|
-
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n Spinner,\n Tabs,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n actorsText,\n canRemove,\n cleanUserID,\n displayName,\n friendsBadge,\n inboxKind,\n initial,\n sortByName,\n type InboxKind,\n type ProfileView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Friends (Unity Runtime/UI/Friends): the friends list, requests (incoming and sent), suggestions\n// and the notification inbox as tabs. A player card, adding by ID and the confirmations are popups.\n// In \"friends from idosgames.com only\" mode the requests, suggestions and \"Add\" disappear.\n\ntype Tab = \"friends\" | \"requests\" | \"suggested\" | \"inbox\";\ntype InboxItem = {\n Id: string;\n Verb?: string | null;\n Count?: number | null;\n LastActors?: Array<{ DisplayName?: string | null } | null> | null;\n Payload?: { Title?: string | null } | null;\n OccurredAt?: string | null;\n};\n\n/** Keeps the badge: incoming requests (sent with the player state) + unread notifications. */\nexport function makeFriendsWatcher(features: FeatureRegistry) {\n return function FriendsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [unread, setUnread] = useState(0);\n useEffect(() => {\n const load = () =>\n void client.social\n .getInboxUnread()\n .then((r) => r.ok && setUnread(Number(r.data.UnreadCount ?? 0)));\n load();\n const id = setInterval(load, 5 * 60_000);\n const off = client.on(\"social:inboxBadge\", (r) =>\n setUnread(Number(r.UnreadCount ?? 0)),\n );\n return () => {\n clearInterval(id);\n off();\n };\n }, [client]);\n useEffect(\n () =>\n features.setBadge(\n \"friends\",\n friendsBadge(\n state?.Social as { IncomingCount?: number } | undefined,\n unread,\n ),\n ),\n [state?.Social, unread],\n );\n return null;\n };\n}\n\nexport function makeFriendsScreen(features: FeatureRegistry) {\n return function FriendsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"friends\");\n const [adding, setAdding] = useState(false);\n const [card, setCard] = useState<ProfileView | null>(null);\n const [version, setVersion] = useState(0);\n const requestsAllowed = client.social.friendRequestsAllowed();\n const incoming = Number(\n (state?.Social as { IncomingCount?: number } | undefined)\n ?.IncomingCount ?? 0,\n );\n const refresh = () => setVersion((n) => n + 1);\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"friends\", label: t(\"friends\") },\n ...(requestsAllowed\n ? [\n { id: \"requests\" as const, label: t(\"requests\"), badge: incoming },\n { id: \"suggested\" as const, label: t(\"suggested\") },\n ]\n : []),\n { id: \"inbox\", label: t(\"inbox\") },\n ];\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <MyID />\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n {requestsAllowed ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n onClick={() => setAdding(true)}\n data-tutorial-anchor=\"friends:add\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"add\")}\n </Button>\n ) : null}\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"friends\" ? <FriendList onPick={setCard} /> : null}\n {tab === \"requests\" ? (\n <Requests onChange={refresh} onPick={setCard} />\n ) : null}\n {tab === \"suggested\" ? <Suggested onPick={setCard} /> : null}\n {tab === \"inbox\" ? <Inbox /> : null}\n </div>\n {adding ? (\n <AddFriendPopup onClose={() => setAdding(false)} onSent={refresh} />\n ) : null}\n {card ? (\n <PlayerCard\n player={card}\n features={features}\n onClose={() => setCard(null)}\n onChange={refresh}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction MyID(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const id = client.auth.context?.userID ?? \"\";\n const copy = () =>\n void navigator.clipboard?.writeText(id).then(() => toast(t(\"copied\")));\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"yourID\")}\n </span>\n <span\n style={{\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n fontFamily: \"monospace\",\n fontSize: 13,\n }}\n >\n {id}\n </span>\n <Button size=\"sm\" tone=\"blue\" onClick={copy}>\n <Icon glyph=\"copy\" size={16} />\n </Button>\n </div>\n );\n}\n\nfunction Avatar({\n player,\n size = 44,\n}: {\n player: ProfileView;\n size?: number;\n}): ReactNode {\n const url = player.PublicData?.AvatarUrl;\n return (\n <div\n style={{\n width: size,\n height: size,\n flex: \"0 0 auto\",\n borderRadius: \"50%\",\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: \"2px solid rgba(255,255,255,.6)\",\n ...outlined,\n fontSize: size * 0.42,\n }}\n >\n {url ? (\n <img\n src={url}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : (\n initial(player)\n )}\n </div>\n );\n}\n\nfunction PlayerRow({\n player,\n index,\n onPick,\n right,\n}: {\n player: ProfileView;\n index: number;\n onPick?: (p: ProfileView) => void;\n right?: ReactNode;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div\n onClick={onPick ? () => onPick(player) : undefined}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Avatar player={player} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {displayName(player)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n {player.Source === \"Platform\" ? ` · ${t(\"platformFriend\")}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction useList(\n load: () => Promise<{\n ok: boolean;\n data?: { Friends?: ProfileView[] | null };\n }>,\n): ProfileView[] | null {\n const [list, setList] = useState<ProfileView[] | null>(null);\n useEffect(() => {\n void load().then((r) =>\n setList(r.ok ? sortByName(r.data?.Friends ?? []) : []),\n );\n }, [load]);\n return list;\n}\n\nfunction FriendList({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const list = useList(\n useCallback(() => client.social.getFriendsList(), [client]),\n );\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow key={p.UserID} player={p} index={i} onPick={onPick} />\n ))}\n </div>\n );\n}\n\nfunction Requests({\n onChange,\n onPick,\n}: {\n onChange: () => void;\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const incoming = useList(\n useCallback(() => client.social.getIncomingRequests(), [client]),\n );\n const outgoing = useList(\n useCallback(() => client.social.getOutgoingRequests(), [client]),\n );\n const [busy, setBusy] = useState<string | null>(null);\n if (!incoming || !outgoing)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (incoming.length === 0 && outgoing.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noRequests\")} />;\n const answer = async (id: string, yes: boolean) => {\n setBusy(id);\n const res = yes\n ? await client.social.acceptFriendRequest(id)\n : await client.social.declineFriendRequest(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (yes) kit.play(\"claim\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {incoming.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"incoming\")}\n </div>\n ) : null}\n {incoming.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <div\n style={{ display: \"flex\", gap: 6 }}\n onClick={(e) => e.stopPropagation()}\n >\n <Button\n size=\"sm\"\n tone=\"green\"\n busy={busy === p.UserID}\n onClick={() => void answer(p.UserID, true)}\n data-tutorial-anchor=\"friends:accept\"\n >\n {t(\"accept\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy === p.UserID}\n onClick={() => void answer(p.UserID, false)}\n >\n ✕\n </Button>\n </div>\n }\n />\n ))}\n {outgoing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"outgoing\")}\n </div>\n ) : null}\n {outgoing.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={incoming.length + i}\n onPick={onPick}\n right={<Icon glyph=\"clock\" size={20} />}\n />\n ))}\n </div>\n );\n}\n\nfunction Suggested({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const list = useList(\n useCallback(() => client.social.getRecommendedFriends(10), [client]),\n );\n const [sent, setSent] = useState<Set<string>>(new Set());\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noSuggestions\")} />;\n const add = async (id: string) => {\n const res = await client.social.sendFriendRequest(id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setSent((s) => new Set(s).add(id));\n toast(t(\"sent\"));\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <span onClick={(e) => e.stopPropagation()}>\n {sent.has(p.UserID) ? (\n <Icon glyph=\"check\" size={22} />\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n onClick={() => void add(p.UserID)}\n >\n {t(\"add\")}\n </Button>\n )}\n </span>\n }\n />\n ))}\n </div>\n );\n}\n\nconst VERB_TEXT: Record<InboxKind, Parameters<typeof t>[0]> = {\n attack: \"vAttack\",\n raid: \"vRaid\",\n friend: \"vFriend\",\n sale: \"vSale\",\n follow: \"vFollow\",\n like: \"vLike\",\n comment: \"vComment\",\n reply: \"vReply\",\n other: \"vOther\",\n};\nconst VERB_GLYPH: Record<\n InboxKind,\n \"swords\" | \"flame\" | \"friends\" | \"coin\" | \"user\" | \"heart\" | \"chat\"\n> = {\n attack: \"swords\",\n raid: \"flame\",\n friend: \"friends\",\n sale: \"coin\",\n follow: \"user\",\n like: \"heart\",\n comment: \"chat\",\n reply: \"chat\",\n other: \"chat\",\n};\n\nfunction Inbox(): ReactNode {\n const client = useIDosGamesClient();\n const stagger = useStagger();\n const [items, setItems] = useState<InboxItem[] | null>(null);\n useEffect(() => {\n void client.social.getInbox({ limit: 30 }).then((r) => {\n setItems(r.ok ? ((r.data.Items ?? []) as InboxItem[]) : []);\n // Opening the news marks them seen (once, not per page).\n if (r.ok) void client.social.markInboxSeen();\n });\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bell\" text={t(\"noInbox\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((it, i) => {\n const kind = inboxKind(it.Verb);\n return (\n <div\n key={it.Id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph={VERB_GLYPH[kind]} size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {actorsText(\n it.LastActors,\n it.Count,\n t(\"someone\"),\n t(\"and\"),\n t(\"more\"),\n )}{\" \"}\n {t(VERB_TEXT[kind])}\n {it.Payload?.Title ? ` · ${it.Payload.Title}` : \"\"}\n </div>\n {it.OccurredAt ? (\n <div style={{ fontSize: 11, color: v.textDim }}>\n {new Date(it.OccurredAt).toLocaleString()}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction AddFriendPopup({\n onClose,\n onSent,\n}: {\n onClose: () => void;\n onSent: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const id = cleanUserID(value, client.auth.context?.userID);\n const send = async () => {\n if (!id) return;\n setBusy(true);\n const res = await client.social.sendFriendRequest(id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"notify\");\n toast(t(\"sent\"));\n onSent();\n onClose();\n };\n return (\n <Popup title={t(\"addFriend\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={t(\"playerID\")}\n autoFocus\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n />\n {value && !id ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"badID\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!id}\n busy={busy}\n onClick={() => void send()}\n >\n {t(\"send\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nfunction PlayerCard({\n player,\n features,\n onClose,\n onChange,\n}: {\n player: ProfileView;\n features: FeatureRegistry;\n onClose: () => void;\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [ask, setAsk] = useState<\"remove\" | \"block\" | null>(null);\n const hasChat = !!features.get(\"chat\");\n const hasMail = !!features.get(\"mailbox\")?.available;\n const isFriend =\n !!player.Source ||\n (\n client.data.user.state?.Social?.Accepted as\n Array<{ UserID?: string }> | undefined\n )?.some((f) => f.UserID === player.UserID);\n return (\n <Popup title={displayName(player)} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Avatar player={player} size={84} />\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n </div>\n <div\n style={{ fontFamily: \"monospace\", fontSize: 11, color: v.textDim }}\n >\n {player.UserID}\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {hasChat && isFriend ? (\n <Button\n tone=\"blue\"\n onClick={() => {\n // The chat opens in its own window — the card must not stay on top of it.\n onClose();\n features.open(\"chat\", { userID: player.UserID });\n }}\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"message\")}\n </Button>\n ) : null}\n {hasMail && isFriend ? (\n <Button\n tone=\"gold\"\n onClick={() => {\n // Gifts and transfers live in the mailbox — it opens straight on this friend.\n onClose();\n features.open(\"mailbox\", {\n friendUserID: player.UserID,\n friendName: displayName(player),\n });\n }}\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"gift\")}\n </Button>\n ) : null}\n {isFriend && canRemove(player) ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"remove\")}>\n {t(\"remove\")}\n </Button>\n ) : null}\n <Button tone=\"red\" onClick={() => setAsk(\"block\")}>\n {t(\"block\")}\n </Button>\n </div>\n {ask ? (\n <ConfirmPopup\n title={ask === \"remove\" ? t(\"remove\") : t(\"block\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={async () => {\n const res =\n ask === \"remove\"\n ? await client.social.removeFriend(player.UserID)\n : await client.social.block(\"User\", player.UserID);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n if (ask === \"block\") toast(t(\"blocked\"));\n onChange();\n onClose();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {ask === \"remove\" ? t(\"removeAsk\") : t(\"blockAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n"
|
|
47
|
+
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n Spinner,\n Tabs,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n actorsText,\n canRemove,\n cleanUserID,\n displayName,\n friendsBadge,\n inboxKind,\n initial,\n sortByName,\n type InboxKind,\n type ProfileView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Friends (Unity Runtime/UI/Friends): the friends list, requests (incoming and sent), suggestions\n// and the notification inbox as tabs. A player card, adding by ID and the confirmations are popups.\n// In \"friends from idosgames.com only\" mode the requests, suggestions and \"Add\" disappear.\n\ntype Tab = \"friends\" | \"requests\" | \"suggested\" | \"inbox\";\ntype InboxItem = {\n Id: string;\n Verb?: string | null;\n Count?: number | null;\n LastActors?: Array<{ DisplayName?: string | null } | null> | null;\n Payload?: { Title?: string | null } | null;\n OccurredAt?: string | null;\n};\n\n/** Keeps the badge: incoming requests (sent with the player state) + unread notifications. */\nexport function makeFriendsWatcher(features: FeatureRegistry) {\n return function FriendsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [unread, setUnread] = useState(0);\n useEffect(() => {\n const load = () =>\n void client.social\n .getInboxUnread()\n .then((r) => r.ok && setUnread(Number(r.data.UnreadCount ?? 0)));\n load();\n const id = setInterval(load, 5 * 60_000);\n const off = client.on(\"social:inboxBadge\", (r) =>\n setUnread(Number(r.UnreadCount ?? 0)),\n );\n return () => {\n clearInterval(id);\n off();\n };\n }, [client]);\n useEffect(\n () =>\n features.setBadge(\n \"friends\",\n friendsBadge(\n state?.Social as { IncomingCount?: number } | undefined,\n unread,\n ),\n ),\n [state?.Social, unread],\n );\n return null;\n };\n}\n\nexport function makeFriendsScreen(features: FeatureRegistry) {\n return function FriendsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"friends\");\n const [adding, setAdding] = useState(false);\n const [card, setCard] = useState<ProfileView | null>(null);\n const [version, setVersion] = useState(0);\n const requestsAllowed = client.social.friendRequestsAllowed();\n const incoming = Number(\n (state?.Social as { IncomingCount?: number } | undefined)\n ?.IncomingCount ?? 0,\n );\n const refresh = () => setVersion((n) => n + 1);\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"friends\", label: t(\"friends\") },\n ...(requestsAllowed\n ? [\n { id: \"requests\" as const, label: t(\"requests\"), badge: incoming },\n { id: \"suggested\" as const, label: t(\"suggested\") },\n ]\n : []),\n { id: \"inbox\", label: t(\"inbox\") },\n ];\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <MyID />\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n {requestsAllowed ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n onClick={() => setAdding(true)}\n data-tutorial-anchor=\"friends:add\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"add\")}\n </Button>\n ) : null}\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"friends\" ? <FriendList onPick={setCard} /> : null}\n {tab === \"requests\" ? (\n <Requests onChange={refresh} onPick={setCard} />\n ) : null}\n {tab === \"suggested\" ? <Suggested onPick={setCard} /> : null}\n {tab === \"inbox\" ? <Inbox /> : null}\n </div>\n {adding ? (\n <AddFriendPopup onClose={() => setAdding(false)} onSent={refresh} />\n ) : null}\n {card ? (\n <PlayerCard\n player={card}\n features={features}\n onClose={() => setCard(null)}\n onChange={refresh}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction MyID(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const id = client.auth.context?.userID ?? \"\";\n const copy = () =>\n void navigator.clipboard?.writeText(id).then(() => toast(t(\"copied\")));\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"yourID\")}\n </span>\n <span\n style={{\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n fontFamily: \"monospace\",\n fontSize: 13,\n }}\n >\n {id}\n </span>\n <Button size=\"sm\" tone=\"blue\" onClick={copy}>\n <Icon glyph=\"copy\" size={16} />\n </Button>\n </div>\n );\n}\n\nfunction Avatar({\n player,\n size = 44,\n}: {\n player: ProfileView;\n size?: number;\n}): ReactNode {\n const url = player.PublicData?.AvatarUrl;\n return (\n <div\n style={{\n width: size,\n height: size,\n flex: \"0 0 auto\",\n borderRadius: \"50%\",\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: `2px solid color-mix(in srgb, ${v.text} 60%, transparent)`,\n ...outlined,\n fontSize: size * 0.42,\n }}\n >\n {url ? (\n <img\n src={url}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : (\n initial(player)\n )}\n </div>\n );\n}\n\nfunction PlayerRow({\n player,\n index,\n onPick,\n right,\n}: {\n player: ProfileView;\n index: number;\n onPick?: (p: ProfileView) => void;\n right?: ReactNode;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div\n onClick={onPick ? () => onPick(player) : undefined}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Avatar player={player} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {displayName(player)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n {player.Source === \"Platform\" ? ` · ${t(\"platformFriend\")}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction useList(\n load: () => Promise<{\n ok: boolean;\n data?: { Friends?: ProfileView[] | null };\n }>,\n): ProfileView[] | null {\n const [list, setList] = useState<ProfileView[] | null>(null);\n useEffect(() => {\n void load().then((r) =>\n setList(r.ok ? sortByName(r.data?.Friends ?? []) : []),\n );\n }, [load]);\n return list;\n}\n\nfunction FriendList({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const list = useList(\n useCallback(() => client.social.getFriendsList(), [client]),\n );\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow key={p.UserID} player={p} index={i} onPick={onPick} />\n ))}\n </div>\n );\n}\n\nfunction Requests({\n onChange,\n onPick,\n}: {\n onChange: () => void;\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const incoming = useList(\n useCallback(() => client.social.getIncomingRequests(), [client]),\n );\n const outgoing = useList(\n useCallback(() => client.social.getOutgoingRequests(), [client]),\n );\n const [busy, setBusy] = useState<string | null>(null);\n if (!incoming || !outgoing)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (incoming.length === 0 && outgoing.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noRequests\")} />;\n const answer = async (id: string, yes: boolean) => {\n setBusy(id);\n const res = yes\n ? await client.social.acceptFriendRequest(id)\n : await client.social.declineFriendRequest(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (yes) kit.play(\"claim\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {incoming.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"incoming\")}\n </div>\n ) : null}\n {incoming.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <div\n style={{ display: \"flex\", gap: 6 }}\n onClick={(e) => e.stopPropagation()}\n >\n <Button\n size=\"sm\"\n tone=\"green\"\n busy={busy === p.UserID}\n onClick={() => void answer(p.UserID, true)}\n data-tutorial-anchor=\"friends:accept\"\n >\n {t(\"accept\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy === p.UserID}\n onClick={() => void answer(p.UserID, false)}\n >\n ✕\n </Button>\n </div>\n }\n />\n ))}\n {outgoing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"outgoing\")}\n </div>\n ) : null}\n {outgoing.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={incoming.length + i}\n onPick={onPick}\n right={<Icon glyph=\"clock\" size={20} />}\n />\n ))}\n </div>\n );\n}\n\nfunction Suggested({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const list = useList(\n useCallback(() => client.social.getRecommendedFriends(10), [client]),\n );\n const [sent, setSent] = useState<Set<string>>(new Set());\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noSuggestions\")} />;\n const add = async (id: string) => {\n const res = await client.social.sendFriendRequest(id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setSent((s) => new Set(s).add(id));\n toast(t(\"sent\"));\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <span onClick={(e) => e.stopPropagation()}>\n {sent.has(p.UserID) ? (\n <Icon glyph=\"check\" size={22} />\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n onClick={() => void add(p.UserID)}\n >\n {t(\"add\")}\n </Button>\n )}\n </span>\n }\n />\n ))}\n </div>\n );\n}\n\nconst VERB_TEXT: Record<InboxKind, Parameters<typeof t>[0]> = {\n attack: \"vAttack\",\n raid: \"vRaid\",\n friend: \"vFriend\",\n sale: \"vSale\",\n follow: \"vFollow\",\n like: \"vLike\",\n comment: \"vComment\",\n reply: \"vReply\",\n other: \"vOther\",\n};\nconst VERB_GLYPH: Record<\n InboxKind,\n \"swords\" | \"flame\" | \"friends\" | \"coin\" | \"user\" | \"heart\" | \"chat\"\n> = {\n attack: \"swords\",\n raid: \"flame\",\n friend: \"friends\",\n sale: \"coin\",\n follow: \"user\",\n like: \"heart\",\n comment: \"chat\",\n reply: \"chat\",\n other: \"chat\",\n};\n\nfunction Inbox(): ReactNode {\n const client = useIDosGamesClient();\n const stagger = useStagger();\n const [items, setItems] = useState<InboxItem[] | null>(null);\n useEffect(() => {\n void client.social.getInbox({ limit: 30 }).then((r) => {\n setItems(r.ok ? ((r.data.Items ?? []) as InboxItem[]) : []);\n // Opening the news marks them seen (once, not per page).\n if (r.ok) void client.social.markInboxSeen();\n });\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bell\" text={t(\"noInbox\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((it, i) => {\n const kind = inboxKind(it.Verb);\n return (\n <div\n key={it.Id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph={VERB_GLYPH[kind]} size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {actorsText(\n it.LastActors,\n it.Count,\n t(\"someone\"),\n t(\"and\"),\n t(\"more\"),\n )}{\" \"}\n {t(VERB_TEXT[kind])}\n {it.Payload?.Title ? ` · ${it.Payload.Title}` : \"\"}\n </div>\n {it.OccurredAt ? (\n <div style={{ fontSize: 11, color: v.textDim }}>\n {new Date(it.OccurredAt).toLocaleString()}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction AddFriendPopup({\n onClose,\n onSent,\n}: {\n onClose: () => void;\n onSent: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const id = cleanUserID(value, client.auth.context?.userID);\n const send = async () => {\n if (!id) return;\n setBusy(true);\n const res = await client.social.sendFriendRequest(id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"notify\");\n toast(t(\"sent\"));\n onSent();\n onClose();\n };\n return (\n <Popup title={t(\"addFriend\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={t(\"playerID\")}\n autoFocus\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n />\n {value && !id ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"badID\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!id}\n busy={busy}\n onClick={() => void send()}\n >\n {t(\"send\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nfunction PlayerCard({\n player,\n features,\n onClose,\n onChange,\n}: {\n player: ProfileView;\n features: FeatureRegistry;\n onClose: () => void;\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [ask, setAsk] = useState<\"remove\" | \"block\" | null>(null);\n const hasChat = !!features.get(\"chat\");\n const hasMail = !!features.get(\"mailbox\")?.available;\n const isFriend =\n !!player.Source ||\n (\n client.data.user.state?.Social?.Accepted as\n Array<{ UserID?: string }> | undefined\n )?.some((f) => f.UserID === player.UserID);\n return (\n <Popup title={displayName(player)} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Avatar player={player} size={84} />\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n </div>\n <div\n style={{ fontFamily: \"monospace\", fontSize: 11, color: v.textDim }}\n >\n {player.UserID}\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {hasChat && isFriend ? (\n <Button\n tone=\"blue\"\n onClick={() => {\n // The chat opens in its own window — the card must not stay on top of it.\n onClose();\n features.open(\"chat\", { userID: player.UserID });\n }}\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"message\")}\n </Button>\n ) : null}\n {hasMail && isFriend ? (\n <Button\n tone=\"gold\"\n onClick={() => {\n // Gifts and transfers live in the mailbox — it opens straight on this friend.\n onClose();\n features.open(\"mailbox\", {\n friendUserID: player.UserID,\n friendName: displayName(player),\n });\n }}\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"gift\")}\n </Button>\n ) : null}\n {isFriend && canRemove(player) ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"remove\")}>\n {t(\"remove\")}\n </Button>\n ) : null}\n <Button tone=\"red\" onClick={() => setAsk(\"block\")}>\n {t(\"block\")}\n </Button>\n </div>\n {ask ? (\n <ConfirmPopup\n title={ask === \"remove\" ? t(\"remove\") : t(\"block\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={async () => {\n const res =\n ask === \"remove\"\n ? await client.social.removeFriend(player.UserID)\n : await client.social.block(\"User\", player.UserID);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n if (ask === \"block\") toast(t(\"blocked\"));\n onChange();\n onClose();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {ask === \"remove\" ? t(\"removeAsk\") : t(\"blockAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n"
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
"path": "i18n.ts",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"path": "module.meta.json",
|
|
67
|
-
"content": "{\n \"id\": \"friends\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Friends for any game: the friends list, requests, suggestions and the notification inbox, with a player card and add-by-ID in popups.\",\n \"description\": \"A ready friends screen (Unity Runtime/UI/Friends) for ANY game, built on the @idosgames/react/ui kit and client.social. Tabs: friends, requests (incoming with accept / decline, and the ones you sent), suggestions (players active in this game lately) and news — the notification inbox (attacks, raids, new friends, sales, follows, likes, comments), opened as seen. The player's own ID sits on top with a copy button. Everything detailed is a popup: adding a friend by ID, the player card (level, power; message — opens the chat feature when chat is installed; remove friend; block) and the confirmations. When the title takes friends from idosgames.com only, requests, suggestions and 'Add' disappear and platform friends cannot be removed in the game. Registers the 'friends' feature; its badge counts requests waiting and unread news. No title settings needed.\",\n \"provides\": [\n \"friends list\",\n \"incoming and sent friend requests\",\n \"friend suggestions\",\n \"notification inbox\",\n \"add a friend by ID\",\n \"player card with message, remove and block\",\n \"friends badge\"\n ],\n \"tags\": [\n \"friends\",\n \"social\",\n \"requests\",\n \"inbox\",\n \"notifications\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
67
|
+
"content": "{\n \"id\": \"friends\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Friends for any game: the friends list, requests, suggestions and the notification inbox, with a player card and add-by-ID in popups.\",\n \"description\": \"A ready friends screen (Unity Runtime/UI/Friends) for ANY game, built on the @idosgames/react/ui kit and client.social. Tabs: friends, requests (incoming with accept / decline, and the ones you sent), suggestions (players active in this game lately) and news — the notification inbox (attacks, raids, new friends, sales, follows, likes, comments), opened as seen. The player's own ID sits on top with a copy button. Everything detailed is a popup: adding a friend by ID, the player card (level, power; message — opens the chat feature when chat is installed; remove friend; block) and the confirmations. When the title takes friends from idosgames.com only, requests, suggestions and 'Add' disappear and platform friends cannot be removed in the game. Registers the 'friends' feature; its badge counts requests waiting and unread news. No title settings needed.\",\n \"provides\": [\n \"friends list\",\n \"incoming and sent friend requests\",\n \"friend suggestions\",\n \"notification inbox\",\n \"add a friend by ID\",\n \"player card with message, remove and block\",\n \"friends badge\"\n ],\n \"tags\": [\n \"friends\",\n \"social\",\n \"requests\",\n \"inbox\",\n \"notifications\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
|
|
68
68
|
}
|
|
69
69
|
]
|
|
70
70
|
}
|
|
@@ -49,10 +49,10 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@idosgames/core": "0.
|
|
53
|
-
"@idosgames/module-sdk": "0.
|
|
54
|
-
"@idosgames/react": "0.
|
|
55
|
-
"@idosgames/wallet": "0.6.
|
|
52
|
+
"@idosgames/core": "0.20.0",
|
|
53
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
54
|
+
"@idosgames/react": "0.4.2",
|
|
55
|
+
"@idosgames/wallet": "0.6.1",
|
|
56
56
|
"@tanstack/react-query": "5.101.2",
|
|
57
57
|
"phaser": "4.2.1",
|
|
58
58
|
"react": "19.2.7",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"version": "0.1.0"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.
|
|
37
|
-
"@idosgames/react": "0.
|
|
35
|
+
"@idosgames/core": "0.20.0",
|
|
36
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
37
|
+
"@idosgames/react": "0.4.2",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "da42d93b3d21d21109e0f35338c349042dbb01ef442701439dc1d9d4ddbff98d",
|
|
@@ -32,15 +32,15 @@
|
|
|
32
32
|
"name": "iDos Games",
|
|
33
33
|
"url": "https://idosgames.com"
|
|
34
34
|
},
|
|
35
|
-
"version": "0.1.
|
|
35
|
+
"version": "0.1.1"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@idosgames/core": "0.
|
|
39
|
-
"@idosgames/module-sdk": "0.
|
|
40
|
-
"@idosgames/react": "0.
|
|
38
|
+
"@idosgames/core": "0.20.0",
|
|
39
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
40
|
+
"@idosgames/react": "0.4.2",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
|
-
"contentHash": "
|
|
43
|
+
"contentHash": "f3ed6702817b2217cee7bf25accd5dbb138d6657bb7f15edcc4cc8c03a26b01f",
|
|
44
44
|
"files": [
|
|
45
45
|
{
|
|
46
46
|
"path": "i18n.ts",
|
|
@@ -60,11 +60,11 @@
|
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"path": "ReferralScreen.tsx",
|
|
63
|
-
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n Spinner,\n errorText,\n formatAmount,\n grantLines,\n grantedBy,\n outlined,\n panel,\n plural,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function makeReferralWatcher(features: FeatureRegistry) {\n return function ReferralWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const defs = useRef<ReferralDefinitions | null>(null);\n useEffect(() => {\n void client.referral.getDefinitions().then((r) => {\n defs.current = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current?.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n });\n }, [client]);\n useEffect(() => {\n if (defs.current)\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current.InviteRewards,\n state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n }, [state?.Referral]);\n return null;\n };\n}\n\n/**\n * `enabled` — whether the title has invites on (Referral.IsEnabled). The tab is always in the lobby;\n * with invites off the screen says so and makes no request (the referral reads mint a code).\n */\nexport function makeReferralScreen(enabled: boolean) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n return enabled ? (\n <Referral />\n ) : (\n <EmptyState glyph=\"link\" text={t(\"disabled\")} />\n );\n };\n}\n\nfunction Referral(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || !defs.IsEnabled)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #2fb36b 0%, #146b3d 100%)\",\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>{t(\"joined\")}</div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {me?.SubscribedToUserID ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
|
|
63
|
+
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n plural,\n Popup,\n ResourceList,\n Spinner,\n toneSurface,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function makeReferralWatcher(features: FeatureRegistry) {\n return function ReferralWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const defs = useRef<ReferralDefinitions | null>(null);\n useEffect(() => {\n void client.referral.getDefinitions().then((r) => {\n defs.current = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current?.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n });\n }, [client]);\n useEffect(() => {\n if (defs.current)\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current.InviteRewards,\n state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n }, [state?.Referral]);\n return null;\n };\n}\n\n/**\n * `enabled` — whether the title has invites on (Referral.IsEnabled). The tab is always in the lobby;\n * with invites off the screen says so and makes no request (the referral reads mint a code).\n */\nexport function makeReferralScreen(enabled: boolean) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n return enabled ? (\n <Referral />\n ) : (\n <EmptyState glyph=\"link\" text={t(\"disabled\")} />\n );\n };\n}\n\nfunction Referral(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || !defs.IsEnabled)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"green\"),\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>{t(\"joined\")}</div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {me?.SubscribedToUserID ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"path": "module.meta.json",
|
|
67
|
-
"content": "{\n \"id\": \"invite\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Invite friends for any game: your code and link to share, friends who came by it, rewards for them, and entering a friend's code in a popup.\",\n \"description\": \"A ready invite-a-friend screen (Unity V2/Runtime/UI/Referral) for ANY game, built on the @idosgames/react/ui kit and client.referral. The player's own short code (minted by the server when the screen opens) with 'Copy' and 'Share link' (the system share sheet, else the clipboard), how many friends came by it and the reward ladder for them (claim one or all). Entering a friend's code is a popup: it shows the newcomer's gift, reads the code the way the server does (8 letters, separators and case ignored) and pays the gift once. A player bound at login by an invite link sees 'You joined by a friend's code'. Registers the 'invite' feature — always shown in the lobby; while the title has invites off (Referral.IsEnabled, off by default) the screen says so and makes no request. Its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.\",\n \"provides\": [\n \"own invite code and link\",\n \"share or copy the invite\",\n \"friends who came by your code\",\n \"invite reward ladder with claim all\",\n \"enter a friend's code popup\",\n \"invite badge\",\n \"demo referral rewards\"\n ],\n \"tags\": [\"referral\", \"invite\", \"friends\", \"growth\", \"social\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
67
|
+
"content": "{\n \"id\": \"invite\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Invite friends for any game: your code and link to share, friends who came by it, rewards for them, and entering a friend's code in a popup.\",\n \"description\": \"A ready invite-a-friend screen (Unity V2/Runtime/UI/Referral) for ANY game, built on the @idosgames/react/ui kit and client.referral. The player's own short code (minted by the server when the screen opens) with 'Copy' and 'Share link' (the system share sheet, else the clipboard), how many friends came by it and the reward ladder for them (claim one or all). Entering a friend's code is a popup: it shows the newcomer's gift, reads the code the way the server does (8 letters, separators and case ignored) and pays the gift once. A player bound at login by an invite link sees 'You joined by a friend's code'. Registers the 'invite' feature — always shown in the lobby; while the title has invites off (Referral.IsEnabled, off by default) the screen says so and makes no request. Its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.\",\n \"provides\": [\n \"own invite code and link\",\n \"share or copy the invite\",\n \"friends who came by your code\",\n \"invite reward ladder with claim all\",\n \"enter a friend's code popup\",\n \"invite badge\",\n \"demo referral rewards\"\n ],\n \"tags\": [\"referral\", \"invite\", \"friends\", \"growth\", \"social\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
|
|
68
68
|
}
|
|
69
69
|
]
|
|
70
70
|
}
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"name": "iDos Games",
|
|
34
34
|
"url": "https://idosgames.com"
|
|
35
35
|
},
|
|
36
|
-
"version": "0.1.
|
|
36
|
+
"version": "0.1.1"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.
|
|
41
|
-
"@idosgames/react": "0.
|
|
39
|
+
"@idosgames/core": "0.20.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
41
|
+
"@idosgames/react": "0.4.2",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "6d86641300a3b71c007d01eb4bc7ac345eef9ca99e99dda75a40d252c50c23f3",
|
|
45
45
|
"files": [
|
|
46
46
|
{
|
|
47
47
|
"path": "i18n.ts",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "LeaderboardScreen.tsx",
|
|
56
|
-
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n GetLeaderboardResponse,\n GetMyProgressResponse,\n LeaderboardUserEntry,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n configSection,\n errorText,\n formatAmount,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boardAction,\n cachedProgress,\n enabledBoards,\n milestoneRows,\n rewardForRank,\n type LeaderboardDefinitionView,\n type LeaderboardSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards (Unity Runtime/UI/Leaderboard): the boards as tabs, a card with my score, my rank and\n// the countdown, and the top list — the player highlighted. Rank rewards and score milestones open\n// as popups (a list of rewards does not belong on the main screen). A game that has something to\n// PLAY for the score registers a \"play\" feature, and a Play button appears here.\n\nconst MEDALS = [\"#ffd24a\", \"#d9e1f2\", \"#e0955a\"];\n\nexport function makeLeaderboardScreen(features: FeatureRegistry) {\n return function LeaderboardScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Leaderboards\n features={features}\n initial={\n typeof args?.leaderboardID === \"string\" ? args.leaderboardID : null\n }\n />\n );\n };\n}\n\nfunction Leaderboards({\n features,\n initial,\n}: {\n features: FeatureRegistry;\n initial: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const now = useNow();\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n const [boardID, setBoardID] = useState<string | null>(initial);\n const board = boards.find((b) => b.LeaderboardID === boardID) ?? boards[0];\n const id = board?.LeaderboardID;\n\n const [top, setTop] = useState<GetLeaderboardResponse | null>(null);\n const [progress, setProgress] = useState<GetMyProgressResponse | null>(null);\n const [loading, setLoading] = useState(true);\n const [popup, setPopup] = useState<\"ranks\" | \"milestones\" | null>(null);\n\n const load = useCallback(async () => {\n if (!id) return;\n const [a, b] = await Promise.all([\n client.leaderboard.getLeaderboard(id),\n client.leaderboard.getMyProgress(id),\n ]);\n setLoading(false);\n if (a.ok) setTop(a.data);\n else toast(errorText(a.error), \"error\");\n if (b.ok) setProgress(b.data);\n }, [client, id, toast]);\n\n useEffect(() => {\n setLoading(true);\n void load();\n }, [load]);\n\n // A score submitted anywhere in the game refreshes the table.\n useEffect(\n () => client.on(\"leaderboard:scoreSubmitted\", () => void load()),\n [client, load],\n );\n\n if (!board || !id) return <EmptyState glyph=\"trophy\" text={t(\"noBoards\")} />;\n\n const me = client.auth.context?.userID;\n const users = (top?.TopUsers ?? [])\n .slice()\n .sort((a, b) => (a.Rank ?? 1e9) - (b.Rank ?? 1e9));\n const milestoneCount = Object.keys(board.Milestones ?? {}).length;\n // An invites board asks to invite (the referral screen), any other one to play.\n const action = boardAction(board);\n const cta =\n action === \"invite\"\n ? features.get(\"invite\")\n ? \"invite\"\n : null\n : features.get(\"play\")\n ? \"play\"\n : null;\n\n const hero = (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #5a3fd0 0%, #25207a 100%)\",\n }}\n >\n <Icon glyph=\"trophy\" size={56} />\n <div style={{ ...outlined, fontSize: 20, textAlign: \"center\" }}>\n {catalog.localize(board.DisplayName ?? id)}\n </div>\n <Timer at={top?.CycleEndUtc} now={now} label={`⏱ ${t(\"endsIn\")}`} />\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={\n board.ScoreDisplayName\n ? catalog.localize(board.ScoreDisplayName)\n : t(\"myScore\")\n }\n value={formatAmount(Number(progress?.CurrentScore ?? 0))}\n />\n <Stat\n label={t(\"myRank\")}\n value={progress?.LastKnownRank ? `#${progress.LastKnownRank}` : \"—\"}\n />\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {cta === \"play\" ? (\n <Button\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </Button>\n ) : cta === \"invite\" ? (\n <Button\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"invite\")}\n </Button>\n ) : null}\n {(board.RankRewards?.length ?? 0) > 0 ? (\n <Button tone=\"blue\" size=\"sm\" onClick={() => setPopup(\"ranks\")}>\n <Icon glyph=\"gift\" size={18} /> {t(\"rankRewards\")}\n </Button>\n ) : null}\n {milestoneCount > 0 ? (\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup(\"milestones\")}\n >\n <Icon glyph=\"star\" size={18} /> {t(\"milestones\")}\n </Button>\n <Badge count={progress?.HasUnclaimedMilestone ? 1 : 0} dot />\n </span>\n ) : null}\n </div>\n </div>\n );\n\n const list = (\n <div>\n <SectionTitle>{t(\"top\")}</SectionTitle>\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : users.length === 0 ? (\n <EmptyState glyph=\"trophy\" text={t(\"empty\")} />\n ) : (\n <TopList users={users} me={me} />\n )}\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 16 }}>\n <Tabs\n tabs={boards.map((b) => ({\n id: b.LeaderboardID,\n label: catalog.localize(b.DisplayName ?? b.LeaderboardID),\n }))}\n value={id}\n onChange={setBoardID}\n />\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 5fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {hero}\n {list}\n </div>\n ) : (\n <>\n {hero}\n {list}\n </>\n )}\n {popup === \"ranks\" ? (\n <RankRewardsPopup\n def={board}\n myRank={progress?.LastKnownRank ?? null}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup === \"milestones\" ? (\n <MilestonesPopup\n def={board}\n progress={progress}\n onClaimed={load}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nfunction TopList({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {users.slice(0, 100).map((entry, i) => {\n const place = entry.Rank ?? i + 1;\n const mine = entry.UserID === me;\n const medal = place <= 3 ? MEDALS[place - 1] : undefined;\n const name =\n entry.PublicProfile?.Username ||\n (mine ? t(\"you\") : `${t(\"player\")} ${entry.UserID.slice(-4)}`);\n return (\n <div\n key={entry.UserID}\n style={{\n ...panel,\n padding: \"8px 12px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n border: mine ? `2px solid ${v.gold}` : panel.border,\n background: mine\n ? \"linear-gradient(180deg, #4b5ae0 0%, #2a2f86 100%)\"\n : panel.background,\n ...stagger(i),\n }}\n >\n <span\n style={{\n width: 34,\n height: 34,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: medal ?? \"rgba(0,0,0,.3)\",\n ...outlined,\n fontSize: 15,\n color: medal ? \"#5a3a00\" : v.text,\n textShadow: medal ? \"none\" : outlined.textShadow,\n flex: \"none\",\n }}\n >\n {place}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 15,\n flex: 1,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n {mine && entry.PublicProfile?.Username ? ` (${t(\"you\")})` : \"\"}\n </span>\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 16,\n color: v.gold,\n }}\n >\n <Icon glyph=\"star\" size={18} />\n {formatAmount(Number(entry.Score ?? 0))}\n </span>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction RankRewardsPopup({\n def,\n myRank,\n onClose,\n}: {\n def: LeaderboardDefinitionView;\n myRank: number | null;\n onClose: () => void;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <Popup title={t(\"rankRewards\")} onClose={onClose}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {(def.RankRewards ?? []).map((r, i) => {\n const lines = grantLines(r?.Rewards);\n const mineHere =\n myRank !== null &&\n rewardForRank({ RankRewards: [r] }, myRank).length > 0;\n return (\n <Card\n key={r?.Rank ?? i}\n highlight={mineHere}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n padding: \"10px 12px\",\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 16 }}>#{r?.Rank}</span>\n <ResourceList lines={lines} />\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction MilestonesPopup({\n def,\n progress,\n onClaimed,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n progress: GetMyProgressResponse | null;\n onClaimed: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const earned = Number(\n progress?.MilestoneTokensEarned ?? progress?.ScoreEarnedThisCycle ?? 0,\n );\n const rows = milestoneRows(\n def,\n earned,\n cachedProgress(state?.Leaderboard, def.LeaderboardID).claimed,\n );\n\n const claim = async (milestoneID: string) => {\n setBusy(milestoneID);\n const res = await client.leaderboard.claimMilestone(\n def.LeaderboardID,\n milestoneID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onClaimed();\n };\n\n return (\n <Popup title={t(\"milestones\")} onClose={onClose} width={440}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {rows.map((row, i) => (\n <Card\n key={row.id}\n highlight={row.state === \"ready\"}\n style={{\n display: \"grid\",\n gap: 8,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </span>\n <ResourceList lines={row.reward} />\n </div>\n <ProgressBar\n value={Math.min(earned, row.required)}\n max={row.required}\n tone={row.state === \"ready\" ? \"gold\" : \"green\"}\n />\n {row.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === row.id}\n onClick={() => void claim(row.id)}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <div style={{ justifySelf: \"center\" }}>\n <Icon glyph=\"check\" size={26} />\n </div>\n ) : null}\n </Card>\n ))}\n </div>\n </Popup>\n );\n}\n\n/**\n * The reward of a finished round pops up by itself (Unity LeaderboardCycleRewardPopup): the place\n * the player took, what it earns, and \"Claim\". An always-on panel; it also keeps the badge.\n */\nexport function makeCycleRewardWatcher(features: FeatureRegistry) {\n return function CycleRewardWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const [pending, setPending] = useState<{\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n } | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n const check = async () => {\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n if (boards.length === 0) return;\n const res = await client.leaderboard.getProgressBatch(\n boards.map((b) => b.LeaderboardID),\n );\n if (cancelled || !res.ok) return;\n const progress = res.data.map((r) =>\n r.Success ? (r.Data ?? null) : null,\n );\n features.setBadge(\n \"leaderboard\",\n progress.filter(\n (p) => p?.HasUnclaimedReward || p?.HasUnclaimedMilestone,\n ).length,\n );\n const withReward = progress.find((p) => p?.HasUnclaimedReward);\n const def = withReward\n ? boards.find((b) => b.LeaderboardID === withReward.LeaderboardID)\n : undefined;\n if (withReward && def)\n setPending({ def, rank: withReward.LastKnownRank ?? null });\n };\n void check();\n const offA = client.on(\n \"leaderboard:cycleRewardClaimed\",\n () => void check(),\n );\n const offB = client.on(\n \"leaderboard:milestoneClaimed\",\n () => void check(),\n );\n return () => {\n cancelled = true;\n offA();\n offB();\n };\n }, [client]);\n\n if (!pending) return null;\n return (\n <CycleRewardPopup\n def={pending.def}\n rank={pending.rank}\n onClose={() => setPending(null)}\n />\n );\n };\n}\n\nfunction CycleRewardPopup({\n def,\n rank,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const lines = rewardForRank(def, rank);\n return (\n <Popup title={t(\"lastCycle\")} onClose={onClose} variant=\"center\" queued>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"trophy\" size={84} />\n </div>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(def.DisplayName ?? def.LeaderboardID)}\n </div>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"yourPlace\")}\n </div>\n <div style={{ ...outlined, fontSize: 34, color: v.gold }}>\n {rank ? `#${rank}` : t(\"notRanked\")}\n </div>\n {lines.length > 0 ? (\n <>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={lines} size={26} gap={14} />\n </>\n ) : null}\n </div>\n <ClaimCycle leaderboardID={def.LeaderboardID} />\n </Popup>\n );\n}\n\nfunction ClaimCycle({ leaderboardID }: { leaderboardID: string }): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.leaderboard.claimCycleReward(leaderboardID).then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n close();\n celebrate(grantedBy(res.data));\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n"
|
|
56
|
+
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n GetLeaderboardResponse,\n GetMyProgressResponse,\n LeaderboardUserEntry,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Card,\n Center,\n configSection,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boardAction,\n cachedProgress,\n enabledBoards,\n milestoneRows,\n rewardForRank,\n type LeaderboardDefinitionView,\n type LeaderboardSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards (Unity Runtime/UI/Leaderboard): the boards as tabs, a card with my score, my rank and\n// the countdown, and the top list — the player highlighted. Rank rewards and score milestones open\n// as popups (a list of rewards does not belong on the main screen). A game that has something to\n// PLAY for the score registers a \"play\" feature, and a Play button appears here.\n\nconst MEDALS = [\"#ffd24a\", \"#d9e1f2\", \"#e0955a\"];\n\nexport function makeLeaderboardScreen(features: FeatureRegistry) {\n return function LeaderboardScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Leaderboards\n features={features}\n initial={\n typeof args?.leaderboardID === \"string\" ? args.leaderboardID : null\n }\n />\n );\n };\n}\n\nfunction Leaderboards({\n features,\n initial,\n}: {\n features: FeatureRegistry;\n initial: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const now = useNow();\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n const [boardID, setBoardID] = useState<string | null>(initial);\n const board = boards.find((b) => b.LeaderboardID === boardID) ?? boards[0];\n const id = board?.LeaderboardID;\n\n const [top, setTop] = useState<GetLeaderboardResponse | null>(null);\n const [progress, setProgress] = useState<GetMyProgressResponse | null>(null);\n const [loading, setLoading] = useState(true);\n const [popup, setPopup] = useState<\"ranks\" | \"milestones\" | null>(null);\n\n const load = useCallback(async () => {\n if (!id) return;\n const [a, b] = await Promise.all([\n client.leaderboard.getLeaderboard(id),\n client.leaderboard.getMyProgress(id),\n ]);\n setLoading(false);\n if (a.ok) setTop(a.data);\n else toast(errorText(a.error), \"error\");\n if (b.ok) setProgress(b.data);\n }, [client, id, toast]);\n\n useEffect(() => {\n setLoading(true);\n void load();\n }, [load]);\n\n // A score submitted anywhere in the game refreshes the table.\n useEffect(\n () => client.on(\"leaderboard:scoreSubmitted\", () => void load()),\n [client, load],\n );\n\n if (!board || !id) return <EmptyState glyph=\"trophy\" text={t(\"noBoards\")} />;\n\n const me = client.auth.context?.userID;\n const users = (top?.TopUsers ?? [])\n .slice()\n .sort((a, b) => (a.Rank ?? 1e9) - (b.Rank ?? 1e9));\n const milestoneCount = Object.keys(board.Milestones ?? {}).length;\n // An invites board asks to invite (the referral screen), any other one to play.\n const action = boardAction(board);\n const cta =\n action === \"invite\"\n ? features.get(\"invite\")\n ? \"invite\"\n : null\n : features.get(\"play\")\n ? \"play\"\n : null;\n\n const hero = (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"blue\"),\n }}\n >\n <Icon glyph=\"trophy\" size={56} />\n <div style={{ ...outlined, fontSize: 20, textAlign: \"center\" }}>\n {catalog.localize(board.DisplayName ?? id)}\n </div>\n <Timer at={top?.CycleEndUtc} now={now} label={`⏱ ${t(\"endsIn\")}`} />\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={\n board.ScoreDisplayName\n ? catalog.localize(board.ScoreDisplayName)\n : t(\"myScore\")\n }\n value={formatAmount(Number(progress?.CurrentScore ?? 0))}\n />\n <Stat\n label={t(\"myRank\")}\n value={progress?.LastKnownRank ? `#${progress.LastKnownRank}` : \"—\"}\n />\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {cta === \"play\" ? (\n <Button\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </Button>\n ) : cta === \"invite\" ? (\n <Button\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"invite\")}\n </Button>\n ) : null}\n {(board.RankRewards?.length ?? 0) > 0 ? (\n <Button tone=\"blue\" size=\"sm\" onClick={() => setPopup(\"ranks\")}>\n <Icon glyph=\"gift\" size={18} /> {t(\"rankRewards\")}\n </Button>\n ) : null}\n {milestoneCount > 0 ? (\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup(\"milestones\")}\n >\n <Icon glyph=\"star\" size={18} /> {t(\"milestones\")}\n </Button>\n <Badge count={progress?.HasUnclaimedMilestone ? 1 : 0} dot />\n </span>\n ) : null}\n </div>\n </div>\n );\n\n const list = (\n <div>\n <SectionTitle>{t(\"top\")}</SectionTitle>\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : users.length === 0 ? (\n <EmptyState glyph=\"trophy\" text={t(\"empty\")} />\n ) : (\n <TopList users={users} me={me} />\n )}\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 16 }}>\n <Tabs\n tabs={boards.map((b) => ({\n id: b.LeaderboardID,\n label: catalog.localize(b.DisplayName ?? b.LeaderboardID),\n }))}\n value={id}\n onChange={setBoardID}\n />\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 5fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {hero}\n {list}\n </div>\n ) : (\n <>\n {hero}\n {list}\n </>\n )}\n {popup === \"ranks\" ? (\n <RankRewardsPopup\n def={board}\n myRank={progress?.LastKnownRank ?? null}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup === \"milestones\" ? (\n <MilestonesPopup\n def={board}\n progress={progress}\n onClaimed={load}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nfunction TopList({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {users.slice(0, 100).map((entry, i) => {\n const place = entry.Rank ?? i + 1;\n const mine = entry.UserID === me;\n const medal = place <= 3 ? MEDALS[place - 1] : undefined;\n const name =\n entry.PublicProfile?.Username ||\n (mine ? t(\"you\") : `${t(\"player\")} ${entry.UserID.slice(-4)}`);\n return (\n <div\n key={entry.UserID}\n style={{\n ...panel,\n padding: \"8px 12px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n border: mine ? `2px solid ${v.gold}` : panel.border,\n ...(mine\n ? toneSurface(\"blue\")\n : { background: panel.background }),\n ...stagger(i),\n }}\n >\n <span\n style={{\n width: 34,\n height: 34,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: medal ?? v.wellSoft,\n ...outlined,\n fontSize: 15,\n color: medal ? \"#5a3a00\" : v.text,\n textShadow: medal ? \"none\" : outlined.textShadow,\n flex: \"none\",\n }}\n >\n {place}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 15,\n flex: 1,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n {mine && entry.PublicProfile?.Username ? ` (${t(\"you\")})` : \"\"}\n </span>\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 16,\n color: v.gold,\n }}\n >\n <Icon glyph=\"star\" size={18} />\n {formatAmount(Number(entry.Score ?? 0))}\n </span>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction RankRewardsPopup({\n def,\n myRank,\n onClose,\n}: {\n def: LeaderboardDefinitionView;\n myRank: number | null;\n onClose: () => void;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <Popup title={t(\"rankRewards\")} onClose={onClose}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {(def.RankRewards ?? []).map((r, i) => {\n const lines = grantLines(r?.Rewards);\n const mineHere =\n myRank !== null &&\n rewardForRank({ RankRewards: [r] }, myRank).length > 0;\n return (\n <Card\n key={r?.Rank ?? i}\n highlight={mineHere}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n padding: \"10px 12px\",\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 16 }}>#{r?.Rank}</span>\n <ResourceList lines={lines} />\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction MilestonesPopup({\n def,\n progress,\n onClaimed,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n progress: GetMyProgressResponse | null;\n onClaimed: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const earned = Number(\n progress?.MilestoneTokensEarned ?? progress?.ScoreEarnedThisCycle ?? 0,\n );\n const rows = milestoneRows(\n def,\n earned,\n cachedProgress(state?.Leaderboard, def.LeaderboardID).claimed,\n );\n\n const claim = async (milestoneID: string) => {\n setBusy(milestoneID);\n const res = await client.leaderboard.claimMilestone(\n def.LeaderboardID,\n milestoneID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onClaimed();\n };\n\n return (\n <Popup title={t(\"milestones\")} onClose={onClose} width={440}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {rows.map((row, i) => (\n <Card\n key={row.id}\n highlight={row.state === \"ready\"}\n style={{\n display: \"grid\",\n gap: 8,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </span>\n <ResourceList lines={row.reward} />\n </div>\n <ProgressBar\n value={Math.min(earned, row.required)}\n max={row.required}\n tone={row.state === \"ready\" ? \"gold\" : \"green\"}\n />\n {row.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === row.id}\n onClick={() => void claim(row.id)}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <div style={{ justifySelf: \"center\" }}>\n <Icon glyph=\"check\" size={26} />\n </div>\n ) : null}\n </Card>\n ))}\n </div>\n </Popup>\n );\n}\n\n/**\n * The reward of a finished round pops up by itself (Unity LeaderboardCycleRewardPopup): the place\n * the player took, what it earns, and \"Claim\". An always-on panel; it also keeps the badge.\n */\nexport function makeCycleRewardWatcher(features: FeatureRegistry) {\n return function CycleRewardWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const [pending, setPending] = useState<{\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n } | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n const check = async () => {\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n if (boards.length === 0) return;\n const res = await client.leaderboard.getProgressBatch(\n boards.map((b) => b.LeaderboardID),\n );\n if (cancelled || !res.ok) return;\n const progress = res.data.map((r) =>\n r.Success ? (r.Data ?? null) : null,\n );\n features.setBadge(\n \"leaderboard\",\n progress.filter(\n (p) => p?.HasUnclaimedReward || p?.HasUnclaimedMilestone,\n ).length,\n );\n const withReward = progress.find((p) => p?.HasUnclaimedReward);\n const def = withReward\n ? boards.find((b) => b.LeaderboardID === withReward.LeaderboardID)\n : undefined;\n if (withReward && def)\n setPending({ def, rank: withReward.LastKnownRank ?? null });\n };\n void check();\n const offA = client.on(\n \"leaderboard:cycleRewardClaimed\",\n () => void check(),\n );\n const offB = client.on(\n \"leaderboard:milestoneClaimed\",\n () => void check(),\n );\n return () => {\n cancelled = true;\n offA();\n offB();\n };\n }, [client]);\n\n if (!pending) return null;\n return (\n <CycleRewardPopup\n def={pending.def}\n rank={pending.rank}\n onClose={() => setPending(null)}\n />\n );\n };\n}\n\nfunction CycleRewardPopup({\n def,\n rank,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const lines = rewardForRank(def, rank);\n return (\n <Popup title={t(\"lastCycle\")} onClose={onClose} variant=\"center\" queued>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"trophy\" size={84} />\n </div>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(def.DisplayName ?? def.LeaderboardID)}\n </div>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"yourPlace\")}\n </div>\n <div style={{ ...outlined, fontSize: 34, color: v.gold }}>\n {rank ? `#${rank}` : t(\"notRanked\")}\n </div>\n {lines.length > 0 ? (\n <>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={lines} size={26} gap={14} />\n </>\n ) : null}\n </div>\n <ClaimCycle leaderboardID={def.LeaderboardID} />\n </Popup>\n );\n}\n\nfunction ClaimCycle({ leaderboardID }: { leaderboardID: string }): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.leaderboard.claimCycleReward(leaderboardID).then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n close();\n celebrate(grantedBy(res.data));\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n"
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "model.ts",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
67
|
"path": "module.meta.json",
|
|
68
|
-
"content": "{\n \"id\": \"leaderboard\",\n \"type\": \"feature\",\n \"summary\": \"Leaderboards screen for any game: boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the last round.\",\n \"description\": \"A ready leaderboards screen for ANY game, built on the @idosgames/react/ui kit. The title's enabled leaderboards are tabs; each shows a card with the player's score, rank and the countdown to the end of the round, and the top list with the player highlighted and medals for the top three. Rank rewards and score milestones open as popups (claim a milestone there). When a round ends, the reward for the place the player took pops up by itself with a Claim button (like LeaderboardCycleRewardPopup in the Unity SDK). A game that has something to play for the score registers a 'play' feature and a Play button appears on the card. Registers the 'leaderboard' feature; its badge counts boards with a reward waiting. Ships title-seed.json: a weekly best-score leaderboard with rank rewards.\",\n \"provides\": [\n \"leaderboard screen with several boards as tabs\",\n \"top players and my rank\",\n \"round countdown\",\n \"rank rewards popup\",\n \"score milestones with claiming\",\n \"auto popup for the reward of the finished round\",\n \"badge for waiting rewards\",\n \"demo leaderboard settings\"\n ],\n \"tags\": [\n \"leaderboard\",\n \"ranking\",\n \"top\",\n \"milestones\",\n \"rank-rewards\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
68
|
+
"content": "{\n \"id\": \"leaderboard\",\n \"type\": \"feature\",\n \"summary\": \"Leaderboards screen for any game: boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the last round.\",\n \"description\": \"A ready leaderboards screen for ANY game, built on the @idosgames/react/ui kit. The title's enabled leaderboards are tabs; each shows a card with the player's score, rank and the countdown to the end of the round, and the top list with the player highlighted and medals for the top three. Rank rewards and score milestones open as popups (claim a milestone there). When a round ends, the reward for the place the player took pops up by itself with a Claim button (like LeaderboardCycleRewardPopup in the Unity SDK). A game that has something to play for the score registers a 'play' feature and a Play button appears on the card. Registers the 'leaderboard' feature; its badge counts boards with a reward waiting. Ships title-seed.json: a weekly best-score leaderboard with rank rewards.\",\n \"provides\": [\n \"leaderboard screen with several boards as tabs\",\n \"top players and my rank\",\n \"round countdown\",\n \"rank rewards popup\",\n \"score milestones with claiming\",\n \"auto popup for the reward of the finished round\",\n \"badge for waiting rewards\",\n \"demo leaderboard settings\"\n ],\n \"tags\": [\n \"leaderboard\",\n \"ranking\",\n \"top\",\n \"milestones\",\n \"rank-rewards\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
|
|
69
69
|
}
|
|
70
70
|
]
|
|
71
71
|
}
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"version": "0.1.0"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.
|
|
37
|
-
"@idosgames/react": "0.
|
|
35
|
+
"@idosgames/core": "0.20.0",
|
|
36
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
37
|
+
"@idosgames/react": "0.4.2",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "64fb8cc7fb26c68c838117f01739d5bafcc8da16d797691c651658f1911f527e",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"version": "0.1.0"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.
|
|
41
|
-
"@idosgames/react": "0.
|
|
39
|
+
"@idosgames/core": "0.20.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.8.1",
|
|
41
|
+
"@idosgames/react": "0.4.2",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "b5f960cc3809c920a30dfe655c0531fea691236f418d59107daaffa8a9bbcb66",
|