@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.
- package/components/errors/401.vue +9 -0
- package/components/errors/403.vue +9 -0
- package/components/errors/404.vue +13 -0
- package/components/errors/500.vue +9 -0
- package/components/errors/503.vue +9 -0
- package/components/errors/504.vue +9 -0
- package/components/errors/MgErrorBase.vue +109 -0
- package/components/errors/MgErrorNewsCta.vue +230 -0
- package/components/ui/MgLoginModal.vue +19 -6
- package/components/ui/MgTestimonials.vue +62 -0
- package/components/ui/MgWalletPlanBadge.vue +95 -0
- package/composables/useConfirmation.ts +117 -0
- package/composables/useForceLogout.ts +73 -0
- package/composables/usePaymentListener.ts +81 -0
- package/error.vue +59 -112
- package/package.json +41 -41
- package/services/httpService.ts +46 -4
- package/services/mediaKitService.ts +6 -6
- package/services/testimoniesService.ts +40 -0
- package/stores/chat.ts +22 -0
- package/stores/notifications.ts +39 -1
- package/stores/testimonies.ts +40 -0
- package/utils/authRedirect.ts +18 -0
- package/utils/clearSession.ts +103 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
17
|
+
// Do NOT call getUser() on error page — causes infinite loop when API is down or token expired
|
|
16
18
|
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
} catch {
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
47
|
+
onUnmounted(() => {
|
|
48
|
+
if (countdownInterval) clearInterval(countdownInterval);
|
|
49
|
+
});
|
|
41
50
|
</script>
|
|
42
51
|
|
|
43
52
|
<template>
|
|
44
53
|
<NuxtLayout>
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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"
|
|
71
|
-
.
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
}
|
package/services/httpService.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
6
|
-
//
|
|
7
|
-
// -
|
|
8
|
-
//
|
|
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(
|
|
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(
|
|
54
|
+
) => mk.post(`media-kit/${userId}/track`, data)
|