@mundogamernetwork/shared-ui 1.0.2 → 1.1.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,117 @@
1
+ import { usePaymentListener } from "./usePaymentListener";
2
+
3
+ export type OrderStatus = "paid" | "pending" | "failed" | "canceled" | "";
4
+
5
+ export interface ConfirmationInvoice {
6
+ status?: string;
7
+ payment_gateway?: string;
8
+ payment_method?: string;
9
+ code_gateway?: string;
10
+ payment_link?: string;
11
+ user?: { email?: string };
12
+ items?: { data: any[] };
13
+ currency?: { data?: { code?: string; symbol?: string } };
14
+ total?: string | number;
15
+ formatted_total?: string;
16
+ formatted_original_total?: string;
17
+ formatted_total_discount?: string;
18
+ discount_coupon?: { code: string };
19
+ reference?: string;
20
+ paid_at?: string;
21
+ created_at?: string;
22
+ [key: string]: any;
23
+ }
24
+
25
+ export function useConfirmation(fetchInvoice: (id: string) => Promise<ConfirmationInvoice>) {
26
+ const invoiceId = ref<string>("");
27
+ const cartData = ref<ConfirmationInvoice>({});
28
+ const displayStatus = ref<OrderStatus>("");
29
+ const processingTooLong = ref(false);
30
+
31
+ const LONG_PENDING_MS = 60_000;
32
+ let longPendingTimer: ReturnType<typeof setTimeout> | null = null;
33
+
34
+ // PayPal = 2, Stripe = 3
35
+ const paymentMethodId = computed(() => {
36
+ const sources = [
37
+ cartData.value?.payment_gateway,
38
+ cartData.value?.payment_method,
39
+ cartData.value?.code_gateway,
40
+ typeof window !== "undefined" ? localStorage.getItem("paymentGateway") : null,
41
+ ]
42
+ .filter(Boolean)
43
+ .join(" ")
44
+ .toLowerCase();
45
+
46
+ if (sources.includes("stripe") || sources.includes("credit") || sources.includes("card")) return 3;
47
+ return 2;
48
+ });
49
+
50
+ const hasKnownPaymentMethod = computed(() => {
51
+ const fromInvoice = [
52
+ cartData.value?.payment_gateway,
53
+ cartData.value?.payment_method,
54
+ cartData.value?.code_gateway,
55
+ ].some(Boolean);
56
+ const fromStorage = typeof window !== "undefined" && !!localStorage.getItem("paymentGateway");
57
+ return fromInvoice || fromStorage;
58
+ });
59
+
60
+ const getInvoiceData = async (): Promise<boolean> => {
61
+ if (!invoiceId.value) return false;
62
+ try {
63
+ const data = await fetchInvoice(invoiceId.value);
64
+ cartData.value = data;
65
+ displayStatus.value = (data.status as OrderStatus) ?? "";
66
+ return data.status !== "pending";
67
+ } catch {
68
+ return false;
69
+ }
70
+ };
71
+
72
+ const cancelLongPendingTimer = () => {
73
+ if (longPendingTimer) {
74
+ clearTimeout(longPendingTimer);
75
+ longPendingTimer = null;
76
+ }
77
+ };
78
+
79
+ const { cancelSafetyNets } = usePaymentListener(
80
+ invoiceId,
81
+ () => getInvoiceData(),
82
+ () => {
83
+ cancelSafetyNets();
84
+ cancelLongPendingTimer();
85
+ displayStatus.value = "failed";
86
+ },
87
+ () => {
88
+ cancelSafetyNets();
89
+ cancelLongPendingTimer();
90
+ displayStatus.value = "canceled";
91
+ },
92
+ );
93
+
94
+ const init = async (id: string) => {
95
+ invoiceId.value = id;
96
+ await getInvoiceData();
97
+
98
+ if (displayStatus.value === "pending") {
99
+ longPendingTimer = setTimeout(() => {
100
+ if (displayStatus.value === "pending") processingTooLong.value = true;
101
+ }, LONG_PENDING_MS);
102
+ }
103
+ };
104
+
105
+ onUnmounted(cancelLongPendingTimer);
106
+
107
+ return {
108
+ invoiceId,
109
+ cartData,
110
+ displayStatus,
111
+ processingTooLong,
112
+ paymentMethodId,
113
+ hasKnownPaymentMethod,
114
+ getInvoiceData,
115
+ init,
116
+ };
117
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Subscribes to the user's private WS channel and reacts immediately when the
3
+ * server broadcasts a `force-logout` event (triggered by logout from any
4
+ * platform, or by revoking a device/app from account settings).
5
+ *
6
+ * Usage: call `useForceLogout(loginUrl)` once in your app.vue or root layout.
7
+ * The composable is a no-op when the user is not signed in or Echo is absent.
8
+ */
9
+ import { clearClientSession } from "../utils/clearSession";
10
+
11
+ export function useForceLogout(loginUrl: string) {
12
+ const authStore = useAuthStore();
13
+ const { user, signedIn } = storeToRefs(authStore);
14
+
15
+ let currentUserId: number | null = null;
16
+
17
+ const getNumericId = (): number | null => {
18
+ if (!user.value) return null;
19
+ const id =
20
+ (user.value as any).numeric_id ??
21
+ (user.value as any).user_id ??
22
+ (user.value as any).id;
23
+ return id && !isNaN(Number(id)) ? Number(id) : null;
24
+ };
25
+
26
+ const unsubscribe = () => {
27
+ if (currentUserId && typeof window !== "undefined" && window.Echo) {
28
+ // Leave only if no other consumer (e.g. notifications) is keeping the channel open.
29
+ // Echo reference-counts channels; calling leave() here is safe.
30
+ window.Echo.leaveChannel(`private-App.Models.User.${currentUserId}`);
31
+ currentUserId = null;
32
+ }
33
+ };
34
+
35
+ const subscribe = (numericId: number) => {
36
+ if (!window.Echo) return;
37
+ currentUserId = numericId;
38
+ window.Echo.private(`App.Models.User.${numericId}`).listen(
39
+ ".force-logout",
40
+ (_data: { source: string }) => {
41
+ // Full client reset so a stale token can never block the next login.
42
+ clearClientSession();
43
+ window.location.href = loginUrl;
44
+ },
45
+ );
46
+ };
47
+
48
+ watch(
49
+ signedIn,
50
+ (val) => {
51
+ if (val) {
52
+ const id = getNumericId();
53
+ if (id) subscribe(id);
54
+ } else {
55
+ unsubscribe();
56
+ }
57
+ },
58
+ { immediate: true },
59
+ );
60
+
61
+ // Also watch user ID in case it resolves after signedIn flips
62
+ watch(
63
+ () => getNumericId(),
64
+ (id) => {
65
+ if (signedIn.value && id && id !== currentUserId) {
66
+ unsubscribe();
67
+ subscribe(id);
68
+ }
69
+ },
70
+ );
71
+
72
+ onUnmounted(unsubscribe);
73
+ }
@@ -0,0 +1,81 @@
1
+ import type { Ref } from "vue";
2
+
3
+ export function usePaymentListener(
4
+ invoiceId: Ref<string | null | undefined>,
5
+ onPaid: () => void,
6
+ onFailed?: () => void,
7
+ onCanceled?: () => void,
8
+ ) {
9
+ const safetyNetTimers: ReturnType<typeof setTimeout>[] = [];
10
+ const SAFETY_NET_DELAYS = [5_000, 15_000, 30_000];
11
+ const SAFETY_NET_INTERVAL_MS = 30_000;
12
+ let safetyNetAttempt = 0;
13
+ let currentInvoiceId: string | null = null;
14
+
15
+ const scheduleSafetyNet = (checkFn: () => Promise<boolean>) => {
16
+ const delay =
17
+ safetyNetAttempt < SAFETY_NET_DELAYS.length
18
+ ? SAFETY_NET_DELAYS[safetyNetAttempt++]
19
+ : SAFETY_NET_INTERVAL_MS;
20
+
21
+ const timer = setTimeout(async () => {
22
+ const resolved = await checkFn();
23
+ if (!resolved) scheduleSafetyNet(checkFn);
24
+ }, delay);
25
+
26
+ safetyNetTimers.push(timer);
27
+ };
28
+
29
+ const cancelSafetyNets = () => {
30
+ safetyNetTimers.forEach(clearTimeout);
31
+ safetyNetTimers.length = 0;
32
+ safetyNetAttempt = 0;
33
+ };
34
+
35
+ const subscribe = (invoiceIdValue: string, checkFn?: () => Promise<boolean>) => {
36
+ if (!window.Echo) return;
37
+
38
+ currentInvoiceId = invoiceIdValue;
39
+ const channel = window.Echo.channel(`invoice.${invoiceIdValue}`);
40
+
41
+ channel.listen(".invoice.paid", () => {
42
+ cancelSafetyNets();
43
+ onPaid();
44
+ });
45
+
46
+ channel.listen(".invoice.failed", () => {
47
+ cancelSafetyNets();
48
+ onFailed?.();
49
+ });
50
+
51
+ channel.listen(".invoice.canceled", () => {
52
+ cancelSafetyNets();
53
+ onCanceled?.();
54
+ });
55
+
56
+ if (checkFn) {
57
+ scheduleSafetyNet(checkFn);
58
+ }
59
+ };
60
+
61
+ const unsubscribe = () => {
62
+ cancelSafetyNets();
63
+ if (currentInvoiceId && window.Echo) {
64
+ window.Echo.leaveChannel(`invoice.${currentInvoiceId}`);
65
+ currentInvoiceId = null;
66
+ }
67
+ };
68
+
69
+ watch(
70
+ invoiceId,
71
+ (id) => {
72
+ unsubscribe();
73
+ if (id) subscribe(id);
74
+ },
75
+ { immediate: true },
76
+ );
77
+
78
+ onUnmounted(unsubscribe);
79
+
80
+ return { subscribe, unsubscribe, scheduleSafetyNet, cancelSafetyNets };
81
+ }
package/error.vue CHANGED
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
- const error = useError();
2
+ import { HttpStatusCode } from "axios";
3
+ import { clearAuthCredentials, clearClientSession } from "./utils/clearSession";
3
4
 
4
5
  interface CustomError {
5
6
  url: string;
@@ -10,131 +11,77 @@ interface CustomError {
10
11
  data?: any;
11
12
  }
12
13
 
14
+ const error = useError();
13
15
  const statusCodeError = Number((error.value as CustomError)?.statusCode) || 404;
14
16
 
15
- const authStore = useAuthStore();
17
+ // Do NOT call getUser() on error page — causes infinite loop when API is down or token expired
16
18
 
17
- onMounted(async () => {
19
+ // Recovery banner: auto-reload for 5xx errors so users aren't stuck
20
+ const isRecoverableError = computed(
21
+ () => statusCodeError >= 500 || statusCodeError === 0,
22
+ );
23
+ const countdown = ref(15);
24
+ let countdownInterval: ReturnType<typeof setInterval> | null = null;
25
+
26
+ function resetAndReload() {
27
+ if (countdownInterval) clearInterval(countdownInterval);
28
+ clearClientSession();
18
29
  try {
19
- await authStore.getUser();
20
- } catch {
21
- // Silently handle auth failure on error pages
30
+ (window.location as any).reload(true);
31
+ } catch (_) {
32
+ window.location.href = window.location.href;
22
33
  }
23
- });
24
-
25
- const locale = useNuxtApp().$i18n?.locale?.value || "en";
26
-
27
- const errorConfig: Record<number, { title: string; description: string; icon: string }> = {
28
- 401: { title: "errors.401.title", description: "errors.401.description", icon: "lock" },
29
- 403: { title: "errors.403.title", description: "errors.403.description", icon: "lock" },
30
- 404: { title: "errors.404.title", description: "errors.404.description", icon: "search" },
31
- 500: { title: "errors.500.title", description: "errors.500.description", icon: "warning" },
32
- 503: { title: "errors.503.title", description: "errors.503.description", icon: "warning" },
33
- 504: { title: "errors.504.title", description: "errors.504.description", icon: "clock" },
34
- };
34
+ }
35
35
 
36
- const currentError = computed(() => errorConfig[statusCodeError] || errorConfig[404]);
36
+ onMounted(() => {
37
+ if (!isRecoverableError.value) return;
38
+ // Break the stale-cookie loop right away: clear ONLY auth credentials so the
39
+ // page (and the next reload) works as guest, without wiping cart/preferences.
40
+ clearAuthCredentials();
41
+ countdownInterval = setInterval(() => {
42
+ countdown.value -= 1;
43
+ if (countdown.value <= 0) resetAndReload();
44
+ }, 1000);
45
+ });
37
46
 
38
- function goHome() {
39
- clearError({ redirect: `/${locale}` });
40
- }
47
+ onUnmounted(() => {
48
+ if (countdownInterval) clearInterval(countdownInterval);
49
+ });
41
50
  </script>
42
51
 
43
52
  <template>
44
53
  <NuxtLayout>
45
- <div class="error-page">
46
- <div class="error-content">
47
- <div class="error-icon">
48
- <MGIcon :icon="currentError.icon" size="3x" />
49
- </div>
50
- <h1 class="error-code">{{ statusCodeError }}</h1>
51
- <h2 class="error-title">{{ $t(currentError.title) }}</h2>
52
- <p class="error-description">{{ $t(currentError.description) }}</p>
53
-
54
- <div v-if="statusCodeError === 404" class="error-suggestions">
55
- <ul>
56
- <li>{{ $t("errors.404.suggestion_1") }}</li>
57
- <li>{{ $t("errors.404.suggestion_2") }}</li>
58
- <li>{{ $t("errors.404.suggestion_3") }}</li>
59
- </ul>
60
- </div>
61
-
62
- <button class="btn-home" @click="goHome">
63
- {{ $t("errors.go_home") }}
64
- </button>
65
- </div>
66
- </div>
54
+ <errors401 v-if="statusCodeError === HttpStatusCode.Unauthorized" />
55
+ <errors403 v-else-if="statusCodeError === HttpStatusCode.Forbidden" />
56
+ <errors404 v-else-if="statusCodeError === HttpStatusCode.NotFound" />
57
+ <errors500 v-else-if="statusCodeError === HttpStatusCode.InternalServerError" />
58
+ <errors503 v-else-if="statusCodeError === HttpStatusCode.ServiceUnavailable" />
59
+ <errors504 v-else-if="statusCodeError === HttpStatusCode.GatewayTimeout" />
60
+ <errors500 v-else />
61
+
62
+ <p v-if="isRecoverableError" class="recovery-line">
63
+ {{ $t("errors.recovery_hint") }}
64
+ <a class="recovery-link" @click="resetAndReload">
65
+ {{ $t("errors.recovery_button") }} ({{ countdown }}s)
66
+ </a>
67
+ </p>
67
68
  </NuxtLayout>
68
69
  </template>
69
70
 
70
- <style lang="scss" scoped>
71
- .error-page {
72
- display: flex;
73
- justify-content: center;
74
- align-items: center;
75
- min-height: 60vh;
76
- padding: 32px;
77
- }
78
-
79
- .error-content {
71
+ <style scoped lang="scss">
72
+ .recovery-line {
80
73
  text-align: center;
81
- max-width: 480px;
82
-
83
- .error-icon {
84
- color: var(--inactive);
85
- margin-bottom: 16px;
86
- }
87
-
88
- .error-code {
89
- font-size: 72px;
90
- font-weight: 700;
91
- color: var(--chip-text);
92
- line-height: 1;
93
- margin-bottom: 8px;
94
- }
95
-
96
- .error-title {
97
- font-size: 28px;
98
- font-weight: 600;
99
- color: var(--card-cover-title);
100
- margin-bottom: 12px;
101
- }
102
-
103
- .error-description {
104
- font-size: 14px;
105
- line-height: 20px;
106
- color: var(--inactive);
107
- margin-bottom: 24px;
108
- }
109
-
110
- .error-suggestions {
111
- text-align: left;
112
- margin-bottom: 24px;
113
-
114
- ul {
115
- list-style: disc;
116
- padding-left: 20px;
117
-
118
- li {
119
- font-size: 13px;
120
- line-height: 22px;
121
- color: var(--inactive);
122
- }
123
- }
124
- }
125
-
126
- .btn-home {
127
- padding: 10px 32px;
128
- background: var(--chip-text);
129
- color: var(--chip-background-2);
130
- border: none;
131
- font-size: 14px;
132
- font-weight: 600;
133
- cursor: pointer;
134
-
135
- &:hover {
136
- opacity: 0.9;
137
- }
138
- }
74
+ font-size: 0.8rem;
75
+ color: var(--inactive, #888);
76
+ margin: 1.5rem auto 2.5rem;
77
+ padding: 0 1rem;
78
+ }
79
+ .recovery-link {
80
+ color: var(--highlight-color, #ff7700);
81
+ cursor: pointer;
82
+ text-decoration: underline;
83
+ white-space: nowrap;
84
+ margin-left: 0.35rem;
85
+ &:hover { opacity: 0.85; }
139
86
  }
140
87
  </style>
package/package.json CHANGED
@@ -1,42 +1,42 @@
1
1
  {
2
- "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.0.2",
4
- "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
- "type": "module",
6
- "main": "./nuxt.config.ts",
7
- "files": [
8
- "assets",
9
- "components",
10
- "composables",
11
- "middleware",
12
- "pages",
13
- "plugins",
14
- "services",
15
- "stores",
16
- "types",
17
- "utils",
18
- "error.vue",
19
- "nuxt.config.ts"
20
- ],
21
- "publishConfig": {
22
- "access": "public"
23
- },
24
- "scripts": {
25
- "dev": "nuxi dev .playground",
26
- "build": "nuxi build .playground"
27
- },
28
- "peerDependencies": {
29
- "@pinia/nuxt": ">=0.5.0",
30
- "@pinia-plugin-persistedstate/nuxt": ">=1.0.0",
31
- "axios": ">=1.0.0",
32
- "laravel-echo": ">=1.15.0",
33
- "nuxt": ">=3.13.0",
34
- "pinia": ">=2.1.0",
35
- "pusher-js": ">=8.0.0",
36
- "vue": ">=3.5.0"
37
- },
38
- "devDependencies": {
39
- "nuxt": "^3.15.4",
40
- "typescript": "^5.0.0"
41
- }
42
- }
2
+ "name": "@mundogamernetwork/shared-ui",
3
+ "version": "1.1.0",
4
+ "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
+ "type": "module",
6
+ "main": "./nuxt.config.ts",
7
+ "files": [
8
+ "assets",
9
+ "components",
10
+ "composables",
11
+ "middleware",
12
+ "pages",
13
+ "plugins",
14
+ "services",
15
+ "stores",
16
+ "types",
17
+ "utils",
18
+ "error.vue",
19
+ "nuxt.config.ts"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "dev": "nuxi dev .playground",
26
+ "build": "nuxi build .playground"
27
+ },
28
+ "peerDependencies": {
29
+ "@pinia/nuxt": ">=0.5.0",
30
+ "@pinia-plugin-persistedstate/nuxt": ">=1.0.0",
31
+ "axios": ">=1.0.0",
32
+ "laravel-echo": ">=1.15.0",
33
+ "nuxt": ">=3.13.0",
34
+ "pinia": ">=2.1.0",
35
+ "pusher-js": ">=8.0.0",
36
+ "vue": ">=3.5.0"
37
+ },
38
+ "devDependencies": {
39
+ "nuxt": "^3.15.4",
40
+ "typescript": "^5.0.0"
41
+ }
42
+ }
@@ -1,13 +1,39 @@
1
1
  import type { UseFetchOptions } from "#app";
2
2
  import axios, { type AxiosInstance } from "axios";
3
+ import { clearAuthCredentials } from "../utils/clearSession";
3
4
 
4
5
  let _httpService: AxiosInstance | null = null;
5
6
 
7
+ // Debounce flags so a burst of failing requests only triggers one recovery.
8
+ const _recovery = { handling: false };
9
+
10
+ /**
11
+ * Detected session loss → clear the stale auth credentials client-side so the
12
+ * NEXT request is a clean guest request and the user can load the site + log in
13
+ * again. Never blocks rendering; always resolves to guest.
14
+ */
15
+ function recoverFromSessionLoss() {
16
+ if (typeof window === "undefined" || _recovery.handling) return;
17
+ _recovery.handling = true;
18
+ try {
19
+ clearAuthCredentials();
20
+ } catch (_) {}
21
+ // Allow another recovery after a cooldown (covers genuinely new failures)
22
+ setTimeout(() => { _recovery.handling = false; }, 10000);
23
+ }
24
+
6
25
  export function getHttpService(): AxiosInstance {
7
26
  if (_httpService) return _httpService;
8
27
 
28
+ // Normalize baseURL so it always ends with /api/v1.
29
+ // Some projects set VITE_API_BASE_URL=https://host/api/v1 (already correct),
30
+ // others set VITE_API_BASE_URL=https://host (without the path).
31
+ // Appending /api/v1 only when absent makes both work with the same service paths.
32
+ const rawBase = (import.meta.env.VITE_API_BASE_URL as string) || "";
33
+ const baseURL = rawBase.endsWith("/api/v1") ? rawBase : rawBase.replace(/\/$/, "") + "/api/v1";
34
+
9
35
  _httpService = axios.create({
10
- baseURL: import.meta.env.VITE_API_BASE_URL,
36
+ baseURL,
11
37
  withCredentials: true,
12
38
  });
13
39
 
@@ -16,10 +42,26 @@ export function getHttpService(): AxiosInstance {
16
42
  (error) => {
17
43
  if (typeof window !== "undefined" && error?.response) {
18
44
  const status = error.response.status;
19
- const url = error.config?.url ?? "";
20
- const isAuthEndpoint = url.includes("/auth/");
45
+ const url: string = error.config?.url ?? "";
46
+ // The auth/user (or /users/me) probe is the canonical session check.
47
+ const isAuthProbe = /\/auth\/user(\?|$)|\/users\/me(\?|$)/.test(url);
48
+
49
+ // 401 on the auth probe = the session is genuinely gone (expired/invalid
50
+ // token). Clear the stale credentials so the loop breaks and the user
51
+ // can log in again. A 401 from any other endpoint is route-level denial,
52
+ // NOT a dead session — leave it to the caller.
53
+ if (status === 401 && isAuthProbe) {
54
+ recoverFromSessionLoss();
55
+ }
56
+
57
+ // 5xx while a credentialed request is in flight is the classic
58
+ // stale-oauth_token loop (OAuth rejects → 500). Clear client-side so
59
+ // the next load is a clean guest instead of an infinite failure.
60
+ if (status >= 500) {
61
+ recoverFromSessionLoss();
62
+ }
21
63
 
22
- if (!isAuthEndpoint && (status === 403 || status === 500 || status === 502 || status === 503)) {
64
+ if (!isAuthProbe && (status === 403 || status === 500 || status === 502 || status === 503)) {
23
65
  console.error(`[HTTP] Error ${status}:`, error.response?.data?.message || "");
24
66
  }
25
67
  }
@@ -2,10 +2,10 @@ import axios, { type AxiosInstance } from "axios"
2
2
 
3
3
  // Media Kit data is served by the api-main Shared\MediaKit module, now registered
4
4
  // in every service. A dedicated client lets each project point at the API host that
5
- // serves it without the /api/v1 base-URL inconsistency between projects:
6
- // - community: VITE_MEDIA_KIT_API_URL=https://api.mundogamer.community (same-origin)
7
- // - tv / agency: leave unset → fall back to their own VITE_API_BASE_URL
8
- // The request path carries the /api/v1 prefix, so the base must NOT include it.
5
+ // serves it. The request path does NOT carry /api/v1, so the base MUST include it
6
+ // (identical convention to pressKitService — keep them consistent):
7
+ // - community: VITE_MEDIA_KIT_API_URL=https://api.mundogamer.community/api/v1 (same-origin)
8
+ // - tv / agency: leave unset fall back to VITE_API_BASE_URL (already ends in /api/v1)
9
9
  let _client: AxiosInstance | null = null
10
10
 
11
11
  function getClient(): AxiosInstance {
@@ -46,9 +46,9 @@ const mk = new Proxy({} as AxiosInstance, {
46
46
  export default mk
47
47
 
48
48
  export const fetchPublicMediaKit = (slug: string) =>
49
- mk.get(`/api/v1/media-kit/${slug}`)
49
+ mk.get(`media-kit/${slug}`)
50
50
 
51
51
  export const trackMediaKitEvent = (
52
52
  userId: number | string,
53
53
  data: { type?: "view" | "click" | "download" | "contact"; referrer?: string } = {},
54
- ) => mk.post(`/api/v1/media-kit/${userId}/track`, data)
54
+ ) => mk.post(`media-kit/${userId}/track`, data)