@innertia-solutions/innertia-nuxt 0.1.45 → 0.1.47
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/app.config.ts +1 -1
- package/components/App/NotificationBell.vue +181 -0
- package/components/App/StatCard.vue +29 -0
- package/composables/useAuth.js +12 -1
- package/composables/useGymSelection.ts +35 -0
- package/composables/useInnertiaMode.ts +6 -3
- package/composables/useNotifications.js +21 -6
- package/middleware/01.detect-subdomain.global.ts +2 -2
- package/middleware/02.validate-tenant.global.ts +2 -2
- package/package.json +1 -1
- package/plugins/api-tenant.client.ts +19 -9
- package/stores/gym.js +27 -0
- package/stores/notifications.js +1 -0
package/app.config.ts
CHANGED
|
@@ -149,7 +149,7 @@ export default defineAppConfig({
|
|
|
149
149
|
// ─── Tipos públicos ──────────────────────────────────────────────────────────
|
|
150
150
|
// Re-exportados desde acá para que cualquier composable / componente los importe.
|
|
151
151
|
|
|
152
|
-
export type InnertiaMode = 'saas' | 'app'
|
|
152
|
+
export type InnertiaMode = 'saas' | 'app' | 'open'
|
|
153
153
|
|
|
154
154
|
export type ContextMobileMode = 'allow' | 'block' | 'redirect'
|
|
155
155
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { IconBell, IconChecks, IconBellOff } from '@tabler/icons-vue'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* App.NotificationBell — campana del top bar: dropdown flotante (Preline hs-dropdown)
|
|
6
|
+
* con lista de notificaciones del usuario, badge de no leídas, tabs Todas/No leídas
|
|
7
|
+
* y footer "Marcar todas" + "Ver todas". Estilo calcado del template CRM.
|
|
8
|
+
*
|
|
9
|
+
* Backend: notification center de innertia-laravel (vía useNotifications).
|
|
10
|
+
* `allRoute`: ruta de la vista "ver todas" (cada producto monta la suya).
|
|
11
|
+
*/
|
|
12
|
+
const props = defineProps({
|
|
13
|
+
allRoute: { type: String, default: '/backoffice/notifications' },
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const { fetchNotifications, markAsRead, markAllAsRead } = useNotifications()
|
|
17
|
+
const store = useNotificationsStore()
|
|
18
|
+
|
|
19
|
+
const tab = ref('all') // all | unread
|
|
20
|
+
const loading = ref(false)
|
|
21
|
+
const serverUnread = ref(0) // conteo exacto de no leídas (del backend)
|
|
22
|
+
const ddRoot = ref(null)
|
|
23
|
+
|
|
24
|
+
// Cierra el dropdown Preline al navegar (--auto-close:false para que los tabs no lo cierren).
|
|
25
|
+
function closeDropdown() {
|
|
26
|
+
try { window.HSDropdown?.close?.(ddRoot.value) } catch { /* noop */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const items = computed(() => tab.value === 'unread'
|
|
30
|
+
? store.notifications.filter(n => !n.read_at)
|
|
31
|
+
: store.notifications)
|
|
32
|
+
|
|
33
|
+
const badge = computed(() => serverUnread.value > 9 ? '9+' : String(serverUnread.value))
|
|
34
|
+
|
|
35
|
+
async function load() {
|
|
36
|
+
loading.value = true
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetchNotifications({ all: 1 })
|
|
39
|
+
serverUnread.value = res?.unread ?? store.unreadCount
|
|
40
|
+
} catch { /* noop */ } finally {
|
|
41
|
+
loading.value = false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
onMounted(load)
|
|
45
|
+
|
|
46
|
+
async function openItem(n) {
|
|
47
|
+
if (!n.read_at) {
|
|
48
|
+
try {
|
|
49
|
+
await markAsRead(n.id)
|
|
50
|
+
serverUnread.value = Math.max(0, serverUnread.value - 1)
|
|
51
|
+
} catch { /* noop */ }
|
|
52
|
+
}
|
|
53
|
+
const link = n.data?.link ?? n.data?.url
|
|
54
|
+
if (link) { closeDropdown(); navigateTo(link) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function goAll() {
|
|
58
|
+
closeDropdown()
|
|
59
|
+
navigateTo(props.allRoute)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function markAll() {
|
|
63
|
+
try {
|
|
64
|
+
await markAllAsRead()
|
|
65
|
+
serverUnread.value = 0
|
|
66
|
+
} catch { /* noop */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const initial = (t) => (t || '?').trim().charAt(0).toUpperCase()
|
|
70
|
+
|
|
71
|
+
function timeAgo(iso) {
|
|
72
|
+
if (!iso) return ''
|
|
73
|
+
const d = new Date(iso)
|
|
74
|
+
const s = Math.floor((Date.now() - d.getTime()) / 1000)
|
|
75
|
+
if (s < 60) return 'hace un momento'
|
|
76
|
+
const m = Math.floor(s / 60); if (m < 60) return `hace ${m} min`
|
|
77
|
+
const h = Math.floor(m / 60); if (h < 24) return `hace ${h} h`
|
|
78
|
+
const days = Math.floor(h / 24); if (days < 7) return `hace ${days} d`
|
|
79
|
+
return d.toLocaleDateString('es-CL', { day: 'numeric', month: 'short' })
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<div ref="ddRoot" class="hs-dropdown [--placement:bottom-right] [--auto-close:false] relative inline-flex">
|
|
85
|
+
<!-- Botón campana -->
|
|
86
|
+
<button
|
|
87
|
+
id="hs-notifications-dd"
|
|
88
|
+
type="button"
|
|
89
|
+
class="relative inline-flex justify-center items-center size-9 rounded-full text-foreground-inverse hover:bg-plain/10 focus:outline-hidden"
|
|
90
|
+
aria-haspopup="menu" aria-expanded="false" aria-label="Notificaciones"
|
|
91
|
+
@click="load"
|
|
92
|
+
>
|
|
93
|
+
<IconBell class="size-4 shrink-0" />
|
|
94
|
+
<span
|
|
95
|
+
v-if="serverUnread > 0"
|
|
96
|
+
class="absolute -top-0.5 -end-0.5 min-w-4 h-4 px-1 inline-flex justify-center items-center text-[10px] font-semibold leading-none text-white bg-red-500 rounded-full"
|
|
97
|
+
>{{ badge }}</span>
|
|
98
|
+
</button>
|
|
99
|
+
|
|
100
|
+
<!-- Dropdown -->
|
|
101
|
+
<div
|
|
102
|
+
class="hs-dropdown-menu hs-dropdown-open:opacity-100 w-90 max-w-[calc(100vw-2rem)] transition-[opacity,margin] duration opacity-0 hidden z-20 bg-dropdown border border-dropdown-line rounded-xl shadow-xl overflow-hidden"
|
|
103
|
+
role="menu" aria-orientation="vertical" aria-labelledby="hs-notifications-dd"
|
|
104
|
+
>
|
|
105
|
+
<!-- Header + tabs (subrayado after inset, estilo template) -->
|
|
106
|
+
<div class="px-5 pt-3 flex justify-between items-center border-b border-dropdown-divider">
|
|
107
|
+
<nav class="flex gap-1" role="tablist">
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
class="relative px-2 py-1.5 mb-2 inline-flex justify-center items-center gap-x-2 text-sm rounded-lg hover:bg-muted-hover after:absolute after:-bottom-2 after:inset-x-2 after:z-10 after:h-0.5 after:pointer-events-none transition-colors"
|
|
111
|
+
:class="tab === 'all' ? 'text-foreground after:bg-secondary' : 'text-muted-foreground-1 hover:text-foreground after:bg-transparent'"
|
|
112
|
+
@click="tab = 'all'"
|
|
113
|
+
>Todas</button>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
class="relative px-2 py-1.5 mb-2 inline-flex justify-center items-center gap-x-2 text-sm rounded-lg hover:bg-muted-hover after:absolute after:-bottom-2 after:inset-x-2 after:z-10 after:h-0.5 after:pointer-events-none transition-colors"
|
|
117
|
+
:class="tab === 'unread' ? 'text-foreground after:bg-secondary' : 'text-muted-foreground-1 hover:text-foreground after:bg-transparent'"
|
|
118
|
+
@click="tab = 'unread'"
|
|
119
|
+
>No leídas<span v-if="serverUnread" class="ms-1 text-xs text-muted-foreground">({{ serverUnread }})</span></button>
|
|
120
|
+
</nav>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<!-- Lista -->
|
|
124
|
+
<div class="max-h-96 overflow-y-auto [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-card-line">
|
|
125
|
+
<!-- Loading -->
|
|
126
|
+
<div v-if="loading && !store.notifications.length" class="p-4 space-y-4">
|
|
127
|
+
<div v-for="i in 4" :key="i" class="flex gap-3 animate-pulse">
|
|
128
|
+
<span class="size-9 rounded-full bg-surface-1 shrink-0" />
|
|
129
|
+
<div class="flex-1 space-y-2"><span class="block h-3 w-1/3 bg-surface-1 rounded" /><span class="block h-3 w-3/4 bg-surface-1 rounded" /></div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<!-- Vacío -->
|
|
134
|
+
<div v-else-if="!items.length" class="py-12 flex flex-col items-center text-center text-muted-foreground">
|
|
135
|
+
<IconBellOff class="size-8 mb-2 opacity-30" :stroke-width="1.5" />
|
|
136
|
+
<p class="text-sm">{{ tab === 'unread' ? 'Sin notificaciones nuevas.' : 'No tienes notificaciones.' }}</p>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<!-- Items -->
|
|
140
|
+
<ul v-else class="divide-y divide-dropdown-divider">
|
|
141
|
+
<li
|
|
142
|
+
v-for="n in items" :key="n.id"
|
|
143
|
+
class="relative w-full flex gap-x-3 p-4 cursor-pointer hover:bg-muted-hover transition-colors"
|
|
144
|
+
:class="!n.read_at ? 'bg-surface' : ''"
|
|
145
|
+
@click="openItem(n)"
|
|
146
|
+
>
|
|
147
|
+
<div class="relative shrink-0">
|
|
148
|
+
<span class="flex items-center justify-center size-9 rounded-full bg-primary/10 text-primary text-sm font-semibold uppercase">{{ initial(n.title) }}</span>
|
|
149
|
+
<span v-if="!n.read_at" class="absolute -top-0.5 -start-0.5 size-2.5 bg-primary rounded-full ring-2 ring-dropdown" />
|
|
150
|
+
</div>
|
|
151
|
+
<div class="grow min-w-0">
|
|
152
|
+
<p class="text-xs text-muted-foreground">{{ timeAgo(n.created_at) }}</p>
|
|
153
|
+
<p class="text-sm font-medium text-foreground">{{ n.title }}</p>
|
|
154
|
+
<p v-if="n.body" class="text-sm text-muted-foreground line-clamp-2">{{ n.body }}</p>
|
|
155
|
+
</div>
|
|
156
|
+
</li>
|
|
157
|
+
</ul>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<!-- Footer -->
|
|
161
|
+
<div class="flex items-center border-t border-dropdown-divider divide-x divide-dropdown-divider">
|
|
162
|
+
<button
|
|
163
|
+
v-if="serverUnread > 0"
|
|
164
|
+
type="button"
|
|
165
|
+
class="flex-1 p-4 flex justify-center items-center gap-x-2 text-sm font-medium text-muted-foreground-1 hover:text-primary-hover focus:outline-hidden"
|
|
166
|
+
@click="markAll"
|
|
167
|
+
>
|
|
168
|
+
<IconChecks class="size-4 shrink-0" />
|
|
169
|
+
Marcar todas como leídas
|
|
170
|
+
</button>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
class="flex-1 p-4 flex justify-center items-center gap-x-2 text-sm font-medium text-muted-foreground-1 hover:text-primary-hover focus:outline-hidden"
|
|
174
|
+
@click="goAll"
|
|
175
|
+
>
|
|
176
|
+
Ver todas
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</template>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
* StatCard — indicador/KPI para la fila de resumen entre el título y la tabla.
|
|
4
|
+
* Estilo alineado a finance/index.vue (bg-card + rounded-card + tokens).
|
|
5
|
+
*/
|
|
6
|
+
defineProps({
|
|
7
|
+
label: { type: String, required: true },
|
|
8
|
+
value: { type: [String, Number], default: '—' },
|
|
9
|
+
hint: { type: String, default: '' },
|
|
10
|
+
valueClass: { type: String, default: 'text-foreground' },
|
|
11
|
+
icon: { type: [Object, Function], default: null },
|
|
12
|
+
iconClass: { type: String, default: 'bg-primary/10 text-primary' },
|
|
13
|
+
loading: { type: Boolean, default: false },
|
|
14
|
+
})
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<div class="bg-card border border-card-line rounded-card p-5 flex items-start justify-between gap-3">
|
|
19
|
+
<div class="min-w-0">
|
|
20
|
+
<p class="text-xs uppercase tracking-wider text-muted-foreground">{{ label }}</p>
|
|
21
|
+
<p v-if="loading" class="mt-2 h-7 w-16 bg-surface-1 rounded animate-pulse" />
|
|
22
|
+
<p v-else class="mt-2 text-2xl font-semibold" :class="valueClass">{{ value }}</p>
|
|
23
|
+
<span v-if="hint && !loading" class="text-xs text-muted-foreground">{{ hint }}</span>
|
|
24
|
+
</div>
|
|
25
|
+
<div v-if="icon" class="size-10 shrink-0 rounded-lg flex items-center justify-center" :class="iconClass">
|
|
26
|
+
<component :is="icon" class="size-5" :stroke-width="1.8" />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
package/composables/useAuth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// useAuthStore, useApi, useOrganizationStore auto-imported
|
|
1
|
+
// useAuthStore, useApi, useOrganizationStore, useInnertiaMode, useGymSelection, useGymStore auto-imported
|
|
2
2
|
|
|
3
3
|
export function useAuth() {
|
|
4
4
|
const authStore = useAuthStore()
|
|
@@ -20,6 +20,15 @@ export function useAuth() {
|
|
|
20
20
|
authStore.setCurrentContext(context)
|
|
21
21
|
queryClient.clear()
|
|
22
22
|
await fetchMe()
|
|
23
|
+
|
|
24
|
+
// En modo 'open' el tenant se resuelve por selección: cargamos los gyms del
|
|
25
|
+
// usuario y devolvemos un destino (0/1/2+). saas/app conservan su return.
|
|
26
|
+
if (useInnertiaMode().isOpen()) {
|
|
27
|
+
const { resolveAfterLogin } = useGymSelection()
|
|
28
|
+
const gymDestination = await resolveAfterLogin() // 'onboarding' | 'ready' | 'picker'
|
|
29
|
+
return { ...(data ?? {}), gymDestination }
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
return data
|
|
24
33
|
}
|
|
25
34
|
|
|
@@ -68,6 +77,8 @@ export function useAuth() {
|
|
|
68
77
|
queryClient.clear()
|
|
69
78
|
authStore.logout()
|
|
70
79
|
organizationStore.reset()
|
|
80
|
+
// En open, limpiar también el gym seleccionado
|
|
81
|
+
if (useInnertiaMode().isOpen()) useGymStore().reset()
|
|
71
82
|
await navigateTo(loginPath)
|
|
72
83
|
}
|
|
73
84
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// useApi, useGymStore auto-imported.
|
|
2
|
+
// Selección de gym en modo 'open': carga los gyms del usuario y resuelve
|
|
3
|
+
// el destino post-login según cuántos tenga (0 / 1 / 2+).
|
|
4
|
+
|
|
5
|
+
export function useGymSelection() {
|
|
6
|
+
const api = useApi()
|
|
7
|
+
const gym = useGymStore()
|
|
8
|
+
|
|
9
|
+
/** Carga los gyms del usuario autenticado y los guarda en el store. */
|
|
10
|
+
async function load() {
|
|
11
|
+
// Shape esperado: { gyms: [{ key, name, status, contexts }] }
|
|
12
|
+
const data = await api.get('auth/my-gyms')
|
|
13
|
+
gym.setGyms(data?.gyms || [])
|
|
14
|
+
return gym.gyms
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resuelve a dónde llevar al usuario tras el login.
|
|
19
|
+
* 'onboarding' → 0 gyms (debe crear/unirse a uno)
|
|
20
|
+
* 'ready' → 1 gym (auto-seleccionado) o ya tenía uno activo válido
|
|
21
|
+
* 'picker' → 2+ gyms sin selección válida previa
|
|
22
|
+
*/
|
|
23
|
+
async function resolveAfterLogin() {
|
|
24
|
+
await load()
|
|
25
|
+
if (gym.gyms.length === 0) return 'onboarding'
|
|
26
|
+
if (gym.gyms.length === 1) { gym.setActive(gym.gyms[0].key); return 'ready' }
|
|
27
|
+
if (gym.activeKey && gym.gyms.some((g) => g.key === gym.activeKey)) return 'ready'
|
|
28
|
+
return 'picker'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Cambia el gym activo (switcher). */
|
|
32
|
+
function switchTo(key) { gym.setActive(key) }
|
|
33
|
+
|
|
34
|
+
return { load, resolveAfterLogin, switchTo }
|
|
35
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { InnertiaMode } from '../app.config'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Devuelve el modo activo de la librería: 'saas' | 'app'.
|
|
4
|
+
* Devuelve el modo activo de la librería: 'saas' | 'app' | 'open'.
|
|
5
5
|
* Default: 'saas' (compatibilidad con productos existentes).
|
|
6
6
|
*
|
|
7
7
|
* Se configura en `nuxt.config.ts` del producto:
|
|
@@ -10,7 +10,9 @@ import type { InnertiaMode } from '../app.config'
|
|
|
10
10
|
* Helpers de conveniencia:
|
|
11
11
|
* isSaas() → true si mode === 'saas'
|
|
12
12
|
* isApp() → true si mode === 'app'
|
|
13
|
-
*
|
|
13
|
+
* isOpen() → true si mode === 'open'
|
|
14
|
+
* hasTenant() → true si mode === 'saas' | 'open' (usan multitenancy;
|
|
15
|
+
* saas resuelve por subdominio, open por gym seleccionado)
|
|
14
16
|
*/
|
|
15
17
|
export function useInnertiaMode() {
|
|
16
18
|
const appConfig = useAppConfig()
|
|
@@ -20,6 +22,7 @@ export function useInnertiaMode() {
|
|
|
20
22
|
mode,
|
|
21
23
|
isSaas: () => mode === 'saas',
|
|
22
24
|
isApp: () => mode === 'app',
|
|
23
|
-
|
|
25
|
+
isOpen: () => mode === 'open',
|
|
26
|
+
hasTenant: () => mode === 'saas' || mode === 'open',
|
|
24
27
|
}
|
|
25
28
|
}
|
|
@@ -1,22 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centro de notificaciones del usuario. Habla con los endpoints del notification
|
|
3
|
+
* center de innertia-laravel (prefijo `notifications`, montado con
|
|
4
|
+
* \Innertia\Saas\Notifications::routes()):
|
|
5
|
+
* GET notifications ?all=1&page=N → {data,total,unread,current_page,last_page}
|
|
6
|
+
* PATCH notifications/{id}/read
|
|
7
|
+
* PATCH notifications/read-all
|
|
8
|
+
* DELETE notifications/{id}
|
|
9
|
+
* DELETE notifications (borra las leídas)
|
|
10
|
+
*/
|
|
1
11
|
export function useNotifications() {
|
|
2
12
|
const api = useApi()
|
|
3
13
|
const store = useNotificationsStore()
|
|
4
14
|
|
|
5
15
|
async function fetchNotifications(params = {}) {
|
|
6
|
-
const
|
|
7
|
-
store.setNotifications(
|
|
8
|
-
return
|
|
16
|
+
const res = await api.get('notifications', { params })
|
|
17
|
+
store.setNotifications(res?.data ?? [])
|
|
18
|
+
return res
|
|
9
19
|
}
|
|
10
20
|
|
|
11
21
|
async function markAsRead(id) {
|
|
12
|
-
await api.
|
|
22
|
+
await api.patch(`notifications/${id}/read`)
|
|
13
23
|
store.markRead(id)
|
|
14
24
|
}
|
|
15
25
|
|
|
16
26
|
async function markAllAsRead() {
|
|
17
|
-
await api.
|
|
27
|
+
await api.patch('notifications/read-all')
|
|
18
28
|
store.markAllRead()
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
async function remove(id) {
|
|
32
|
+
await api.delete(`notifications/${id}`)
|
|
33
|
+
store.remove(id)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { fetchNotifications, markAsRead, markAllAsRead, remove }
|
|
22
37
|
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Server-only: lee el hostname de la request para extraer el subdomain del tenant.
|
|
3
3
|
// Solo activo en mode === 'saas'.
|
|
4
4
|
export default defineNuxtRouteMiddleware((to) => {
|
|
5
|
-
//
|
|
6
|
-
if (!useInnertiaMode().
|
|
5
|
+
// Solo saas resuelve el tenant por subdominio (open lo hace por selección in-app)
|
|
6
|
+
if (!useInnertiaMode().isSaas()) return
|
|
7
7
|
|
|
8
8
|
// Evitar loop infinito: si ya estamos en la página de error de tenant, no redirigir de nuevo.
|
|
9
9
|
if (to.path === '/tenant-error') return
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// Usa la URL interna del backend (apiInternalUrl) para evitar pasar por el proxy de Nitro.
|
|
4
4
|
// Solo activo en mode === 'saas'.
|
|
5
5
|
export default defineNuxtRouteMiddleware(async (to) => {
|
|
6
|
-
//
|
|
7
|
-
if (!useInnertiaMode().
|
|
6
|
+
// Solo saas valida el tenant por subdominio (open lo hace por selección in-app)
|
|
7
|
+
if (!useInnertiaMode().isSaas()) return
|
|
8
8
|
|
|
9
9
|
if (!import.meta.server) return
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
// Registra el interceptor del header X-Tenant.
|
|
2
|
-
// El backend (ResolveTenantFromHeader) identifica el tenant por su key/slug,
|
|
3
|
-
//
|
|
4
|
-
// para evitar
|
|
2
|
+
// El backend (ResolveTenantFromHeader) identifica el tenant por su key/slug, no por UUID.
|
|
3
|
+
// - saas: slug del tenant resuelto por subdominio (tenantStore); solo se envía
|
|
4
|
+
// cuando el tenant está validado (tenantId != null) para evitar el 'local' de dev.
|
|
5
|
+
// - open: key del gym seleccionado in-app (gymStore.activeKey).
|
|
5
6
|
// useRequestInterceptors auto-imported desde nuxt-core.
|
|
6
|
-
// useTenantStore auto-imported desde
|
|
7
|
-
//
|
|
7
|
+
// useTenantStore / useGymStore auto-imported desde stores.
|
|
8
|
+
// Activo en mode === 'saas' | 'open'.
|
|
8
9
|
export default defineNuxtPlugin(() => {
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
const { isOpen, hasTenant } = useInnertiaMode()
|
|
11
|
+
|
|
12
|
+
// Modo sin tenant (app) → no inyectar header de tenant
|
|
13
|
+
if (!hasTenant()) return
|
|
11
14
|
|
|
12
15
|
const { add } = useRequestInterceptors()
|
|
13
|
-
const tenantStore = useTenantStore()
|
|
14
16
|
|
|
15
17
|
add((headers: Record<string, string>) => {
|
|
16
|
-
//
|
|
18
|
+
// open: tenant por gym seleccionado
|
|
19
|
+
if (isOpen()) {
|
|
20
|
+
const gymStore = useGymStore()
|
|
21
|
+
if (gymStore.activeKey) headers['X-Tenant'] = gymStore.activeKey
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// saas: tenant por subdominio — solo si fue validado (tiene id) y tiene slug
|
|
26
|
+
const tenantStore = useTenantStore()
|
|
17
27
|
if (tenantStore.tenantId && tenantStore.tenantSlug) {
|
|
18
28
|
headers['X-Tenant'] = tenantStore.tenantSlug
|
|
19
29
|
}
|
package/stores/gym.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gym activo en modo 'open'.
|
|
5
|
+
* A diferencia de saas (tenant por subdominio), open resuelve el tenant por
|
|
6
|
+
* selección in-app: el usuario elige entre sus gyms y esa key viaja como X-Tenant.
|
|
7
|
+
* gyms: [{ key, name, status, contexts }]
|
|
8
|
+
*/
|
|
9
|
+
export const useGymStore = defineStore('gym', {
|
|
10
|
+
state: () => ({ activeKey: null, gyms: [] }),
|
|
11
|
+
|
|
12
|
+
getters: {
|
|
13
|
+
active: (s) => s.gyms.find((g) => g.key === s.activeKey) || null,
|
|
14
|
+
hasMultiple: (s) => s.gyms.length > 1,
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
actions: {
|
|
18
|
+
setGyms(gyms) { this.gyms = gyms || [] },
|
|
19
|
+
setActive(key) { this.activeKey = key },
|
|
20
|
+
reset() { this.activeKey = null; this.gyms = [] },
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
persist: {
|
|
24
|
+
// Solo la key activa sobrevive el refresh; la lista se recarga del backend
|
|
25
|
+
pick: ['activeKey'],
|
|
26
|
+
},
|
|
27
|
+
})
|
package/stores/notifications.js
CHANGED
|
@@ -18,6 +18,7 @@ export const useNotificationsStore = defineStore('notifications', {
|
|
|
18
18
|
const now = new Date().toISOString()
|
|
19
19
|
this.notifications.forEach(n => { if (!n.read_at) n.read_at = now })
|
|
20
20
|
},
|
|
21
|
+
remove(id) { this.notifications = this.notifications.filter(n => n.id !== id) },
|
|
21
22
|
clear() { this.notifications = [] },
|
|
22
23
|
},
|
|
23
24
|
persist: false,
|