@koloseum/utils 0.2.28 → 0.3.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.
package/dist/utils.js DELETED
@@ -1,2019 +0,0 @@
1
- import { v4 as uuidv4 } from "uuid";
2
- import { error as svelteError } from "@sveltejs/kit";
3
- import { FunctionsFetchError, FunctionsHttpError, FunctionsRelayError } from "@supabase/supabase-js";
4
- import Bowser from "bowser";
5
- import validator from "validator";
6
- /* HELPERS */
7
- const parsePgInterval = (await import("postgres-interval")).default;
8
- const sanitize = (await import("sanitize-html")).default;
9
- const { trim, escape, isMobilePhone, isURL } = validator;
10
- const { KenyaAdministrativeDivisions } = (await import("kenya-administrative-divisions")).default;
11
- /* DUMMY DATA */
12
- export const Data = {
13
- /**
14
- * A generic authenticated user.
15
- * @param {string} id - The user ID; defaults to a random UUID
16
- * @param {Date} date - The date and time; defaults to the current date and time
17
- * @param {string} phone - The phone number; defaults to a generic Kenyan phone number
18
- * @param {string} identityId - A default identity ID; defaults to a random UUID
19
- * @returns A generic authenticated user.
20
- */
21
- authenticatedUser: (id = uuidv4(), date = new Date(), phone = "254111222333", identityId = uuidv4()) => {
22
- // Convert date to ISO string
23
- const time = date.toISOString();
24
- // User data
25
- return {
26
- id,
27
- aud: "authenticated",
28
- role: "authenticated",
29
- email: "",
30
- phone,
31
- phone_confirmed_at: time,
32
- confirmation_sent_at: time,
33
- confirmed_at: time,
34
- last_sign_in_at: time,
35
- app_metadata: {
36
- person_data: { player_id: "KP1234567", first_name: "John", last_name: "Test", pseudonym: "JDtest" },
37
- provider: "phone",
38
- providers: ["phone"],
39
- roles: ["player", "backroom_superuser"]
40
- },
41
- user_metadata: {
42
- email_verified: false,
43
- phone_verified: false,
44
- sub: id,
45
- backroom: {
46
- welcome_notification_sent: true
47
- },
48
- players: {
49
- welcome_notification_sent: true
50
- }
51
- },
52
- identities: [
53
- {
54
- identity_id: identityId,
55
- id,
56
- user_id: id,
57
- identity_data: {
58
- email_verified: false,
59
- phone_verified: false,
60
- sub: id
61
- },
62
- provider: "phone",
63
- last_sign_in_at: time,
64
- created_at: time,
65
- updated_at: time
66
- }
67
- ],
68
- created_at: time,
69
- updated_at: time,
70
- is_anonymous: false
71
- };
72
- }
73
- };
74
- /* STATUS MESSAGES */
75
- export const Status = {
76
- /**
77
- * A generic error message.
78
- */
79
- ERROR: "An unexpected error occurred. Kindly try again.",
80
- /**
81
- * A generic loading message.
82
- */
83
- LOADING: "Please wait…",
84
- /**
85
- * A generic password reset request message.
86
- */
87
- PASSWORD_RESET_REQUESTED: "If the provided email address is registered, you will receive a password reset link shortly."
88
- };
89
- /* UTILITY FUNCTIONS */
90
- export const Utility = {
91
- /**
92
- * Calls a Supabase Edge function.
93
- * @param {boolean} browser - Whether the function is being called in the browser
94
- * @param {SupabaseClient<Database>} supabase - The Supabase client
95
- * @param {string} path - The path to the Edge function
96
- * @param {FunctionInvokeOptions} [options] - The options to use for the function invocation; defaults to `{ method: "GET" }`
97
- * @returns The response from the Edge function
98
- */
99
- callEdgeFunction: async (browser, supabase, path, options = { method: "GET" }) => {
100
- try {
101
- // Only add Origin header in browser environment
102
- if (browser)
103
- options.headers = { ...options.headers, Origin: window.location.origin };
104
- // Fetch response with additional options for better cross-origin handling
105
- const { data, error } = await supabase.functions.invoke(path, options);
106
- // Handle different error types
107
- if (error) {
108
- // Define context
109
- const context = {};
110
- // Define error code and message
111
- const code = Number(error.context?.status) || 500;
112
- let message = error.context?.statusText || Status.ERROR;
113
- // Handle HTTP errors
114
- if (error instanceof FunctionsHttpError) {
115
- try {
116
- // Get content type header safely
117
- const contentType = error.context?.headers?.get("content-type") || "";
118
- // If content type is JSON, get error data
119
- if (contentType.includes("application/json")) {
120
- try {
121
- const errorData = await error.context.json();
122
- message = errorData.message || message;
123
- // Assign attempt ID to context if present
124
- if (path.includes("verify-id") && errorData.attemptId)
125
- context.attemptId = errorData.attemptId;
126
- }
127
- catch (jsonError) {
128
- console.error("Failed to parse JSON error response:", jsonError);
129
- }
130
- }
131
- // If content type is plain text, get error message
132
- else if (contentType.includes("text/plain")) {
133
- try {
134
- message = await error.context.text();
135
- }
136
- catch (textError) {
137
- console.error("Failed to parse text error response:", textError);
138
- }
139
- }
140
- }
141
- catch (parseError) {
142
- console.error("Failed to parse error response:", parseError);
143
- }
144
- }
145
- // Handle relay and fetch errors
146
- else if (error instanceof FunctionsRelayError || error instanceof FunctionsFetchError)
147
- message = error.message;
148
- // Return error
149
- return { code: code, error: message, context };
150
- }
151
- // Return response
152
- return { code: 200, data };
153
- }
154
- catch (unexpectedError) {
155
- console.error("Unexpected error:", unexpectedError);
156
- return { code: 500, error: Status.ERROR };
157
- }
158
- },
159
- /**
160
- * Capitalises a given word.
161
- * @param {string} word - The word to be capitalised
162
- * @returns The capitalised word
163
- */
164
- capitalise: (word) => word.charAt(0).toUpperCase() +
165
- word
166
- .slice(1)
167
- .split("")
168
- .map((c) => c.toLowerCase())
169
- .join(""),
170
- /**
171
- * Cleans a URL by removing the protocol and trailing slashes.
172
- * @param {string} url - The URL to clean
173
- * @returns {string} The cleaned URL
174
- */
175
- cleanUrl: (url) => url.replace(/^https?:\/\//, "").replace(/\/+$/, ""),
176
- /**
177
- * Returns a custom error object.
178
- * @param {number} code - The error code; defaults to `500` if not provided or invalid
179
- * @param {string} message - The error message
180
- * @returns An object with the error `code` and `message`
181
- */
182
- customError: (code, message) => !code || code < 400 || code > 599 ? { code: 500, message: Status.ERROR } : { code, message },
183
- /**
184
- * A regular expression for E.164 phone numbers.
185
- *
186
- * - Source: https://www.twilio.com/docs/glossary/what-e164
187
- */
188
- e164Regex: /^\+?[1-9]\d{1,14}$/,
189
- /**
190
- * Formats a date in the format DD/MM/YYYY (by default) or YYYY-MM-DD (for client).
191
- * @param {string} date - Date to be formatted
192
- * @param {boolean} forClient - Specify whether the data is for displaying on the client; defaults to `false`
193
- * @returns The formatted date string, or `null` if invalid input or in case of an error
194
- */
195
- formatDate: (date, forClient = false) => {
196
- // Return null if no date is provided
197
- if (date === "" || typeof date !== "string")
198
- return null;
199
- // Handle input
200
- try {
201
- // Parse date string
202
- const parsedDate = Date.parse(date);
203
- // Return null if date string is not parseable
204
- if (isNaN(parsedDate))
205
- return null;
206
- // Use the Intl.DateTimeFormat with explicit options to ensure correct formatting
207
- const formattedDate = new Intl.DateTimeFormat(forClient ? "fr-CA" : "en-KE", {
208
- day: "2-digit",
209
- month: "2-digit",
210
- year: "numeric",
211
- timeZone: "Africa/Nairobi"
212
- }).format(parsedDate);
213
- // Return formatted date string
214
- return formattedDate;
215
- }
216
- catch (error) {
217
- console.error(error);
218
- return null;
219
- }
220
- },
221
- /**
222
- * Formats a social media platform for display.
223
- * @param {SocialMediaPlatform} platform - The social media platform to be formatted
224
- * @returns The formatted social media platform string
225
- */
226
- formatSocialMediaPlatform: (platform) => {
227
- const platforms = ["facebook", "instagram", "kick", "tiktok", "twitch", "twitter", "youtube"];
228
- if (!platforms.includes(platform))
229
- return "";
230
- if (platform === "tiktok")
231
- return "TikTok";
232
- if (platform === "twitter")
233
- return "X (formerly Twitter)";
234
- if (platform === "youtube")
235
- return "YouTube";
236
- return Utility.capitalise(platform);
237
- },
238
- /**
239
- * Returns an age in years given a birth date.
240
- * @param {Date | string | number | null} dateOfBirth - The date of birth
241
- * @returns The age in years, or `NaN` if the input is invalid
242
- */
243
- getAge: (dateOfBirth) => {
244
- // Return NaN if no date is provided
245
- if (!dateOfBirth)
246
- return NaN;
247
- // Validate and process date of birth
248
- let birthDate;
249
- if (dateOfBirth instanceof Date) {
250
- if (isNaN(dateOfBirth.getTime()))
251
- return NaN;
252
- birthDate = dateOfBirth;
253
- }
254
- else if (typeof dateOfBirth === "string") {
255
- if (dateOfBirth === "" || isNaN(Date.parse(dateOfBirth)))
256
- return NaN;
257
- // Format date of birth if it's a string
258
- const formattedDate = Utility.formatDate(dateOfBirth, true);
259
- if (!formattedDate)
260
- return NaN;
261
- birthDate = new Date(formattedDate);
262
- }
263
- else if (typeof dateOfBirth === "number") {
264
- if (isNaN(dateOfBirth))
265
- return NaN;
266
- birthDate = new Date(dateOfBirth);
267
- }
268
- else {
269
- return NaN;
270
- }
271
- // Validate the final date object
272
- if (isNaN(birthDate.getTime()))
273
- return NaN;
274
- // Calculate age
275
- const currentDate = new Date();
276
- const monthDiff = currentDate.getMonth() - birthDate.getMonth();
277
- let age = currentDate.getFullYear() - birthDate.getFullYear();
278
- // Return age
279
- return monthDiff < 0 || (monthDiff === 0 && currentDate.getDate() < birthDate.getDate()) ? --age : age;
280
- },
281
- /**
282
- * Formats a date as a locale-specific string.
283
- * @param {Date | string | number | null} date - Date to be formatted
284
- * @param {boolean} withTime - Specify whether the date should include time; defaults to `false`
285
- * @param {string} timeZone - The time zone to use for formatting; defaults to `Africa/Nairobi`
286
- * @returns The formatted date string, or an empty string if invalid input
287
- */
288
- getDateString: (date, withTime = false, timeZone = "Africa/Nairobi") => {
289
- // Return empty string if no date is provided
290
- if (!date)
291
- return "";
292
- // Validate date to format
293
- let dateToFormat;
294
- if (date instanceof Date) {
295
- if (isNaN(date.getTime()))
296
- return "";
297
- dateToFormat = date;
298
- }
299
- else if (typeof date === "string" || typeof date === "number") {
300
- if (typeof date === "string" && (date === "" || isNaN(Date.parse(date))))
301
- return "";
302
- if (typeof date === "number" && isNaN(date))
303
- return "";
304
- dateToFormat = new Date(date);
305
- }
306
- else
307
- return "";
308
- // Validate the final date object
309
- if (isNaN(dateToFormat.getTime()))
310
- return "";
311
- // Format date
312
- return dateToFormat.toLocaleString("en-KE", {
313
- timeZone,
314
- year: "numeric",
315
- month: "long",
316
- day: "numeric",
317
- hour: withTime ? "numeric" : undefined,
318
- minute: withTime ? "2-digit" : undefined,
319
- hour12: withTime ? true : undefined
320
- });
321
- },
322
- /**
323
- * Formats an identity type for display.
324
- * @param {IdentityType} identityType - The identity type to be formatted
325
- * @returns The formatted identity type string
326
- */
327
- getIdentityTypeString: (identityType) => {
328
- if (identityType === "national")
329
- return "National ID";
330
- if (identityType === "alien")
331
- return "Alien ID";
332
- if (identityType === "passport")
333
- return "Passport";
334
- if (identityType === "driver_licence")
335
- return "Driver's licence";
336
- return "";
337
- },
338
- /**
339
- * Returns a list of all counties in Kenya.
340
- * @param {"name" | "code"} sortBy - The field to sort the counties by, i.e. `name` or `code`; defaults to `name`
341
- * @returns {Promise<County[]>} A list of objects with the county `name`, `code`, and a list of `subCounties`
342
- */
343
- getKenyaCounties: async (sortBy = "name") => {
344
- // Create Kenya administrative divisions instance
345
- const kenyaAdmin = new KenyaAdministrativeDivisions();
346
- // Get all counties
347
- const countiesData = await kenyaAdmin.getAll();
348
- // Format counties
349
- const counties = countiesData.map((county) => {
350
- // Get list of sub-counties
351
- const subCounties = [];
352
- for (const subCounty of county.constituencies)
353
- subCounties.push(subCounty.constituency_name);
354
- subCounties.sort();
355
- // Format county name and add to list
356
- const countyName = county.county_name.split(" ");
357
- let name = county.county_name;
358
- if (countyName.length > 1)
359
- name = countyName.map((word) => Utility.capitalise(word)).join(" ");
360
- // Return county
361
- return { name, code: county.county_code, subCounties };
362
- });
363
- // Return sorted counties
364
- return counties.sort((a, b) => (sortBy === "name" ? a.name.localeCompare(b.name) : a.code - b.code));
365
- },
366
- /**
367
- * Formats a date to an ISO string in Kenyan time, i.e. `Africa/Nairobi` (UTC+3).
368
- * @param {Date | string | number | null} date - The date to format
369
- * @returns {string} The formatted date in ISO string format, or an empty string if invalid input
370
- */
371
- getKenyanISOString: (date) => {
372
- // Return empty string if no date is provided or input is invalid
373
- if (!date || (typeof date !== "string" && typeof date !== "number" && !(date instanceof Date)))
374
- return "";
375
- // Get locale string to format
376
- const dateObj = date instanceof Date ? date : new Date(date);
377
- const localeString = dateObj.toLocaleString("sv-SE", {
378
- timeZone: "Africa/Nairobi"
379
- });
380
- // Return formatted string
381
- return localeString.replace(" ", "T") + "+03:00";
382
- },
383
- /**
384
- * Returns the URL for a menu item based on the slug.
385
- * @param {string} base - The base URL
386
- * @param {string} slug - The slug of the menu item
387
- * @returns {string} The URL for the menu item
388
- */
389
- getMenuItemUrl: (base, slug) => {
390
- // Validate base URL
391
- if (typeof base !== "string")
392
- return "";
393
- // Format base URL
394
- if (base === "/")
395
- base = "";
396
- if (base.charAt(base.length - 1) === "/")
397
- base = base.slice(0, -1);
398
- // Return URL
399
- return `${base}${slug === "/" ? slug : typeof slug === "string" ? `/${slug}` : ""}`;
400
- },
401
- /**
402
- * Returns the parent URL for a given base URL.
403
- * @param {string} base - The base URL
404
- * @returns {string} The parent URL
405
- */
406
- getParentUrl: (base) => {
407
- // Validate input
408
- if (typeof base !== "string")
409
- return "";
410
- // Return parent URL
411
- return base.replace(/\/$/, "").split("/").slice(0, -1).join("/") || "/";
412
- },
413
- /**
414
- * Returns a pronoun given pronouns and a type.
415
- * @param {PronounsItem | null} pronouns - The pronouns to be formatted
416
- * @param {"subject" | "object" | "possessive" | "reflexive"} type - The type of pronoun to be returned
417
- * @param {Sex} sex - The user's sex; defaults to `undefined`
418
- * @returns The formatted pronoun, or `null` if the input is invalid
419
- */
420
- getPronoun: (pronouns, type, sex) => {
421
- // Get pronoun from pronouns item
422
- if (pronouns) {
423
- // Return subject pronoun
424
- if (type === "subject") {
425
- if (pronouns === "he/him/his/himself")
426
- return "he";
427
- if (pronouns === "she/her/hers/herself")
428
- return "she";
429
- if (pronouns === "they/them/their/themself")
430
- return "they";
431
- if (pronouns === "name_only")
432
- return "";
433
- }
434
- // Return object pronoun
435
- else if (type === "object") {
436
- if (pronouns === "he/him/his/himself")
437
- return "him";
438
- if (pronouns === "she/her/hers/herself")
439
- return "her";
440
- if (pronouns === "they/them/their/themself")
441
- return "them";
442
- if (pronouns === "name_only")
443
- return "";
444
- }
445
- // Return possessive pronoun
446
- else if (type === "possessive") {
447
- if (pronouns === "he/him/his/himself")
448
- return "his";
449
- if (pronouns === "she/her/hers/herself")
450
- return "her";
451
- if (pronouns === "they/them/their/themself")
452
- return "their";
453
- if (pronouns === "name_only")
454
- return "";
455
- }
456
- // Return reflexive pronoun
457
- else if (type === "reflexive") {
458
- if (pronouns === "he/him/his/himself")
459
- return "himself";
460
- if (pronouns === "she/her/hers/herself")
461
- return "herself";
462
- if (pronouns === "they/them/their/themself")
463
- return "themself";
464
- if (pronouns === "name_only")
465
- return "";
466
- }
467
- }
468
- // Assume pronoun for sex if no pronouns are provided
469
- else if (sex) {
470
- // Return subject pronoun
471
- if (type === "subject") {
472
- if (sex === "male" || sex === "intersex_man")
473
- return "he";
474
- if (sex === "female" || sex === "intersex_woman")
475
- return "she";
476
- return "they";
477
- }
478
- // Return object pronoun
479
- else if (type === "object") {
480
- if (sex === "male" || sex === "intersex_man")
481
- return "him";
482
- if (sex === "female" || sex === "intersex_woman")
483
- return "her";
484
- return "them";
485
- }
486
- // Return possessive pronoun
487
- else if (type === "possessive") {
488
- if (sex === "male" || sex === "intersex_man")
489
- return "his";
490
- if (sex === "female" || sex === "intersex_woman")
491
- return "her";
492
- return "their";
493
- }
494
- // Return reflexive pronoun
495
- else if (type === "reflexive") {
496
- if (sex === "male" || sex === "intersex_man")
497
- return "himself";
498
- if (sex === "female" || sex === "intersex_woman")
499
- return "herself";
500
- return "themself";
501
- }
502
- }
503
- // Return null
504
- return null;
505
- },
506
- /**
507
- * Returns the redirect URL for a given URI.
508
- * @param {string} uri - The URI to get the redirect URL for, i.e. `microserviceGroup:path` (e.g. `players:fgc/tournaments`)
509
- * @param {"development" | "production"} env - The environment to use for the redirect URL; defaults to `production`
510
- * @returns {string} An object with the redirect `url`, or an `error` if any occurs
511
- */
512
- getRedirectUrl: (uri, env = "production") => {
513
- // Get microservice groups
514
- const microserviceGroups = ["public", "players", "lounges", "backroom"];
515
- // Extract microservice group and path from URI
516
- let [microserviceGroup, path] = uri.split(":");
517
- if (!microserviceGroup || !path)
518
- return { error: Utility.customError(400, "URI is invalid.") };
519
- if (!microserviceGroups.includes(microserviceGroup))
520
- return { error: Utility.customError(400, "Microservice group is invalid.") };
521
- // Initialise port number for local development
522
- let port;
523
- // Return redirect URL for Public microservices
524
- if (microserviceGroup === "public") {
525
- // Initialise subdomain for production
526
- let subdomain = "";
527
- // Handle Authentication microservice
528
- if (path.startsWith("auth")) {
529
- subdomain = "auth.";
530
- port = 5173;
531
- path = path.replace("auth", "");
532
- }
533
- // Handle Legal microservice
534
- else if (path.startsWith("legal")) {
535
- subdomain = "legal.";
536
- port = 5174;
537
- path = path.replace("legal", "");
538
- }
539
- // Handle Landing microservice
540
- else if (path.startsWith("landing")) {
541
- port = 5184;
542
- path = path.replace("landing", "");
543
- }
544
- // Return redirect URL
545
- return {
546
- url: env === "production"
547
- ? `https://${subdomain}koloseum.ke${path || ""}`
548
- : `http://127.0.0.1:${port}${path || ""}`
549
- };
550
- }
551
- // Handle Players microservices
552
- if (microserviceGroup === "players") {
553
- if (path.startsWith("account")) {
554
- port = 5175;
555
- if (env === "development")
556
- path = path.replace("account", "");
557
- }
558
- if (path.startsWith("fgc")) {
559
- port = 5178;
560
- if (env === "development")
561
- path = path.replace("fgc", "");
562
- }
563
- if (path.startsWith("commerce")) {
564
- port = 5179;
565
- if (env === "development")
566
- path = path.replace("commerce", "");
567
- }
568
- }
569
- // Handle Lounges microservices
570
- if (microserviceGroup === "lounges") {
571
- if (path.startsWith("branches")) {
572
- port = 5181;
573
- if (env === "development")
574
- path = path.replace("branches", "");
575
- }
576
- if (path.startsWith("staff")) {
577
- port = 5182;
578
- if (env === "development")
579
- path = path.replace("staff", "");
580
- }
581
- }
582
- // Handle Backroom microservices
583
- if (microserviceGroup === "backroom") {
584
- if (path.startsWith("compliance")) {
585
- port = 5176;
586
- if (env === "development")
587
- path = path.replace("compliance", "");
588
- }
589
- if (path.startsWith("competitions")) {
590
- port = 5177;
591
- if (env === "development")
592
- path = path.replace("competitions", "");
593
- }
594
- if (path.startsWith("commerce")) {
595
- port = 5180;
596
- if (env === "development")
597
- path = path.replace("commerce", "");
598
- }
599
- if (path.startsWith("staff")) {
600
- port = 5183;
601
- if (env === "development")
602
- path = path.replace("staff", "");
603
- }
604
- }
605
- // Return redirect URL
606
- return {
607
- url: env === "production"
608
- ? `https://${microserviceGroup}.koloseum.ke/${path || ""}`
609
- : `http://127.0.0.1:${port}${path || ""}`
610
- };
611
- },
612
- /**
613
- * Generate a SuprSend notification inbox configuration object for a user.
614
- * @param {string} userId - The user ID to generate the configuration for.
615
- * @param {string} publicApiKey - The public API key to use for SuprSend.
616
- * @returns The SuprSend notification inbox configuration object.
617
- */
618
- getSuprSendInboxConfig: (userId, publicApiKey) => ({
619
- distinctId: userId,
620
- publicApiKey,
621
- inbox: {
622
- stores: [
623
- { storeId: "all", label: "Inbox", query: { archived: false } },
624
- { storeId: "archived", label: "Archived", query: { archived: true } }
625
- ],
626
- theme: {
627
- bell: {
628
- color: "var(--color-accent)"
629
- },
630
- badge: {
631
- backgroundColor: "var(--color-primary)",
632
- color: "var(--color-primary-content)"
633
- },
634
- header: {
635
- container: {
636
- backgroundColor: "var(--color-base-100)",
637
- borderColor: "var(--color-base-200)"
638
- },
639
- headerText: {
640
- color: "var(--color-neutral)"
641
- },
642
- markAllReadText: {
643
- color: "var(--color-accent)"
644
- }
645
- },
646
- tabs: {
647
- color: "var(--color-primary)",
648
- unselectedColor: "var(--color-accent)",
649
- bottomColor: "var(--color-primary)",
650
- badgeColor: "var(--color-base-200)",
651
- badgeText: "var(--color-accent)"
652
- },
653
- notificationsContainer: {
654
- container: {
655
- backgroundColor: "var(--color-base-100)",
656
- borderColor: "var(--color-base-200)",
657
- height: "75vh",
658
- marginTop: "0.75rem"
659
- },
660
- noNotificationsText: {
661
- color: "var(--color-warning)"
662
- },
663
- noNotificationsSubtext: {
664
- color: "var(--color-neutral)"
665
- },
666
- loader: {
667
- color: "var(--color-primary)"
668
- }
669
- },
670
- notification: {
671
- container: {
672
- borderColor: "var(--color-base-200)",
673
- readBackgroundColor: "var(--color-base-100)",
674
- unreadBackgroundColor: "var(--color-base-150)",
675
- hoverBackgroundColor: "var(--color-base-200)"
676
- },
677
- headerText: {
678
- color: "var(--color-secondary)"
679
- },
680
- bodyText: {
681
- color: "var(--color-neutral)",
682
- linkColor: "var(--color-secondary)"
683
- },
684
- unseenDot: {
685
- backgroundColor: "var(--color-warning)"
686
- },
687
- createdOnText: {
688
- color: "var(--color-accent)"
689
- },
690
- subtext: {
691
- color: "var(--color-neutral)"
692
- },
693
- expiresText: {
694
- color: "var(--color-warning)",
695
- expiringBackgroundColor: "var(--color-warning)",
696
- expiringColor: "var(--color-neutral)"
697
- },
698
- actions: [
699
- {
700
- text: {
701
- color: "var(--color-primary-content)"
702
- },
703
- container: {
704
- backgroundColor: "var(--color-primary)",
705
- hoverBackgroundColor: "var(--color-secondary)",
706
- border: "unset"
707
- }
708
- },
709
- {
710
- text: {
711
- color: "var(--color-primary-content)"
712
- },
713
- container: {
714
- backgroundColor: "var(--color-primary)",
715
- hoverBackgroundColor: "var(--color-secondary)",
716
- border: "unset"
717
- }
718
- }
719
- ],
720
- actionsMenuIcon: {
721
- hoverBackgroundColor: "var(--color-base-200)",
722
- color: "var(--color-neutral)"
723
- },
724
- actionsMenu: {
725
- backgroundColor: "var(--color-base-100)",
726
- borderColor: "var(--color-base-200)"
727
- },
728
- actionsMenuItem: {
729
- hoverBackgroundColor: "var(--color-base-200)"
730
- },
731
- actionsMenuItemIcon: {
732
- color: "var(--color-accent)"
733
- },
734
- actionsMenuItemText: {
735
- color: "var(--color-neutral)"
736
- }
737
- }
738
- }
739
- },
740
- toast: {
741
- theme: {
742
- container: {
743
- backgroundColor: "var(--color-base-100)",
744
- borderColor: "var(--color-base-200)"
745
- },
746
- headerText: {
747
- color: "var(--color-primary)"
748
- },
749
- bodyText: {
750
- color: "var(--color-neutral)"
751
- }
752
- }
753
- }
754
- }),
755
- /**
756
- * Handles the click event for pronouns checkboxes.
757
- * @param {MouseEvent} e - The click event
758
- * @param {PronounsCheckboxes} checkboxes - The pronouns checkboxes
759
- */
760
- handlePronounsCheckboxClick: (e, checkboxes) => {
761
- const target = e.target;
762
- if (target.value === "none") {
763
- for (let checkbox of Object.values(checkboxes))
764
- if (checkbox && checkbox !== target && checkbox.checked) {
765
- e.preventDefault();
766
- return;
767
- }
768
- for (let checkbox of Object.values(checkboxes))
769
- if (checkbox && checkbox !== target) {
770
- checkbox.checked = false;
771
- checkbox.disabled = !checkbox.disabled;
772
- }
773
- }
774
- else {
775
- if (checkboxes.none?.checked) {
776
- e.preventDefault();
777
- return;
778
- }
779
- if (target.value === "male" || target.value === "female") {
780
- const oppositeIndex = target.value === "male" ? "female" : "male";
781
- const oppositeCheckbox = checkboxes[oppositeIndex];
782
- if (oppositeCheckbox?.checked) {
783
- e.preventDefault();
784
- return;
785
- }
786
- if (oppositeCheckbox) {
787
- oppositeCheckbox.checked = false;
788
- oppositeCheckbox.disabled = !oppositeCheckbox.disabled;
789
- }
790
- if (checkboxes.none) {
791
- checkboxes.none.checked = false;
792
- if (!checkboxes.neutral?.checked)
793
- checkboxes.none.disabled = !checkboxes.none.disabled;
794
- }
795
- }
796
- else {
797
- if (checkboxes.none) {
798
- checkboxes.none.checked = false;
799
- if (checkboxes.neutral && !checkboxes.male?.checked && !checkboxes.female?.checked)
800
- checkboxes.none.disabled = checkboxes.neutral.checked;
801
- }
802
- }
803
- }
804
- },
805
- /**
806
- * Checks if a user is authorised to access a microservice.
807
- * @param {SupabaseClient<Database>} supabase - The Supabase client.
808
- * @param {UserWithCustomMetadata} user - The user to check.
809
- * @param {Object} options - The options for the function.
810
- * @param {MicroserviceGroup} options.microserviceGroup - The microservice group.
811
- * @param {Microservice<MicroserviceGroup> | string} options.microservice - The microservice.
812
- * @param {string} options.playersUrl - The URL to the Players microservice.
813
- * @param {(role: string) => string} options.requestedPermission - A function that returns the requested permission for a given role.
814
- * @returns {Promise<{ isAuthorised?: boolean; redirect?: { code: number; url: string }; error?: CustomError }>} The result of the function.
815
- */
816
- isUserAuthorised: async (supabase, user, options) => {
817
- // Destructure options
818
- const { microserviceGroup, microservice, playersUrl } = options;
819
- // Return true if microservice group is public
820
- if (microserviceGroup === "public")
821
- return { isAuthorised: true };
822
- // Validate user metadata and app metadata
823
- if (!user.user_metadata || !user.app_metadata)
824
- return { error: Utility.customError(400, "User metadata is required.") };
825
- // Get user's roles and the role prefix
826
- const roles = [];
827
- const rolePrefix = microserviceGroup === "backroom" ? "backroom" : microserviceGroup.slice(0, -1);
828
- for (const role of user.app_metadata.roles) {
829
- if (role === "player") {
830
- if (microserviceGroup === "players")
831
- roles.push(role);
832
- else
833
- continue;
834
- }
835
- if (role.startsWith(`${rolePrefix}_`))
836
- roles.push(role.replace(`${rolePrefix}_`, ""));
837
- }
838
- // Redirect to Players microservices if user does not have any roles for the microservice group
839
- if (roles.length === 0 && microserviceGroup !== "players") {
840
- // Return error if Players URL is not provided
841
- if (!playersUrl)
842
- return { error: Utility.customError(400, "Players URL is required.") };
843
- // Redirect to Players microservices
844
- return { redirect: { code: 307, url: playersUrl } };
845
- }
846
- // Destructure role
847
- const [role] = roles;
848
- // Define condition variables
849
- const isPlayer = microserviceGroup === "players" && role === "player";
850
- const isSuperuser = (microserviceGroup === "backroom" || microserviceGroup === "lounges") && role === "superuser";
851
- let isAuthorised = false;
852
- // Grant access if Player (accessing Players microservices), superuser, or account microservice
853
- if (isPlayer || isSuperuser || microservice === "account")
854
- isAuthorised = true;
855
- // Evaluate access
856
- else if (microserviceGroup !== "players") {
857
- // Return error if requested permission is not provided
858
- if (!options.requestedPermission)
859
- return { error: Utility.customError(400, "Requested permission is required.") };
860
- // Get user's role and requested app permission
861
- const requestedPermission = options.requestedPermission(role);
862
- // Check if user has the requested permission
863
- const { data, error: isAuthorisedError } = await supabase
864
- .schema("compliance")
865
- .rpc("authorise", { requested_permission: requestedPermission });
866
- if (isAuthorisedError)
867
- return Utility.parsePostgrestError(isAuthorisedError);
868
- // Assign result of authorisation check
869
- isAuthorised = data;
870
- }
871
- // If user has completed Player registration
872
- if (roles.includes("player")) {
873
- // Prepare person data
874
- let personData = {};
875
- if (user.app_metadata.person_data) {
876
- const { first_name: firstName, last_name: lastName, pseudonym } = user.app_metadata.person_data;
877
- personData = { firstName, lastName, phone: user.phone, pseudonym };
878
- }
879
- // Validate SuprSend subscriber
880
- const { code, error: validationError } = await Utility.callEdgeFunction(false, supabase, `suprsend/validate-subscriber?user-id=${user.id}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: personData });
881
- if (validationError)
882
- return { error: Utility.customError(code, validationError) };
883
- // Send welcome notification if not already sent
884
- const { error: welcomeError } = await Utility.sendWelcomeNotification(supabase, user, microserviceGroup);
885
- if (welcomeError)
886
- return { error: welcomeError };
887
- }
888
- // Return result
889
- return { isAuthorised };
890
- },
891
- /**
892
- * A reference of microservices available on the platform, represented as an object with each user group (i.e. `public`, `players`, `lounges`, and `backroom`) having its own array of microservices. Each microservice in a list is an object with the following properties:
893
- * - `name`: The name of the microservice.
894
- * - `description`: A description of the microservice.
895
- * - `slug`: The slug of the microservice.
896
- * - `features`: An array of features that the microservice has.
897
- * - `roles`: An array of roles attached to the microservice.
898
- */
899
- microservices: {
900
- backroom: [
901
- {
902
- name: "Compliance",
903
- description: "Management of compliance and regulatory adherence for Players and Lounges.",
904
- slug: "compliance",
905
- features: [
906
- {
907
- name: "Players",
908
- description: "Search, review, and manage Player data.",
909
- slug: "players",
910
- tabs: [
911
- {
912
- name: "Profile",
913
- description: "Review basic Player data.",
914
- root: true
915
- },
916
- {
917
- name: "Documents",
918
- description: "Review Player documents.",
919
- slug: "documents"
920
- },
921
- {
922
- name: "Data updates",
923
- description: "Review Player data updates.",
924
- slug: "data-updates",
925
- tabs: [
926
- {
927
- name: "Requests",
928
- description: "Review Player data update requests.",
929
- root: true
930
- },
931
- {
932
- name: "History",
933
- description: "Review Player data update history.",
934
- slug: "history"
935
- }
936
- ]
937
- },
938
- {
939
- name: "Credits",
940
- description: "Review Koloseum Credits data for the Player.",
941
- slug: "credits",
942
- tabs: [
943
- {
944
- name: "Transactions",
945
- description: "Review Player transactions with Koloseum Credits.",
946
- root: true
947
- },
948
- {
949
- name: "Transfers",
950
- description: "Review Player transfers with Koloseum Credits.",
951
- slug: "transfers"
952
- },
953
- {
954
- name: "Subscriptions",
955
- description: "Review Player subscriptions with Koloseum Credits.",
956
- slug: "subscriptions"
957
- }
958
- ]
959
- },
960
- {
961
- name: "Misconduct",
962
- description: "Review Player reports and sanctions.",
963
- slug: "misconduct",
964
- tabs: [
965
- {
966
- name: "Reports",
967
- description: "Review Player reports.",
968
- root: true
969
- },
970
- {
971
- name: "Sanctions",
972
- description: "Review Player sanctions.",
973
- slug: "sanctions"
974
- }
975
- ]
976
- },
977
- {
978
- name: "Points / KXP",
979
- description: "Review Koloseum Experience Points (KXP) transactions for the Player.",
980
- slug: "points"
981
- }
982
- ]
983
- },
984
- {
985
- name: "Lounges",
986
- description: "Search, review, and manage Lounge data.",
987
- slug: "lounges",
988
- tabs: [
989
- {
990
- name: "Profile",
991
- description: "Review basic Lounge data.",
992
- root: true
993
- },
994
- {
995
- name: "Documents",
996
- description: "Review Lounge documents.",
997
- slug: "documents"
998
- },
999
- {
1000
- name: "Staff",
1001
- description: "Search, review, and manage Lounge staff data.",
1002
- slug: "staff"
1003
- },
1004
- {
1005
- name: "Branches",
1006
- description: "Review Lounge branches.",
1007
- slug: "branches",
1008
- tabs: [
1009
- {
1010
- name: "Profile",
1011
- description: "Review basic Lounge branch data.",
1012
- root: true
1013
- },
1014
- {
1015
- name: "Amenities",
1016
- description: "Review Lounge branch amenities.",
1017
- slug: "amenities"
1018
- },
1019
- {
1020
- name: "Staff",
1021
- description: "Search, review, and manage Lounge branchstaff data.",
1022
- slug: "staff"
1023
- }
1024
- ]
1025
- },
1026
- {
1027
- name: "Data updates",
1028
- description: "Review Lounge data updates.",
1029
- slug: "data-updates",
1030
- tabs: [
1031
- {
1032
- name: "Requests",
1033
- description: "Review Lounge data update requests.",
1034
- root: true
1035
- },
1036
- {
1037
- name: "History",
1038
- description: "Review Lounge data update history.",
1039
- slug: "history"
1040
- }
1041
- ]
1042
- },
1043
- {
1044
- name: "Credits",
1045
- description: "Review Koloseum Credits data for the Lounge.",
1046
- slug: "credits",
1047
- tabs: [
1048
- {
1049
- name: "Transactions",
1050
- description: "Review Lounge transactions with Koloseum Credits.",
1051
- root: true
1052
- },
1053
- {
1054
- name: "Transfers",
1055
- description: "Review Lounge transfers with Koloseum Credits.",
1056
- slug: "transfers"
1057
- },
1058
- {
1059
- name: "Subscriptions",
1060
- description: "Review Lounge subscriptions with Koloseum Credits.",
1061
- slug: "subscriptions"
1062
- }
1063
- ]
1064
- },
1065
- {
1066
- name: "Misconduct",
1067
- description: "Review Lounge reports and sanctions.",
1068
- slug: "misconduct",
1069
- tabs: [
1070
- {
1071
- name: "Reports",
1072
- description: "Review Lounge reports.",
1073
- root: true
1074
- },
1075
- {
1076
- name: "Sanctions",
1077
- description: "Review Lounge sanctions.",
1078
- slug: "sanctions"
1079
- }
1080
- ]
1081
- },
1082
- {
1083
- name: "Points / KXP",
1084
- description: "Review Koloseum Experience Points (KXP) transactions for the Lounge.",
1085
- slug: "points"
1086
- }
1087
- ]
1088
- }
1089
- ],
1090
- roles: [
1091
- {
1092
- name: "Compliance",
1093
- slug: "compliance",
1094
- root: true
1095
- },
1096
- {
1097
- name: "Players",
1098
- slug: "players",
1099
- featureSlugs: ["players"]
1100
- },
1101
- {
1102
- name: "Lounges",
1103
- slug: "lounges",
1104
- featureSlugs: ["lounges"]
1105
- }
1106
- ]
1107
- },
1108
- {
1109
- name: "Competitions",
1110
- description: "Management of competitions data and live events across Markets.",
1111
- slug: "competitions",
1112
- features: [
1113
- {
1114
- name: "Ma Esto",
1115
- description: "Manage football esports data for Ma Esto.",
1116
- slug: "fbl"
1117
- },
1118
- {
1119
- name: "Savanna FGC",
1120
- description: "Manage fighting games esports data for Savanna FGC.",
1121
- slug: "fgc",
1122
- tabs: [
1123
- {
1124
- name: "Savanna Circuit",
1125
- description: "Manage competition data for the Savanna Circuit.",
1126
- slug: "league",
1127
- tabs: [
1128
- {
1129
- name: "Rankings",
1130
- description: "Manage rankings for any Savanna Circuit season.",
1131
- root: true
1132
- },
1133
- {
1134
- name: "Tournaments",
1135
- description: "Manage tournaments for any Savanna Circuit season.",
1136
- slug: "tournaments",
1137
- tabs: [
1138
- {
1139
- name: "Events",
1140
- description: "Manage events for any Savanna Circuit tournament.",
1141
- root: true
1142
- },
1143
- {
1144
- name: "Tickets",
1145
- description: "Manage tickets for any Savanna Circuit tournament.",
1146
- slug: "tickets"
1147
- },
1148
- {
1149
- name: "Settings",
1150
- description: "Manage settings for any Savanna Circuit tournament.",
1151
- slug: "settings"
1152
- }
1153
- ]
1154
- },
1155
- {
1156
- name: "Settings",
1157
- description: "Manage settings for any Savanna Circuit season.",
1158
- slug: "settings"
1159
- }
1160
- ]
1161
- },
1162
- {
1163
- name: "Savanna Fight Night",
1164
- description: "Manage competition data for Savanna Fight Night.",
1165
- slug: "challenges"
1166
- }
1167
- ]
1168
- },
1169
- {
1170
- name: "Hit List",
1171
- description: "Manage battle royale esports data for Hit List.",
1172
- slug: "bryl"
1173
- }
1174
- ],
1175
- roles: [
1176
- {
1177
- name: "Competitions",
1178
- slug: "competitions",
1179
- root: true
1180
- },
1181
- {
1182
- name: "Ma Esto",
1183
- slug: "fbl",
1184
- featureSlugs: ["fbl"]
1185
- },
1186
- {
1187
- name: "Savanna FGC",
1188
- slug: "fgc",
1189
- featureSlugs: ["fgc"]
1190
- },
1191
- {
1192
- name: "Hit List",
1193
- slug: "bryl",
1194
- featureSlugs: ["bryl"]
1195
- }
1196
- ]
1197
- },
1198
- {
1199
- name: "KLSM",
1200
- description: "Management of game and console trades between users.",
1201
- slug: "commerce",
1202
- features: [],
1203
- roles: [
1204
- {
1205
- name: "Commerce",
1206
- slug: "commerce",
1207
- root: true
1208
- }
1209
- ]
1210
- },
1211
- {
1212
- name: "Staff",
1213
- description: "Management of Backroom staff authorisation.",
1214
- slug: "staff",
1215
- features: [
1216
- {
1217
- name: "Roles",
1218
- description: "Review and manage Backroom roles.",
1219
- slug: "roles"
1220
- },
1221
- {
1222
- name: "Users",
1223
- description: "Review and manage Backroom users.",
1224
- slug: "users"
1225
- }
1226
- ],
1227
- roles: [
1228
- {
1229
- name: "Human Resources",
1230
- slug: "hr",
1231
- root: true
1232
- }
1233
- ]
1234
- }
1235
- ],
1236
- players: [
1237
- {
1238
- name: "Sessions",
1239
- description: "Track your gaming sessions at registered lounges.",
1240
- slug: "sessions",
1241
- features: [],
1242
- roles: null
1243
- },
1244
- {
1245
- name: "KLSM",
1246
- description: "Trade used games and consoles with other users at affordable prices.",
1247
- slug: "commerce",
1248
- features: [],
1249
- roles: null
1250
- },
1251
- {
1252
- name: "Savanna FGC",
1253
- description: "Check out the latest news and competitions from Savanna FGC.",
1254
- slug: "fgc",
1255
- features: [
1256
- {
1257
- name: "Home",
1258
- description: "Learn more about the Savanna Circuit.",
1259
- root: true
1260
- },
1261
- {
1262
- name: "Rules",
1263
- description: "Review the rules and regulations of the Savanna Circuit.",
1264
- slug: "rules"
1265
- },
1266
- {
1267
- name: "Tournaments",
1268
- description: "Review and join upcoming tournaments on the Savanna Circuit.",
1269
- slug: "tournaments"
1270
- }
1271
- ],
1272
- roles: null
1273
- },
1274
- {
1275
- name: "Account",
1276
- description: "Review Player account settings and preferences.",
1277
- slug: "account",
1278
- features: [
1279
- {
1280
- name: "Personal",
1281
- description: "Review and manage your personal information.",
1282
- slug: "personal"
1283
- },
1284
- {
1285
- name: "Gaming & Socials",
1286
- description: "Review and manage your gaming and social media information.",
1287
- slug: "gaming-socials"
1288
- },
1289
- {
1290
- name: "Notifications",
1291
- description: "Review and manage your notification preferences.",
1292
- slug: "notifications"
1293
- },
1294
- {
1295
- name: "Security",
1296
- description: "Review and manage your security preferences.",
1297
- slug: "security"
1298
- },
1299
- {
1300
- name: "Lounges",
1301
- description: "Review your access to Lounges microservices.",
1302
- slug: "lounges"
1303
- },
1304
- {
1305
- name: "Backroom",
1306
- description: "Review your access to Backroom microservices.",
1307
- slug: "backroom"
1308
- }
1309
- ],
1310
- roles: null
1311
- }
1312
- ]
1313
- },
1314
- /**
1315
- * The minimum birth date (from today's date) for a user who is a minor, i.e. `YYYY-MM-DD`
1316
- */
1317
- minimumMinorBirthDate: (() => {
1318
- const today = new Date();
1319
- const tomorrow = new Date();
1320
- tomorrow.setUTCDate(today.getDate() + 1);
1321
- const tomorrow18YearsAgo = tomorrow.getFullYear() - 18;
1322
- tomorrow.setFullYear(tomorrow18YearsAgo);
1323
- return tomorrow.toISOString().split("T")[0];
1324
- })(),
1325
- /**
1326
- * Check if a page is active based on the slug and microservice.
1327
- * @param page - The current page object.
1328
- * @param slug - The slug of the page to check.
1329
- * @param microservice - The microservice to check against.
1330
- * @param base - The base path of the application; defaults to an empty string.
1331
- * @returns `true` if the page is active, `false` otherwise.
1332
- */
1333
- pageIsActive: (page, slug, microservice, base = "") => {
1334
- // Return true if microservice is provided and matches slug
1335
- if (microservice)
1336
- return slug === microservice;
1337
- // Remove trailing slash from base for consistency
1338
- const cleanBase = base.replace(/\/$/, "");
1339
- // Match exactly the base path if slug is falsy (i.e. root microservice feature)
1340
- if (!slug)
1341
- return page.url.pathname === cleanBase || page.url.pathname === `${cleanBase}/`;
1342
- // Match exact or subpath otherwise
1343
- const target = `${cleanBase}/${slug}`;
1344
- return page.url.pathname === target || page.url.pathname.startsWith(`${target}/`);
1345
- },
1346
- /**
1347
- * Paginates an array.
1348
- * @template T - The type of array items; defaults to `any`
1349
- * @param {T[]} array - The array to paginate
1350
- * @param {number} page - The page number
1351
- * @param {number} pageSize - The number of items per page; defaults to 10
1352
- * @returns {Object} The paginated array and total number of pages
1353
- */
1354
- paginateArray: (array, page, pageSize = 10) => {
1355
- // Define variables
1356
- const startIndex = (page - 1) * pageSize;
1357
- const endIndex = startIndex + pageSize;
1358
- const totalPages = Math.ceil(array.length / pageSize);
1359
- // Paginate items
1360
- const paginatedItems = array.slice(startIndex, endIndex);
1361
- // Return data
1362
- return { paginatedItems, totalPages };
1363
- },
1364
- /**
1365
- * Parses a `CustomError` object within a page/layout server load function and returns an error to be thrown.
1366
- * @param {CustomError} error - The error object
1367
- * @returns A new `Error` object if the error is unexpected, or a Svelte error otherwise
1368
- */
1369
- parseLoadError: (error) => error.code === 500 ? new Error(error.message) : svelteError(error.code, { message: error.message }),
1370
- /**
1371
- * Parses a Postgres interval string and returns a string in the specified format.
1372
- * @param {string} interval - The interval string to parse
1373
- * @param {"postgres" | "iso" | "iso-short"} type - The format to return the interval in; defaults to `postgres`
1374
- * @returns A string in the specified format
1375
- */
1376
- parsePostgresInterval: (interval, type = "postgres") => {
1377
- // Return null if interval is falsy
1378
- if (!interval)
1379
- return null;
1380
- // Parse interval and return in the specified format
1381
- const { toPostgres, toISOString, toISOStringShort } = parsePgInterval(interval);
1382
- if (type === "postgres")
1383
- return toPostgres();
1384
- if (type === "iso")
1385
- return toISOString();
1386
- if (type === "iso-short")
1387
- return toISOStringShort();
1388
- // Return null if type is invalid
1389
- return null;
1390
- },
1391
- /**
1392
- * Parses a `PostgrestError` object and returns a custom error object if any has occurred.
1393
- * @param {PostgrestError | null} postgrestError - The `PostgrestError` object, or `null` if no error occurred
1394
- * @param {boolean} clientSafe - Whether to clamp 5xx errors down to 422 to prevent Sentry from capturing them as unhandled; defaults to `true`
1395
- * @returns An object with an `error` if any has occurred
1396
- */
1397
- parsePostgrestError: (postgrestError, clientSafe = true) => {
1398
- // Return undefined if no error occurred
1399
- let error;
1400
- if (!postgrestError)
1401
- return { error };
1402
- // Get custom error code from hint
1403
- const customErrorCode = Number(postgrestError.hint);
1404
- // Clamp 5xx errors down to 422 to prevent Sentry from capturing them as unhandled; see https://koloseum-technologies.sentry.io/issues/6766267685/
1405
- let statusCode = clientSafe ? (customErrorCode >= 500 ? 422 : customErrorCode) : customErrorCode;
1406
- // Return custom error if hint is a number between 400 and 599
1407
- if (!isNaN(customErrorCode) && customErrorCode >= 400 && customErrorCode <= 599) {
1408
- error = Utility.customError(statusCode, postgrestError.message);
1409
- return { error };
1410
- }
1411
- // Map Postgrest error codes to custom error codes
1412
- const errorMap = [
1413
- { code: "08", status: 503 },
1414
- { code: "09", status: 500 },
1415
- { code: "0L", status: 403 },
1416
- { code: "0P", status: 403 },
1417
- { code: "23503", status: 409 },
1418
- { code: "23505", status: 409 },
1419
- { code: "25006", status: 405 },
1420
- { code: "25", status: 500 },
1421
- { code: "28", status: 403 },
1422
- { code: "2D", status: 500 },
1423
- { code: "38", status: 500 },
1424
- { code: "39", status: 500 },
1425
- { code: "3B", status: 500 },
1426
- { code: "40", status: 500 },
1427
- { code: "53400", status: 500 },
1428
- { code: "53", status: 503 },
1429
- { code: "54", status: 500 },
1430
- { code: "55", status: 500 },
1431
- { code: "57", status: 500 },
1432
- { code: "58", status: 500 },
1433
- { code: "F0", status: 500 },
1434
- { code: "HV", status: 500 },
1435
- { code: "P0001", status: 400 },
1436
- { code: "P0", status: 500 },
1437
- { code: "XX", status: 500 },
1438
- { code: "42883", status: 404 },
1439
- { code: "42P01", status: 404 },
1440
- { code: "42P17", status: 500 },
1441
- { code: "42501", status: 403 }
1442
- ];
1443
- // Return custom error if Postgrest error code is found
1444
- for (const { code, status } of errorMap)
1445
- if (postgrestError.code === code || postgrestError.code.startsWith(code)) {
1446
- statusCode = clientSafe ? (status >= 500 ? 422 : status) : status;
1447
- error = Utility.customError(statusCode, Status.ERROR);
1448
- return { error };
1449
- }
1450
- // Return generic error
1451
- error = Utility.customError(clientSafe ? 422 : 500, Status.ERROR);
1452
- return { error };
1453
- },
1454
- /**
1455
- * A regular expression for user passwords to match during authentication. It covers the following rules:
1456
- * - at least 8 characters long
1457
- * - at least one digit
1458
- * - at least one lowercase letter
1459
- * - at least one uppercase letter
1460
- * - at least one symbol
1461
- */
1462
- passwordRegex: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*()-_+=|{}[\]:;'<>,.?/~]).{8,}$/,
1463
- /**
1464
- * Returns a regular expression for a Koloseum platform ID.
1465
- * @param {string} type - The type of platform ID to return
1466
- * @returns A regular expression for the platform ID, or `null` if the type is invalid
1467
- */
1468
- platformIdRegex: (type) => {
1469
- if (type === "lounge")
1470
- return /^KL\d{7}$/;
1471
- if (type === "loungeBranch")
1472
- return /^KLB\d{7}$/;
1473
- if (type === "player")
1474
- return /^KP\d{7}$/;
1475
- return null;
1476
- },
1477
- /**
1478
- * Sanitises any potential HTML injected into user input.
1479
- * @param {string} input - The input to be sanitised
1480
- * @returns A sanitised string, or an empty string if the input is invalid
1481
- */
1482
- sanitiseHtml: (input) => typeof input !== "string" ? "" : sanitize(input, { allowedTags: [], allowedAttributes: {} }),
1483
- /**
1484
- * Sends a welcome notification to a new user.
1485
- * @param {SupabaseClient<Database>} supabase - The Supabase client
1486
- * @param {UserWithCustomMetadata} user - The user to send the notification to
1487
- * @param {MicroserviceGroup} microserviceGroup - The microservice group the user belongs to
1488
- * @returns An object with an `error` if any has occurred
1489
- */
1490
- sendWelcomeNotification: async (supabase, user, microserviceGroup) => {
1491
- // Backroom
1492
- if (microserviceGroup === "backroom" && !user.user_metadata.backroom?.welcome_notification_sent) {
1493
- // Use atomic update with timestamp to prevent race conditions
1494
- const currentTime = new Date().toISOString();
1495
- const { data: updatedUser, error: updateError } = await supabase.auth.updateUser({
1496
- data: {
1497
- backroom: {
1498
- ...user.user_metadata.backroom,
1499
- welcome_notification_sent: true,
1500
- welcome_notification_timestamp: currentTime
1501
- }
1502
- }
1503
- });
1504
- if (updateError)
1505
- return { error: Utility.customError(updateError.status ?? 500, updateError.message) };
1506
- // Only send notification if flag was successfully updated
1507
- const updatedBackroom = updatedUser.user?.user_metadata?.backroom;
1508
- if (updatedBackroom?.welcome_notification_sent === true &&
1509
- updatedBackroom?.welcome_notification_timestamp === currentTime) {
1510
- // Define SuprSend workflow body
1511
- const workflowBody = {
1512
- name: "welcome-to-backroom",
1513
- template: "welcome-to-backroom",
1514
- notification_category: "system",
1515
- users: [
1516
- {
1517
- distinct_id: user.id,
1518
- $skip_create: true
1519
- }
1520
- ]
1521
- };
1522
- // Send welcome notification
1523
- const { code, error: workflowError } = await Utility.callEdgeFunction(false, supabase, `suprsend/trigger-workflow?user-id=${user.id}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: workflowBody });
1524
- // If notification fails, revert the flag and return error
1525
- if (workflowError) {
1526
- await supabase.auth.updateUser({
1527
- data: {
1528
- backroom: {
1529
- ...user.user_metadata.backroom,
1530
- welcome_notification_sent: false,
1531
- welcome_notification_timestamp: undefined
1532
- }
1533
- }
1534
- });
1535
- return { error: Utility.customError(code, workflowError) };
1536
- }
1537
- // Update user object in memory
1538
- user.user_metadata.backroom = updatedBackroom;
1539
- }
1540
- }
1541
- // Return empty object
1542
- return {};
1543
- },
1544
- /**
1545
- * A regular expression for social media handles, without a leading slash or @ character.
1546
- *
1547
- * - Source: https://stackoverflow.com/a/74579651
1548
- */
1549
- socialMediaHandleRegex: /^([A-Za-z0-9_.]{3,25})$/gm,
1550
- /**
1551
- * Validates address data submitted in a form and returns the validated data.
1552
- * @param {FormData} formData - The submitted form data
1553
- * @returns An object with the validated `address`, or an `error` if any has occurred
1554
- */
1555
- validateAddress: async (formData) => {
1556
- // Create Kenya administrative divisions instance
1557
- const kenyaAdmin = new KenyaAdministrativeDivisions();
1558
- // Building name
1559
- let buildingName = formData.get("building-name");
1560
- if (!buildingName)
1561
- return { error: Utility.customError(400, "Building name is required.") };
1562
- buildingName = Utility.sanitiseHtml(trim(escape(buildingName)));
1563
- // Unit number
1564
- let unitNumber = formData.get("unit-number") || undefined;
1565
- if (unitNumber) {
1566
- unitNumber = Utility.sanitiseHtml(trim(escape(unitNumber)));
1567
- if (unitNumber === "")
1568
- unitNumber = undefined;
1569
- }
1570
- // Street name
1571
- let streetName = formData.get("street-name");
1572
- if (!streetName)
1573
- return { error: Utility.customError(400, "Street name is required.") };
1574
- streetName = Utility.sanitiseHtml(trim(escape(streetName)));
1575
- // P.O. box and postal code
1576
- let boxNumber = formData.get("box-number") || undefined;
1577
- let postalCode = formData.get("postal-code") || undefined;
1578
- if (boxNumber) {
1579
- boxNumber = Utility.sanitiseHtml(trim(escape(boxNumber)));
1580
- if (boxNumber === "")
1581
- boxNumber = undefined;
1582
- if (boxNumber && !postalCode)
1583
- return { error: Utility.customError(400, "Postal code is required.") };
1584
- }
1585
- if (postalCode) {
1586
- postalCode = Utility.sanitiseHtml(trim(escape(postalCode)));
1587
- if (postalCode === "")
1588
- postalCode = undefined;
1589
- if (postalCode && !boxNumber)
1590
- return { error: Utility.customError(400, "P.O. box is required.") };
1591
- }
1592
- // Town
1593
- let town = formData.get("town");
1594
- if (!town)
1595
- return { error: Utility.customError(400, "City/town is required.") };
1596
- town = Utility.sanitiseHtml(trim(escape(town)));
1597
- // County
1598
- let county = undefined;
1599
- const countyCode = Number(formData.get("county-code"));
1600
- if (isNaN(countyCode))
1601
- return { error: Utility.customError(400, "County is invalid.") };
1602
- if (!countyCode)
1603
- return { error: Utility.customError(400, "County is required.") };
1604
- const counties = await kenyaAdmin.getAll();
1605
- if (!counties || counties.length === 0)
1606
- return { error: Utility.customError(400, "County is invalid.") };
1607
- const countyData = counties.find(({ county_code }) => county_code === countyCode);
1608
- if (!countyData)
1609
- return { error: Utility.customError(400, "County is invalid.") };
1610
- const { county_name, constituencies } = countyData;
1611
- county = county_name;
1612
- // Sub-county
1613
- let subCounty = formData.get("sub-county") || undefined;
1614
- if (subCounty) {
1615
- subCounty = Utility.sanitiseHtml(trim(escape(subCounty)));
1616
- if (subCounty === "")
1617
- subCounty = undefined;
1618
- else {
1619
- // Check if constituencies exists and is an array before mapping
1620
- if (!constituencies || !Array.isArray(constituencies))
1621
- return { error: Utility.customError(400, "Sub-county data is unavailable.") };
1622
- // Get list of sub-counties
1623
- const subCounties = constituencies.map(({ constituency_name: name }) => name);
1624
- if (!subCounties.includes(subCounty))
1625
- return { error: Utility.customError(400, "Sub-county is invalid.") };
1626
- }
1627
- }
1628
- // Additional information
1629
- let additionalInfo = formData.get("address-additional-info") || undefined;
1630
- if (additionalInfo) {
1631
- additionalInfo = Utility.sanitiseHtml(trim(escape(additionalInfo)));
1632
- if (additionalInfo === "")
1633
- additionalInfo = undefined;
1634
- }
1635
- // Create address object
1636
- const address = {
1637
- buildingName,
1638
- unitNumber,
1639
- streetName,
1640
- town,
1641
- subCounty,
1642
- county,
1643
- additionalInfo
1644
- };
1645
- // Return data
1646
- return { address };
1647
- },
1648
- /**
1649
- * Validates an E.164 phone number.
1650
- * @param {string} phoneNumber - The phone number to be validated
1651
- * @param {"any" | MobilePhoneLocale | MobilePhoneLocale[]} [locale="any"] - The locale(s) to validate the phone number against
1652
- * @returns `true` if the phone number is valid; `false` otherwise
1653
- */
1654
- validateE164: (phoneNumber, locale = "en-KE") => isMobilePhone(phoneNumber, locale) && Boolean(phoneNumber.match(Utility.e164Regex)),
1655
- /**
1656
- * Validates a social media handle.
1657
- * @param {string} handle - The social media handle to be validated
1658
- * @returns `true` if the handle is valid; `false` otherwise
1659
- */
1660
- validateSocialMediaHandle: (handle) => !isURL(handle, { require_protocol: false }) &&
1661
- !handle.startsWith("@") &&
1662
- Boolean(handle.match(Utility.socialMediaHandleRegex))
1663
- };
1664
- /* CACHE FUNCTIONS */
1665
- export const Cache = {
1666
- /**
1667
- * Delete cached data from Valkey.
1668
- * @param valkey - The Valkey client instance
1669
- * @param cachePrefix - The cache prefix
1670
- * @param key - The cache key
1671
- */
1672
- deleteData: async (valkey, cachePrefix, key) => {
1673
- try {
1674
- await valkey.del(`${cachePrefix}${key}`);
1675
- }
1676
- catch (error) {
1677
- console.error("Cache delete error:", error);
1678
- }
1679
- },
1680
- /**
1681
- * Generate cache key with consistent formatting
1682
- * @param prefix - The key prefix
1683
- * @param params - Additional parameters to include in the key
1684
- * @returns The formatted cache key
1685
- */
1686
- generateDataKey: (prefix, ...params) => `${prefix}:${params.join(":")}`,
1687
- /**
1688
- * Get cached data from Valkey.
1689
- * @param valkey - The Valkey client instance
1690
- * @param cachePrefix - The cache prefix
1691
- * @param key - The cache key
1692
- * @returns The cached data, or `null` if not found
1693
- */
1694
- getData: async (valkey, cachePrefix, key) => {
1695
- try {
1696
- const cached = await valkey.get(`${cachePrefix}${key}`);
1697
- return cached ? JSON.parse(cached) : null;
1698
- }
1699
- catch (error) {
1700
- console.error("Cache get error:", error);
1701
- return null;
1702
- }
1703
- },
1704
- /**
1705
- * Invalidate cache by pattern
1706
- * @param valkey - The Valkey client instance
1707
- * @param cachePrefix - The cache prefix
1708
- * @param pattern - The pattern to match keys against (e.g., "app-config*")
1709
- */
1710
- invalidateDataByPattern: async (valkey, cachePrefix, pattern) => {
1711
- try {
1712
- const keys = await valkey.keys(`${cachePrefix}${pattern}`);
1713
- if (keys.length > 0)
1714
- await valkey.del(...keys);
1715
- }
1716
- catch (error) {
1717
- console.error("Cache pattern invalidation error:", error);
1718
- }
1719
- },
1720
- /**
1721
- * Set data in Valkey cache.
1722
- * @param valkey - The Valkey client instance
1723
- * @param cachePrefix - The cache prefix
1724
- * @param key - The cache key
1725
- * @param data - The data to cache
1726
- * @param ttl - The time to live in seconds; defaults to 1 hour
1727
- */
1728
- setData: async (valkey, cachePrefix, key, data, ttl = 3600) => {
1729
- try {
1730
- await valkey.setex(`${cachePrefix}${key}`, ttl, JSON.stringify(data));
1731
- }
1732
- catch (error) {
1733
- console.error("Cache set error:", error);
1734
- }
1735
- }
1736
- };
1737
- /* BROWSER COMPATIBILITY FUNCTIONS */
1738
- export const Browser = {
1739
- /**
1740
- * Checks if a specific feature is supported by the browser.
1741
- * @param feature - The feature to check
1742
- * @param browserName - The name of the browser
1743
- * @param browserVersion - The version of the browser
1744
- * @returns `true` if the feature is supported, `false` otherwise
1745
- */
1746
- checkFeatureSupport: (feature, browserName, browserVersion) => {
1747
- // Feature support matrix based on browser versions
1748
- const featureSupport = {
1749
- optionalChaining: {
1750
- Chrome: 80,
1751
- Firefox: 74,
1752
- Safari: 13.4,
1753
- Edge: 80,
1754
- Opera: 80
1755
- },
1756
- nullishCoalescing: {
1757
- Chrome: 80,
1758
- Firefox: 72,
1759
- Safari: 13.4,
1760
- Edge: 80,
1761
- Opera: 80
1762
- },
1763
- fetch: {
1764
- Chrome: 42,
1765
- Firefox: 39,
1766
- Safari: 10.1,
1767
- Edge: 14,
1768
- Opera: 29
1769
- },
1770
- es6Modules: {
1771
- Chrome: 61,
1772
- Firefox: 60,
1773
- Safari: 10.1,
1774
- Edge: 16,
1775
- Opera: 48
1776
- },
1777
- asyncAwait: {
1778
- Chrome: 55,
1779
- Firefox: 52,
1780
- Safari: 10.1,
1781
- Edge: 14,
1782
- Opera: 42
1783
- }
1784
- };
1785
- const featureMatrix = featureSupport[feature];
1786
- if (!featureMatrix)
1787
- return true; // Unknown feature, assume supported
1788
- const minVersion = featureMatrix[browserName];
1789
- if (!minVersion)
1790
- return true; // Unknown browser, assume supported
1791
- return browserVersion >= minVersion;
1792
- },
1793
- /**
1794
- * Gets detailed browser information for debugging.
1795
- */
1796
- getDetailedInfo: () => {
1797
- if (typeof window === "undefined") {
1798
- return {
1799
- browser: "Server-side rendering",
1800
- os: "Unknown",
1801
- platform: "Unknown",
1802
- engine: "Unknown",
1803
- userAgent: "Unknown"
1804
- };
1805
- }
1806
- const browser = Bowser.getParser(window.navigator.userAgent);
1807
- return {
1808
- browser: `${browser.getBrowserName()} ${browser.getBrowserVersion()}`,
1809
- os: `${browser.getOSName()} ${browser.getOSVersion()}`,
1810
- platform: browser.getPlatformType(),
1811
- engine: browser.getEngineName(),
1812
- userAgent: window.navigator.userAgent
1813
- };
1814
- },
1815
- /**
1816
- * Gets comprehensive browser information using Bowser.
1817
- */
1818
- getInfo: () => {
1819
- if (typeof window === "undefined") {
1820
- return {
1821
- name: "server",
1822
- version: 0,
1823
- isOldSafari: false,
1824
- isIOS: false,
1825
- supportsOptionalChaining: true,
1826
- supportsNullishCoalescing: true,
1827
- supportsFetch: true
1828
- };
1829
- }
1830
- const browser = Bowser.getParser(window.navigator.userAgent);
1831
- const browserName = browser.getBrowserName();
1832
- const browserVersion = parseFloat(browser.getBrowserVersion() || "0");
1833
- const osName = browser.getOSName();
1834
- const isIOS = osName === "iOS";
1835
- const isSafari = browserName === "Safari";
1836
- const isOldSafari = isSafari && browserVersion < 13.4;
1837
- return {
1838
- name: browserName,
1839
- version: browserVersion,
1840
- isOldSafari,
1841
- isIOS,
1842
- supportsOptionalChaining: Browser.checkFeatureSupport("optionalChaining", browserName, browserVersion),
1843
- supportsNullishCoalescing: Browser.checkFeatureSupport("nullishCoalescing", browserName, browserVersion),
1844
- supportsFetch: Browser.checkFeatureSupport("fetch", browserName, browserVersion)
1845
- };
1846
- },
1847
- /**
1848
- * Gets specific browser recommendations based on the detected browser.
1849
- */
1850
- getRecommendations: () => {
1851
- if (typeof window === "undefined")
1852
- return {
1853
- title: "Browser compatibility issue",
1854
- message: "Browser detection is not available on theserver.",
1855
- recommendations: [],
1856
- critical: false
1857
- };
1858
- const browser = Bowser.getParser(window.navigator.userAgent);
1859
- const browserName = browser.getBrowserName();
1860
- const browserVersion = browser.getBrowserVersion();
1861
- // Internet Explorer
1862
- if (browserName === "Internet Explorer") {
1863
- return {
1864
- title: "Unsupported browser",
1865
- message: "Internet Explorer is not supported. Please use a modern browser.",
1866
- recommendations: [
1867
- "Download and install Microsoft Edge",
1868
- "Use an alternative such as Chrome or Firefox (or Safari on Mac)"
1869
- ],
1870
- critical: true
1871
- };
1872
- }
1873
- // Apple Safari
1874
- if (browserName === "Safari" && parseFloat(browserVersion || "0") < 13.4) {
1875
- return {
1876
- title: "Browser compatibility issue",
1877
- message: `You're using Safari ${browserVersion || "Unknown"}, which doesn't support modern web features.`,
1878
- recommendations: [
1879
- "Update to Safari 13.4 or later",
1880
- "Use Chrome or Firefox as an alternative",
1881
- "Update your iOS device to the latest version"
1882
- ],
1883
- critical: true
1884
- };
1885
- }
1886
- // Google Chrome
1887
- if (browserName === "Chrome" && parseFloat(browserVersion || "0") < 80) {
1888
- return {
1889
- title: "Outdated browser",
1890
- message: `You're using Chrome ${browserVersion || "Unknown"}, which may not support all features.`,
1891
- recommendations: ["Update Chrome to version 80 or later", "Enable automatic updates in Chrome settings"],
1892
- critical: false
1893
- };
1894
- }
1895
- // Mozilla Firefox
1896
- if (browserName === "Firefox" && parseFloat(browserVersion || "0") < 80) {
1897
- return {
1898
- title: "Outdated browser",
1899
- message: `You're using Firefox ${browserVersion || "Unknown"}, which may not support all features.`,
1900
- recommendations: ["Update Firefox to version 80 or later", "Enable automatic updates in Firefox settings"],
1901
- critical: false
1902
- };
1903
- }
1904
- // Microsoft Edge
1905
- if (browserName === "Edge" && parseFloat(browserVersion || "0") < 80) {
1906
- return {
1907
- title: "Outdated browser",
1908
- message: `You're using Edge ${browserVersion || "Unknown"}, which may not support all features.`,
1909
- recommendations: ["Update Edge to version 80 or later", "Enable automatic updates in Edge settings"],
1910
- critical: false
1911
- };
1912
- }
1913
- // For unknown or very old browsers
1914
- if (browserName === "Unknown" || parseFloat(browserVersion || "0") < 50) {
1915
- return {
1916
- title: "Unrecognised browser",
1917
- message: "Your browser may not be fully supported. For the best experience, please use a modern browser.",
1918
- recommendations: ["Use Chrome, Firefox or Edge (latest version)", "Use Safari 13.4+ (on Mac/iOS)"],
1919
- critical: true
1920
- };
1921
- }
1922
- // Default for modern browsers
1923
- return {
1924
- title: "Browser compatibility issue",
1925
- message: "Your browser may not fully support this application.",
1926
- recommendations: [
1927
- "Update your browser to the latest version",
1928
- "Clear your browser cache and cookies",
1929
- "Disable browser extensions temporarily",
1930
- "Try a different modern browser"
1931
- ],
1932
- critical: false
1933
- };
1934
- },
1935
- /**
1936
- * Checks if the current browser is compatible with the application.
1937
- */
1938
- isBrowserCompatible: () => {
1939
- if (typeof window === "undefined")
1940
- return true;
1941
- const browser = Bowser.getParser(window.navigator.userAgent);
1942
- const browserName = browser.getBrowserName();
1943
- const browserVersion = parseFloat(browser.getBrowserVersion() || "0");
1944
- // Define minimum supported versions
1945
- const minVersions = {
1946
- "Internet Explorer": 0, // Not supported at all
1947
- Safari: 13.4,
1948
- Chrome: 80,
1949
- Firefox: 80,
1950
- Edge: 80,
1951
- Opera: 80
1952
- };
1953
- // Internet Explorer is never compatible
1954
- if (browserName === "Internet Explorer") {
1955
- return false;
1956
- }
1957
- // Check if browser version meets minimum requirements
1958
- const minVersion = minVersions[browserName];
1959
- if (minVersion && browserVersion < minVersion) {
1960
- return false;
1961
- }
1962
- // Check for critical modern features
1963
- return (Browser.checkFeatureSupport("optionalChaining", browserName, browserVersion) &&
1964
- Browser.checkFeatureSupport("nullishCoalescing", browserName, browserVersion) &&
1965
- Browser.checkFeatureSupport("fetch", browserName, browserVersion) &&
1966
- Browser.checkFeatureSupport("es6Modules", browserName, browserVersion) &&
1967
- Browser.checkFeatureSupport("asyncAwait", browserName, browserVersion));
1968
- },
1969
- /**
1970
- * Checks if the browser is Internet Explorer.
1971
- */
1972
- isInternetExplorer: () => {
1973
- if (typeof window === "undefined")
1974
- return false;
1975
- const browser = Bowser.getParser(window.navigator.userAgent);
1976
- return browser.getBrowserName() === "Internet Explorer";
1977
- },
1978
- /**
1979
- * Checks if the browser is iOS Safari.
1980
- */
1981
- isIOSSafari: () => {
1982
- if (typeof window === "undefined")
1983
- return false;
1984
- const browser = Bowser.getParser(window.navigator.userAgent);
1985
- return browser.getOSName() === "iOS" && browser.getBrowserName() === "Safari";
1986
- },
1987
- /**
1988
- * Checks if the browser is a legacy version that needs updating.
1989
- */
1990
- isLegacyBrowser: () => {
1991
- if (typeof window === "undefined")
1992
- return false;
1993
- const browser = Bowser.getParser(window.navigator.userAgent);
1994
- const browserName = browser.getBrowserName();
1995
- const browserVersion = parseFloat(browser.getBrowserVersion() || "0");
1996
- // Legacy browser thresholds
1997
- const legacyThresholds = {
1998
- "Internet Explorer": 0, // All versions are legacy
1999
- Safari: 13.4,
2000
- Chrome: 80,
2001
- Firefox: 80,
2002
- Edge: 80,
2003
- Opera: 80
2004
- };
2005
- const threshold = legacyThresholds[browserName];
2006
- return threshold ? browserVersion < threshold : false;
2007
- },
2008
- /**
2009
- * Checks if the browser is an old version of Safari.
2010
- */
2011
- isOldSafari: () => {
2012
- if (typeof window === "undefined")
2013
- return false;
2014
- const browser = Bowser.getParser(window.navigator.userAgent);
2015
- const browserName = browser.getBrowserName();
2016
- const browserVersion = parseFloat(browser.getBrowserVersion() || "0");
2017
- return browserName === "Safari" && browserVersion < 13.4;
2018
- }
2019
- };