xpt-shared-types 1.20.0 → 1.25.0

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.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Tournament templates — the contract between `utils/tournamentTemplate.ts`
3
+ * in xpt-strapi and the create dialog's setup step, the post-publish
4
+ * "Save as template" prompt and the Settings → Templates page in xpt-client.
5
+ *
6
+ * A template holds settings only: never entries, dates or results. A null
7
+ * setting means the host chose not to save it and the XPT default applies
8
+ * (`resolveSetup`). Alongside the host's templates, the API derives their
9
+ * **last tournament per game** — the newest one taken out of draft in the
10
+ * last `LAST_RUN_WINDOW_DAYS` — so a host can repeat a night without ever
11
+ * having saved anything.
12
+ *
13
+ * What a setup carries is close to the whole publish checklist: the catalogue
14
+ * choice (platform and account network), the region, the banner and the prize
15
+ * amounts ride along with the shape and the rules. Only the date is left out,
16
+ * because it belongs to one event rather than to the setup.
17
+ *
18
+ * Routes (all scoped to the caller):
19
+ * - `GET /tournament-templates/setups` → `{ data: TournamentSetupsResponse }`
20
+ * - `POST /tournament-templates` with `SaveTemplateBody` → `{ data: TournamentTemplateSummary }`
21
+ * - `PUT /tournament-templates/:id/default` → `{ data: TournamentTemplateSummary }`
22
+ * - `DELETE /tournament-templates/:id`
23
+ * - `POST /tournaments` accepts `entryFee`, `checkInTime`, `rules`, `region`,
24
+ * `platform`, `gameAccount`, `imgMain`, `prizes` and `template` (the
25
+ * documentId, for usage meta) alongside the usual fields.
26
+ */
27
+
28
+ import type { StrapiMedia } from '../contracts';
29
+ import type { TournamentTeamSize } from '../generated/enums';
30
+
31
+ /** How far back the automatic "last tournament" looks. */
32
+ export const LAST_RUN_WINDOW_DAYS = 30;
33
+
34
+ /** Templates a host may keep; the save route refuses the next one. */
35
+ export const MAX_TEMPLATES_PER_HOST = 20;
36
+
37
+ /**
38
+ * The settings a host may leave out of a setup — the "Also copied" rows.
39
+ * `platform` covers the account network too: the two are chosen together and
40
+ * publish readiness counts them as one detail.
41
+ */
42
+ export const TEMPLATE_COPY_KEYS = [
43
+ 'entryFee',
44
+ 'prizes',
45
+ 'checkInTime',
46
+ 'platform',
47
+ 'region',
48
+ 'hasMatchLobby',
49
+ 'hasThirdPlace',
50
+ 'rules',
51
+ 'imgMain',
52
+ ] as const;
53
+
54
+ export type TemplateCopyKey = (typeof TEMPLATE_COPY_KEYS)[number];
55
+ export type TemplateIncludeFlags = Record<TemplateCopyKey, boolean>;
56
+
57
+ /** A catalogue row as a setup names it. */
58
+ export interface SetupRef {
59
+ documentId: string;
60
+ name: string | null;
61
+ }
62
+
63
+ /** One placement's payout. The set of ranks is derived, never carried. */
64
+ export interface SetupPrize {
65
+ rank: string;
66
+ value: number | null;
67
+ }
68
+
69
+ /** The banner, as the media row a new tournament reuses. */
70
+ export interface SetupBanner {
71
+ id: number;
72
+ url: string | null;
73
+ }
74
+
75
+ export interface TournamentSetupFields {
76
+ participants: number;
77
+ teamSize: TournamentTeamSize;
78
+ /** Null: not saved, XPT default applies. */
79
+ entryFee: number | null;
80
+ /** Minutes; 0 is "no check-in". Null: not saved. */
81
+ checkInTime: number | null;
82
+ hasThirdPlace: boolean | null;
83
+ hasMatchLobby: boolean | null;
84
+ rules: string | null;
85
+ platform: SetupRef | null;
86
+ /** Rides with `platform`; the create refuses it if the catalogue has moved. */
87
+ gameAccount: SetupRef | null;
88
+ region: SetupRef | null;
89
+ imgMain: SetupBanner | null;
90
+ prizes: SetupPrize[];
91
+ }
92
+
93
+ /** What a blank tournament starts from, and what a null setting falls back to. */
94
+ export const TOURNAMENT_SETUP_DEFAULTS: TournamentSetupFields = {
95
+ participants: 8,
96
+ teamSize: 'one',
97
+ entryFee: 0,
98
+ checkInTime: 0,
99
+ hasThirdPlace: false,
100
+ hasMatchLobby: true,
101
+ rules: null,
102
+ platform: null,
103
+ gameAccount: null,
104
+ region: null,
105
+ imgMain: null,
106
+ prizes: [],
107
+ };
108
+
109
+ /** Enough of a game for the client to open the create dialog on it. */
110
+ export interface SetupGame {
111
+ id: number;
112
+ documentId: string;
113
+ title: string | null;
114
+ slug: string | null;
115
+ imgThumb: StrapiMedia | null;
116
+ }
117
+
118
+ /** A saved template as the routes return it. */
119
+ export interface TournamentTemplateSummary extends TournamentSetupFields {
120
+ documentId: string;
121
+ name: string;
122
+ game: SetupGame | null;
123
+ isDefault: boolean;
124
+ useCount: number;
125
+ lastUsedAt: string | null;
126
+ createdAt: string | null;
127
+ }
128
+
129
+ /** The host's last tournament for one game, derived at read time. */
130
+ export interface LastRunSummary extends TournamentSetupFields {
131
+ documentId: string;
132
+ title: string;
133
+ slug: string;
134
+ game: SetupGame | null;
135
+ dateTime: string | null;
136
+ createdAt: string | null;
137
+ }
138
+
139
+ /** `GET /tournament-templates/setups` — templates default first, runs newest first. */
140
+ export interface TournamentSetupsResponse {
141
+ templates: TournamentTemplateSummary[];
142
+ lastRuns: LastRunSummary[];
143
+ }
144
+
145
+ /** `POST /tournament-templates`. Missing include flags read as ticked. */
146
+ export interface SaveTemplateBody {
147
+ tournamentDocumentId: string;
148
+ name: string;
149
+ include?: Partial<TemplateIncludeFlags>;
150
+ isDefault?: boolean;
151
+ }
152
+
153
+ /** What the setup step offers, and what the details step was prefilled from. */
154
+ export type SetupChoice =
155
+ | { kind: 'template'; template: TournamentTemplateSummary }
156
+ | { kind: 'lastRun'; lastRun: LastRunSummary }
157
+ | { kind: 'blank' };
158
+
159
+ /** Every setting resolved: nulls replaced by the XPT default. */
160
+ export function resolveSetup(
161
+ fields: Partial<TournamentSetupFields> | null | undefined
162
+ ): TournamentSetupFields {
163
+ const source = fields ?? {};
164
+ return {
165
+ participants: source.participants || TOURNAMENT_SETUP_DEFAULTS.participants,
166
+ teamSize: source.teamSize ?? TOURNAMENT_SETUP_DEFAULTS.teamSize,
167
+ entryFee: source.entryFee ?? TOURNAMENT_SETUP_DEFAULTS.entryFee,
168
+ checkInTime: source.checkInTime ?? TOURNAMENT_SETUP_DEFAULTS.checkInTime,
169
+ hasThirdPlace: source.hasThirdPlace ?? TOURNAMENT_SETUP_DEFAULTS.hasThirdPlace,
170
+ hasMatchLobby: source.hasMatchLobby ?? TOURNAMENT_SETUP_DEFAULTS.hasMatchLobby,
171
+ rules: source.rules ?? TOURNAMENT_SETUP_DEFAULTS.rules,
172
+ platform: source.platform ?? null,
173
+ gameAccount: source.gameAccount ?? null,
174
+ region: source.region ?? null,
175
+ imgMain: source.imgMain ?? null,
176
+ prizes: source.prizes ?? [],
177
+ };
178
+ }
179
+
180
+ /**
181
+ * The copy keys this setup actually has something for. A setting the host
182
+ * unticked when saving, or never set on the tournament, carries nothing — so
183
+ * the "Also copied" list offers it no row rather than a tickable blank.
184
+ */
185
+ export function copyableSetupKeys(
186
+ fields: Partial<TournamentSetupFields> | null | undefined
187
+ ): TemplateCopyKey[] {
188
+ const setup = fields ?? {};
189
+ const has: Record<TemplateCopyKey, boolean> = {
190
+ entryFee: setup.entryFee != null,
191
+ prizes: (setup.prizes ?? []).length > 0,
192
+ checkInTime: setup.checkInTime != null,
193
+ platform: setup.platform != null,
194
+ region: setup.region != null,
195
+ hasMatchLobby: setup.hasMatchLobby != null,
196
+ hasThirdPlace: setup.hasThirdPlace != null,
197
+ rules: !!setup.rules,
198
+ imgMain: setup.imgMain != null,
199
+ };
200
+ return TEMPLATE_COPY_KEYS.filter((key) => has[key]);
201
+ }
202
+
203
+ /** The total a setup's prize amounts add up to. */
204
+ export const setupPrizePot = (prizes: SetupPrize[] | null | undefined): number =>
205
+ (prizes ?? []).reduce((sum, prize) => sum + (Number(prize.value) || 0), 0);
206
+
207
+ const TRAILING_COUNTER = /\s*#(\d+)$/;
208
+
209
+ /** The tournament title rules' limit; a bumped name has to stay inside it. */
210
+ export const MAX_TOURNAMENT_NAME = 50;
211
+
212
+ /**
213
+ * The name a tournament started from a setup gets. A trailing `#N` is bumped
214
+ * (`Friday Night Ops #3` → `Friday Night Ops #4`); a name without one is
215
+ * treated as the first of its series and becomes `#2`. Either way the host
216
+ * sees a name they can keep or type over, and never one that silently
217
+ * duplicates the tournament it came from.
218
+ */
219
+ export function nextTournamentName(name: string): string {
220
+ const match = TRAILING_COUNTER.exec(name);
221
+ const base = (match ? name.slice(0, match.index) : name).trim();
222
+ if (!base) return name;
223
+
224
+ const suffix = ` #${match ? Number(match[1]) + 1 : 2}`;
225
+ const room = MAX_TOURNAMENT_NAME - suffix.length;
226
+ return `${base.length > room ? base.slice(0, room).trim() : base}${suffix}`;
227
+ }
228
+
229
+ /** A template name from a tournament title: the trailing `#N`, if any, dropped. */
230
+ export function templateNameFrom(title: string): string {
231
+ return title.replace(TRAILING_COUNTER, '').trim();
232
+ }
233
+
234
+ /** The setups offered for one game: its templates and, if any, its last run. */
235
+ export function setupsForGame(
236
+ setups: TournamentSetupsResponse | null | undefined,
237
+ gameDocumentId: string | null | undefined
238
+ ): { templates: TournamentTemplateSummary[]; lastRun: LastRunSummary | null } {
239
+ if (!setups || !gameDocumentId) return { templates: [], lastRun: null };
240
+ return {
241
+ templates: setups.templates.filter(
242
+ (t) => t.game?.documentId === gameDocumentId
243
+ ),
244
+ lastRun:
245
+ setups.lastRuns.find((r) => r.game?.documentId === gameDocumentId) ??
246
+ null,
247
+ };
248
+ }
249
+
250
+ /** Badge counts for the game grid: templates for the game plus its last run. */
251
+ export function setupCountByGame(
252
+ setups: TournamentSetupsResponse | null | undefined
253
+ ): Record<string, number> {
254
+ const counts: Record<string, number> = {};
255
+ if (!setups) return counts;
256
+ for (const t of setups.templates) {
257
+ const id = t.game?.documentId;
258
+ if (id) counts[id] = (counts[id] ?? 0) + 1;
259
+ }
260
+ for (const r of setups.lastRuns) {
261
+ const id = r.game?.documentId;
262
+ if (id) counts[id] = (counts[id] ?? 0) + 1;
263
+ }
264
+ return counts;
265
+ }
266
+
267
+ /**
268
+ * What the setup step starts on: the default template if the game has one,
269
+ * else the game's last run, else blank — so Continue is a single tap.
270
+ */
271
+ export function preselectSetup(
272
+ templates: TournamentTemplateSummary[],
273
+ lastRun: LastRunSummary | null | undefined
274
+ ): SetupChoice {
275
+ const preferred = templates.find((t) => t.isDefault) ?? templates[0];
276
+ if (preferred) return { kind: 'template', template: preferred };
277
+ if (lastRun) return { kind: 'lastRun', lastRun };
278
+ return { kind: 'blank' };
279
+ }
280
+
281
+ /** The setup fields of a choice, or null for blank. */
282
+ export function setupOfChoice(
283
+ choice: SetupChoice
284
+ ): TournamentSetupFields | null {
285
+ if (choice.kind === 'template') return choice.template;
286
+ if (choice.kind === 'lastRun') return choice.lastRun;
287
+ return null;
288
+ }
289
+
290
+ /** The name a choice was made under, for the provenance strip. */
291
+ export function nameOfChoice(choice: SetupChoice): string | null {
292
+ if (choice.kind === 'template') return choice.template.name;
293
+ if (choice.kind === 'lastRun') return choice.lastRun.title;
294
+ return null;
295
+ }
296
+
297
+ export interface DescribeSetupOptions {
298
+ /** `players` or `teams` for the team size. */
299
+ entryNoun: (teamSize: TournamentTeamSize) => string;
300
+ /** `1v1` … `5v5`. */
301
+ teamSizeLabel: (teamSize: TournamentTeamSize) => string;
302
+ /** `Free entry`, `50 XPT` — the app's own money formatting. */
303
+ feeLabel: (fee: number) => string;
304
+ /** Mention rules when the setup carries them. Default true. */
305
+ withRules?: boolean;
306
+ }
307
+
308
+ /**
309
+ * The detail line under a setup: `16 teams · 2v2 · 50 XPT · check-in 30 min
310
+ * · rules`. Nulls read as their defaults. The labels come from the caller
311
+ * so this package does not carry the app's copy.
312
+ */
313
+ export function describeSetup(
314
+ fields: Partial<TournamentSetupFields> | null | undefined,
315
+ options: DescribeSetupOptions
316
+ ): string {
317
+ const setup = resolveSetup(fields);
318
+ const parts = [
319
+ `${setup.participants} ${options.entryNoun(setup.teamSize)}`,
320
+ options.teamSizeLabel(setup.teamSize),
321
+ options.feeLabel(setup.entryFee ?? 0),
322
+ setup.checkInTime ? `check-in ${setup.checkInTime} min` : 'no check-in',
323
+ ];
324
+ if (options.withRules !== false && setup.rules) parts.push('rules');
325
+ return parts.join(' · ');
326
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A user's UI state: small per-user flags that steer the interface — which
3
+ * hints are still showing, which onboarding steps are done.
4
+ *
5
+ * Stored as the private `uiState` JSON on the user, so it never appears on a
6
+ * user record. Read with GET /user/ui-state and written with
7
+ * PATCH /user/ui-state, which merges key by key: a patch naming one key never
8
+ * clears another. The server drops unknown keys and malformed values.
9
+ */
10
+ export interface UserUiState {
11
+ /**
12
+ * Tournaments (documentId) whose host closed the publish success dialog
13
+ * without going to Manage, so the next-steps panel still shows. Cleared
14
+ * per tournament on Dismiss or when Manage is opened. Newest last, capped
15
+ * at {@link POST_PUBLISH_PENDING_LIMIT}.
16
+ */
17
+ postPublishPending?: string[];
18
+ /**
19
+ * Tournaments (documentId) whose host answered "Not now" to the
20
+ * save-as-template prompt, or saved a template from it, so the prompt does
21
+ * not show again. Newest last, capped at
22
+ * {@link TEMPLATE_PROMPT_DISMISSED_LIMIT}.
23
+ */
24
+ templatePromptDismissed?: string[];
25
+ }
26
+
27
+ /** How many tournaments `postPublishPending` remembers; oldest drop first. */
28
+ export const POST_PUBLISH_PENDING_LIMIT = 20;
29
+
30
+ /** How many tournaments `templatePromptDismissed` remembers; oldest drop first. */
31
+ export const TEMPLATE_PROMPT_DISMISSED_LIMIT = 20;