@mundogamernetwork/shared-ui 1.0.3 → 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 +1 -1
- package/services/httpService.ts +46 -4
- 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
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
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import httpService from "./httpService";
|
|
2
|
+
|
|
3
|
+
export interface TestimonyParams {
|
|
4
|
+
page?: number;
|
|
5
|
+
per_page?: number;
|
|
6
|
+
[key: string]: any;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const buildQuery = (params: TestimonyParams): string => {
|
|
10
|
+
const search = new URLSearchParams();
|
|
11
|
+
for (const [key, value] of Object.entries(params)) {
|
|
12
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
13
|
+
search.append(key, String(value));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const qs = search.toString();
|
|
17
|
+
return qs ? `?${qs}` : "";
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const getUserTestimonials = (params: TestimonyParams = {}) => {
|
|
21
|
+
return httpService({
|
|
22
|
+
url: `/public/user-testimonials${buildQuery(params)}`,
|
|
23
|
+
baseURL: import.meta.env.VITE_API_BASE_URL,
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const submitUserTestimonial = (data: {
|
|
28
|
+
body: string;
|
|
29
|
+
name: string;
|
|
30
|
+
role?: string;
|
|
31
|
+
company?: string;
|
|
32
|
+
[key: string]: any;
|
|
33
|
+
}) => {
|
|
34
|
+
return httpService({
|
|
35
|
+
method: "POST",
|
|
36
|
+
url: "/public/user-testimonials",
|
|
37
|
+
data,
|
|
38
|
+
baseURL: import.meta.env.VITE_API_BASE_URL,
|
|
39
|
+
});
|
|
40
|
+
};
|
package/stores/chat.ts
CHANGED
|
@@ -125,6 +125,26 @@ export const useChatStore = defineStore("chat-store", () => {
|
|
|
125
125
|
handleChatMessages(chatId);
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
+
// WS channels: receiver = user.{platform}.chats.{uuid}
|
|
129
|
+
// sender = user.{platform}.chats.sendMessage.{uuid}
|
|
130
|
+
const subscribeToChatChannel = (userUuid: string, platformSlug: string, asSender = false) => {
|
|
131
|
+
if (typeof window === "undefined" || !window.Echo) return;
|
|
132
|
+
const channelName = asSender
|
|
133
|
+
? `user.${platformSlug}.chats.sendMessage.${userUuid}`
|
|
134
|
+
: `user.${platformSlug}.chats.${userUuid}`;
|
|
135
|
+
window.Echo.private(channelName).listen(".NewChatMessage", (data: any) => {
|
|
136
|
+
callNewMessage(data);
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const unsubscribeFromChatChannel = (userUuid: string, platformSlug: string, asSender = false) => {
|
|
141
|
+
if (typeof window === "undefined" || !window.Echo) return;
|
|
142
|
+
const channelName = asSender
|
|
143
|
+
? `user.${platformSlug}.chats.sendMessage.${userUuid}`
|
|
144
|
+
: `user.${platformSlug}.chats.${userUuid}`;
|
|
145
|
+
window.Echo.leaveChannel(`private-${channelName}`);
|
|
146
|
+
};
|
|
147
|
+
|
|
128
148
|
return {
|
|
129
149
|
hasNewMessage,
|
|
130
150
|
currentMessageData,
|
|
@@ -142,6 +162,8 @@ export const useChatStore = defineStore("chat-store", () => {
|
|
|
142
162
|
substituteUserChat,
|
|
143
163
|
startPollingForMessages,
|
|
144
164
|
stopAllPolling,
|
|
165
|
+
subscribeToChatChannel,
|
|
166
|
+
unsubscribeFromChatChannel,
|
|
145
167
|
};
|
|
146
168
|
});
|
|
147
169
|
|
package/stores/notifications.ts
CHANGED
|
@@ -10,7 +10,9 @@ import type { MgNotification } from "../types";
|
|
|
10
10
|
|
|
11
11
|
export const useNotificationsStore = defineStore("notifications-store", () => {
|
|
12
12
|
const notifications = ref<MgNotification[]>([]);
|
|
13
|
-
const
|
|
13
|
+
const unreadCount = ref(0);
|
|
14
|
+
// Alias kept for backward compat
|
|
15
|
+
const notificationCount = unreadCount;
|
|
14
16
|
const totalNotifications = ref(0);
|
|
15
17
|
const currentPage = ref(1);
|
|
16
18
|
const loading = ref(false);
|
|
@@ -108,12 +110,46 @@ export const useNotificationsStore = defineStore("notifications-store", () => {
|
|
|
108
110
|
};
|
|
109
111
|
|
|
110
112
|
const addNotification = (notification: MgNotification) => {
|
|
113
|
+
const key = (notification as any).notification_id ?? notification.id;
|
|
114
|
+
const exists = key && notifications.value.some((n: any) => ((n.notification_id ?? n.id) === key));
|
|
115
|
+
if (exists) return;
|
|
111
116
|
notifications.value.unshift(notification);
|
|
112
117
|
notificationCount.value++;
|
|
113
118
|
};
|
|
114
119
|
|
|
120
|
+
const mapIncomingNotification = (data: any): MgNotification => ({
|
|
121
|
+
type: data.mg_network_system_id ?? data.type ?? null,
|
|
122
|
+
object: data.object ?? data.data?.object ?? null,
|
|
123
|
+
id: data.notification_id ?? data.id ?? null,
|
|
124
|
+
slug: data.slug ?? data.data?.slug ?? null,
|
|
125
|
+
url: data.url ?? data.data?.url ?? null,
|
|
126
|
+
title: data.title ?? data.data?.title ?? "",
|
|
127
|
+
message: data.message ?? data.data?.message ?? "",
|
|
128
|
+
image: data.image ?? data.data?.image ?? "",
|
|
129
|
+
read_at: null,
|
|
130
|
+
created_at: data.created_at_diff ?? data.created_at ?? "",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const subscribeToUserChannel = (userId: number | string) => {
|
|
134
|
+
if (typeof window === "undefined" || !window.Echo) return;
|
|
135
|
+
window.Echo.private(`App.Models.User.${userId}`).notification((data: any) => {
|
|
136
|
+
addNotification(mapIncomingNotification(data));
|
|
137
|
+
if (typeof window !== "undefined" && window.Audio) {
|
|
138
|
+
try {
|
|
139
|
+
new Audio("/sounds/notification.mp3").play().catch(() => {});
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const unsubscribeFromUserChannel = (userId: number | string) => {
|
|
146
|
+
if (typeof window === "undefined" || !window.Echo) return;
|
|
147
|
+
window.Echo.leaveChannel(`private-App.Models.User.${userId}`);
|
|
148
|
+
};
|
|
149
|
+
|
|
115
150
|
return {
|
|
116
151
|
notifications,
|
|
152
|
+
unreadCount,
|
|
117
153
|
notificationCount,
|
|
118
154
|
totalNotifications,
|
|
119
155
|
currentPage,
|
|
@@ -125,6 +161,8 @@ export const useNotificationsStore = defineStore("notifications-store", () => {
|
|
|
125
161
|
markAllAsRead,
|
|
126
162
|
removeNotification,
|
|
127
163
|
addNotification,
|
|
164
|
+
subscribeToUserChannel,
|
|
165
|
+
unsubscribeFromUserChannel,
|
|
128
166
|
};
|
|
129
167
|
});
|
|
130
168
|
|