@innertia-solutions/innertia-nuxt 0.1.50 → 0.1.53

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.
@@ -274,30 +274,88 @@ function buildSheet(ws, workbook) {
274
274
  return { name: ws.name, cols, rows, firstRow: 1, imageMap }
275
275
  }
276
276
 
277
+ /** Detecta el delimitador más probable de un texto tabular (tab / ; / ,). */
278
+ function detectDelimiter(text) {
279
+ const line = text.split(/\r?\n/).find(l => l.trim()) ?? ''
280
+ const counts = { '\t': (line.match(/\t/g) || []).length, ';': (line.match(/;/g) || []).length, ',': (line.match(/,/g) || []).length }
281
+ const max = Math.max(counts['\t'], counts[';'], counts[','])
282
+ if (max === 0) return null
283
+ return counts['\t'] === max ? '\t' : (counts[';'] === max ? ';' : ',')
284
+ }
285
+
286
+ /** Parser genérico de texto delimitado con soporte de comillas. */
287
+ function parseDelimited(text, delim) {
288
+ const rows = []
289
+ let row = [], cell = '', inQ = false
290
+ for (let i = 0; i < text.length; i++) {
291
+ const c = text[i]
292
+ if (inQ) {
293
+ if (c === '"') { if (text[i + 1] === '"') { cell += '"'; i++ } else inQ = false }
294
+ else cell += c
295
+ } else {
296
+ if (c === '"') inQ = true
297
+ else if (c === delim) { row.push(cell); cell = '' }
298
+ else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = '' }
299
+ else if (c === '\r') continue
300
+ else cell += c
301
+ }
302
+ }
303
+ if (cell.length || row.length) { row.push(cell); rows.push(row) }
304
+ return rows
305
+ }
306
+
307
+ /** Construye una sheet (misma forma que buildSheet) desde texto delimitado. */
308
+ function buildSheetFromDelimited(text, name = 'Hoja 1') {
309
+ const delim = detectDelimiter(text) ?? '\t'
310
+ const raw = parseDelimited(text, delim).filter(r => r.length && !(r.length === 1 && r[0] === ''))
311
+ const colCount = raw.reduce((m, r) => Math.max(m, r.length), 0)
312
+ const cols = []
313
+ for (let c = 0; c < colCount; c++) cols.push(colLetter(c))
314
+ const rows = raw.map((r, ri) => {
315
+ const rowData = []
316
+ for (let c = 0; c < colCount; c++) {
317
+ rowData.push({ v: r[c] ?? '', align: 'left', t: 'string', address: colLetter(c) + (ri + 1) })
318
+ }
319
+ return rowData
320
+ })
321
+ return { name, cols, rows, firstRow: 1, imageMap: {} }
322
+ }
323
+
277
324
  const loadXlsx = async () => {
278
325
  if (viewerType.value !== 'xlsx') return
279
326
  isLoadingXlsx.value = true
280
327
  xlsxError.value = null
281
328
  xlsxActiveIdx.value = 0
329
+ let buf = null
282
330
  try {
331
+ const res = await fetch(src.value)
332
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
333
+ buf = await res.arrayBuffer()
334
+
283
335
  const mod = await import('exceljs').catch(() => null)
284
336
  const ExcelJS = pickModule(mod, 'Workbook')
285
337
  ?? (mod?.default?.Workbook ? mod.default : null)
286
338
  ?? mod
287
- if (!ExcelJS?.Workbook) {
288
- console.warn('[FilesPreview] exceljs module shape:', mod)
289
- throw new Error('Para previsualizar .xlsx instalá la dep: pnpm add exceljs')
290
- }
291
- const res = await fetch(src.value)
292
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
293
- const buf = await res.arrayBuffer()
339
+ if (!ExcelJS?.Workbook) throw new Error('exceljs no disponible')
340
+
294
341
  const wb = new ExcelJS.Workbook()
295
342
  await wb.xlsx.load(buf)
296
343
  const sheets = wb.worksheets.map(ws => buildSheet(ws, wb))
297
344
  xlsxWorkbook.value = { sheets }
298
345
  } catch (e) {
299
- console.error('[FilesPreview] xlsx error', e)
300
- xlsxError.value = e.message
346
+ // Fallback: el archivo no es un .xlsx OOXML válido (típico: un CSV/TSV exportado
347
+ // con extensión .xls, o mime mal detectado). Lo renderizamos como tabla delimitada.
348
+ try {
349
+ const text = new TextDecoder('utf-8').decode(buf ?? new ArrayBuffer(0))
350
+ if (text && text.trim()) {
351
+ xlsxWorkbook.value = { sheets: [buildSheetFromDelimited(text)] }
352
+ } else {
353
+ throw e
354
+ }
355
+ } catch (_) {
356
+ console.error('[FilesPreview] xlsx error', e)
357
+ xlsxError.value = 'No se pudo previsualizar la hoja de cálculo.'
358
+ }
301
359
  } finally {
302
360
  isLoadingXlsx.value = false
303
361
  }
@@ -0,0 +1,22 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ provider: { type: String, default: 'google_drive' },
4
+ label: { type: String, default: 'Google Drive' },
5
+ })
6
+
7
+ const { status, connect, disconnect } = useProviderConnection(props.provider)
8
+ const { data, isLoading } = status()
9
+
10
+ const connected = computed(() => data.value?.connected ?? data.value?.data?.connected ?? false)
11
+ const email = computed(() => data.value?.email ?? data.value?.data?.email ?? null)
12
+ </script>
13
+
14
+ <template>
15
+ <div class="inline-flex items-center gap-2">
16
+ <template v-if="connected">
17
+ <span class="text-sm text-muted-foreground">{{ label }}: <span class="text-foreground font-medium">{{ email }}</span></span>
18
+ <AppButton text="Desconectar" severity="secondary" size="sm" @click="disconnect" />
19
+ </template>
20
+ <AppButton v-else text="Conectar Google Drive" severity="primary" size="sm" :loading="isLoading" @click="connect" />
21
+ </div>
22
+ </template>
@@ -1,4 +1,4 @@
1
- // useAuthStore, useApi, useOrganizationStore, useInnertiaMode, useGymSelection, useGymStore auto-imported
1
+ // useAuthStore, useApi, useOrganizationStore, useInnertiaMode, useTenantSelection, useActiveTenantStore auto-imported
2
2
 
3
3
  export function useAuth() {
4
4
  const authStore = useAuthStore()
@@ -21,12 +21,12 @@ export function useAuth() {
21
21
  queryClient.clear()
22
22
  await fetchMe()
23
23
 
24
- // En modo 'open' el tenant se resuelve por selección: cargamos los gyms del
24
+ // En modo 'open' el tenant se resuelve por selección: cargamos los tenants del
25
25
  // usuario y devolvemos un destino (0/1/2+). saas/app conservan su return.
26
26
  if (useInnertiaMode().isOpen()) {
27
- const { resolveAfterLogin } = useGymSelection()
28
- const gymDestination = await resolveAfterLogin() // 'onboarding' | 'ready' | 'picker'
29
- return { ...(data ?? {}), gymDestination }
27
+ const { resolveAfterLogin } = useTenantSelection()
28
+ const tenantDestination = await resolveAfterLogin() // 'onboarding' | 'ready' | 'picker'
29
+ return { ...(data ?? {}), tenantDestination }
30
30
  }
31
31
 
32
32
  return data
@@ -77,8 +77,8 @@ export function useAuth() {
77
77
  queryClient.clear()
78
78
  authStore.logout()
79
79
  organizationStore.reset()
80
- // En open, limpiar también el gym seleccionado
81
- if (useInnertiaMode().isOpen()) useGymStore().reset()
80
+ // En open, limpiar también el tenant seleccionado
81
+ if (useInnertiaMode().isOpen()) useActiveTenantStore().reset()
82
82
  await navigateTo(loginPath)
83
83
  }
84
84
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * useGooglePicker — carga la Google API JS y abre el Google Picker. Client-only.
3
+ *
4
+ * Uso:
5
+ * const { openPicker } = useGooglePicker()
6
+ * const picked = await openPicker({ apiKey, token, appId, mode: 'file', mimeTypes })
7
+ * // picked === { id, name, mimeType, url } o null si se cancela
8
+ */
9
+ export function useGooglePicker() {
10
+ let pickerApiLoaded = false
11
+
12
+ function loadScript(src: string): Promise<void> {
13
+ return new Promise((resolve, reject) => {
14
+ if (document.querySelector(`script[src="${src}"]`)) return resolve()
15
+ const s = document.createElement('script')
16
+ s.src = src
17
+ s.onload = () => resolve()
18
+ s.onerror = reject
19
+ document.head.appendChild(s)
20
+ })
21
+ }
22
+
23
+ async function ensureLoaded() {
24
+ if (pickerApiLoaded) return
25
+ await loadScript('https://apis.google.com/js/api.js')
26
+ await new Promise<void>((res) => (window as any).gapi.load('picker', () => res()))
27
+ pickerApiLoaded = true
28
+ }
29
+
30
+ /**
31
+ * Abre el Google Picker. opts: { apiKey, token, appId, mode: 'file'|'folder', mimeTypes?: string }
32
+ * Resuelve con { id, name, mimeType, url } del item elegido, o null si se cancela.
33
+ */
34
+ async function openPicker(opts: {
35
+ apiKey: string
36
+ token: string
37
+ appId: string
38
+ mode: 'file' | 'folder'
39
+ mimeTypes?: string
40
+ }) {
41
+ if (typeof window === 'undefined') return null
42
+ await ensureLoaded()
43
+ return new Promise((resolve) => {
44
+ const google = (window as any).google
45
+ const view = opts.mode === 'folder'
46
+ ? new google.picker.DocsView(google.picker.ViewId.FOLDERS).setSelectFolderEnabled(true).setMimeTypes('application/vnd.google-apps.folder')
47
+ : new google.picker.DocsView(google.picker.ViewId.DOCS)
48
+ if (opts.mimeTypes && opts.mode !== 'folder') view.setMimeTypes(opts.mimeTypes)
49
+
50
+ const picker = new google.picker.PickerBuilder()
51
+ .setAppId(opts.appId)
52
+ .setOAuthToken(opts.token)
53
+ .setDeveloperKey(opts.apiKey)
54
+ .addView(view)
55
+ .setCallback((data: any) => {
56
+ if (data.action === google.picker.Action.PICKED) {
57
+ const doc = data.docs[0]
58
+ resolve({ id: doc.id, name: doc.name, mimeType: doc.mimeType, url: doc.url })
59
+ } else if (data.action === google.picker.Action.CANCEL) {
60
+ resolve(null)
61
+ }
62
+ })
63
+ .build()
64
+ picker.setVisible(true)
65
+ })
66
+ }
67
+
68
+ return { openPicker }
69
+ }
@@ -12,7 +12,7 @@ import type { InnertiaMode } from '../app.config'
12
12
  * isApp() → true si mode === 'app'
13
13
  * isOpen() → true si mode === 'open'
14
14
  * hasTenant() → true si mode === 'saas' | 'open' (usan multitenancy;
15
- * saas resuelve por subdominio, open por gym seleccionado)
15
+ * saas resuelve por subdominio, open por tenant seleccionado)
16
16
  */
17
17
  export function useInnertiaMode() {
18
18
  const appConfig = useAppConfig()
@@ -0,0 +1,74 @@
1
+ // useTenantSelection, useActiveTenantStore, useAppConfig auto-imported.
2
+ //
3
+ // Modo 'open' — login personal unificado.
4
+ //
5
+ // Tras autenticar NO conocemos el tenant ni el rol: la fuente de verdad es
6
+ // `auth/my-gyms`, que devuelve tenants:[{key,name,status,contexts}] — un tenant
7
+ // puede otorgar varios contextos (p.ej. backoffice + student = 2 destinos).
8
+ //
9
+ // Expande esos tenants en una lista plana de "destinos" (tenant × contexto) para
10
+ // rutear post-login:
11
+ // 0 destinos -> onboarding
12
+ // 1 destino -> entrar directo (fija tenant activo + navega a home del contexto)
13
+ // 2+ -> selector
14
+ //
15
+ // Los contextos que participan del login personal se declaran por producto en
16
+ // `appConfig.innertia.personalContexts` (p.ej. ['backoffice','student','coach']).
17
+ // Un contexto con login dedicado (p.ej. kiosco 'attendance') NO debe listarse ahí.
18
+
19
+ export function useLoginDestinations() {
20
+ const { load } = useTenantSelection()
21
+ const store = useActiveTenantStore()
22
+ const appConfig = useAppConfig()
23
+
24
+ const personalContexts = () => appConfig.innertia?.personalContexts ?? []
25
+ const contextDefs = () => appConfig.innertia?.contexts ?? {}
26
+
27
+ /** Home de un contexto (según app.config), con fallback razonable. */
28
+ function homeFor(context) {
29
+ const def = Object.values(contextDefs()).find((c) => c.context === context)
30
+ return def?.home || `/${context}`
31
+ }
32
+
33
+ /** Label legible de un contexto. */
34
+ function labelFor(context) {
35
+ const def = Object.values(contextDefs()).find((c) => c.context === context)
36
+ return def?.label || context
37
+ }
38
+
39
+ /**
40
+ * Carga los tenants del usuario y los expande a destinos tenant×contexto.
41
+ * Devuelve { destinations, tenants }.
42
+ * destination = { tenantKey, tenantName, tenantStatus, context, contextLabel, home }
43
+ */
44
+ async function resolve() {
45
+ await load() // rellena store.tenants desde auth/my-gyms
46
+ const tenants = store.tenants || []
47
+
48
+ const destinations = []
49
+ for (const tenant of tenants) {
50
+ const ctxs = (tenant.contexts || []).filter((c) => personalContexts().includes(c))
51
+ for (const context of ctxs) {
52
+ destinations.push({
53
+ tenantKey: tenant.key,
54
+ tenantName: tenant.name || tenant.key,
55
+ tenantStatus: tenant.status,
56
+ context,
57
+ contextLabel: labelFor(context),
58
+ home: homeFor(context),
59
+ })
60
+ }
61
+ }
62
+
63
+ return { destinations, tenants }
64
+ }
65
+
66
+ /** Fija el tenant + contexto activos y navega a la home del contexto elegido. */
67
+ async function go(destination) {
68
+ store.setActive(destination.tenantKey)
69
+ store.setActiveContext(destination.context)
70
+ await navigateTo(destination.home)
71
+ }
72
+
73
+ return { resolve, go, homeFor, labelFor }
74
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * useProviderConnection — estado y flujo OAuth de un proveedor de archivos (ej. Google Drive).
3
+ *
4
+ * Consume los endpoints de conexión del backend innertia-laravel:
5
+ * - GET files/providers/{provider}/status → { connected, email }
6
+ * - GET files/providers/{provider}/connect?scope=user → 302 al consent de Google (popup)
7
+ * - DELETE files/providers/{provider}/connection?scope=user → { connected: false }
8
+ *
9
+ * El popup del consent se cierra solo (el callback devuelve <script>window.close()</script>);
10
+ * al detectar el cierre invalidamos el status para reflejar la conexión.
11
+ *
12
+ * useApi / useQueryClient / useQuery / useRuntimeConfig se auto-importan en la capa.
13
+ */
14
+ export function useProviderConnection(provider = 'google_drive') {
15
+ const api = useApi()
16
+ const queryClient = useQueryClient()
17
+
18
+ const status = () => useQuery({
19
+ queryKey: ['provider', provider, 'status'],
20
+ queryFn: () => api.get(`files/providers/${provider}/status`),
21
+ })
22
+
23
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: ['provider', provider, 'status'] })
24
+
25
+ const connect = () => {
26
+ if (typeof window === 'undefined') return
27
+ const cfg = useRuntimeConfig()
28
+ const base = (cfg.public.apiBaseUrl || '/api').replace(/\/$/, '')
29
+ const popup = window.open(`${base}/files/providers/${provider}/connect?scope=user`, 'provider-connect', 'width=520,height=680')
30
+ // cuando el popup se cierra (callback hizo window.close), refrescamos el estado
31
+ const timer = setInterval(() => {
32
+ if (!popup || popup.closed) { clearInterval(timer); invalidate() }
33
+ }, 800)
34
+ }
35
+
36
+ const disconnect = () => api.delete(`files/providers/${provider}/connection?scope=user`).then(invalidate)
37
+
38
+ return { status, connect, disconnect, invalidate }
39
+ }
@@ -0,0 +1,35 @@
1
+ // useApi, useActiveTenantStore auto-imported.
2
+ // Selección de tenant en modo 'open': carga los tenants del usuario y resuelve
3
+ // el destino post-login según cuántos tenga (0 / 1 / 2+).
4
+
5
+ export function useTenantSelection() {
6
+ const api = useApi()
7
+ const store = useActiveTenantStore()
8
+
9
+ /** Carga los tenants del usuario autenticado y los guarda en el store. */
10
+ async function load() {
11
+ // El backend aún expone la ruta gym-flavored: { gyms: [{ key, name, status, contexts }] }
12
+ const data = await api.get('auth/my-gyms')
13
+ store.setTenants(data?.gyms || [])
14
+ return store.tenants
15
+ }
16
+
17
+ /**
18
+ * Resuelve a dónde llevar al usuario tras el login.
19
+ * 'onboarding' -> 0 tenants (debe crear/unirse a uno)
20
+ * 'ready' -> 1 tenant (auto-seleccionado) o ya tenia uno activo valido
21
+ * 'picker' -> 2+ tenants sin seleccion valida previa
22
+ */
23
+ async function resolveAfterLogin() {
24
+ await load()
25
+ if (store.tenants.length === 0) return 'onboarding'
26
+ if (store.tenants.length === 1) { store.setActive(store.tenants[0].key); return 'ready' }
27
+ if (store.activeKey && store.tenants.some((t) => t.key === store.activeKey)) return 'ready'
28
+ return 'picker'
29
+ }
30
+
31
+ /** Cambia el tenant activo (switcher). */
32
+ function switchTo(key) { store.setActive(key) }
33
+
34
+ return { load, resolveAfterLogin, switchTo }
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.50",
3
+ "version": "0.1.53",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -2,9 +2,9 @@
2
2
  // El backend (ResolveTenantFromHeader) identifica el tenant por su key/slug, no por UUID.
3
3
  // - saas: slug del tenant resuelto por subdominio (tenantStore); solo se envía
4
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
+ // - open: key del tenant seleccionado in-app (activeTenant.activeKey).
6
6
  // useRequestInterceptors auto-imported desde nuxt-core.
7
- // useTenantStore / useGymStore auto-imported desde stores.
7
+ // useTenantStore / useActiveTenantStore auto-imported desde stores.
8
8
  // Activo en mode === 'saas' | 'open'.
9
9
  export default defineNuxtPlugin(() => {
10
10
  const { isOpen, hasTenant } = useInnertiaMode()
@@ -15,10 +15,10 @@ export default defineNuxtPlugin(() => {
15
15
  const { add } = useRequestInterceptors()
16
16
 
17
17
  add((headers: Record<string, string>) => {
18
- // open: tenant por gym seleccionado
18
+ // open: tenant por selección in-app
19
19
  if (isOpen()) {
20
- const gymStore = useGymStore()
21
- if (gymStore.activeKey) headers['X-Tenant'] = gymStore.activeKey
20
+ const activeTenant = useActiveTenantStore()
21
+ if (activeTenant.activeKey) headers['X-Tenant'] = activeTenant.activeKey
22
22
  return
23
23
  }
24
24
 
@@ -0,0 +1,27 @@
1
+ import { defineStore } from 'pinia'
2
+
3
+ /**
4
+ * Tenant 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 tenants y esa key viaja como X-Tenant.
7
+ * tenants: [{ key, name, status, contexts }]
8
+ */
9
+ export const useActiveTenantStore = defineStore('activeTenant', {
10
+ state: () => ({ activeKey: null, activeContext: null, tenants: [] }),
11
+
12
+ getters: {
13
+ active: (s) => s.tenants.find((t) => t.key === s.activeKey) || null,
14
+ hasMultiple: (s) => s.tenants.length > 1,
15
+ },
16
+
17
+ actions: {
18
+ setTenants(tenants) { this.tenants = tenants || [] },
19
+ setActive(key) { this.activeKey = key },
20
+ setActiveContext(context) { this.activeContext = context },
21
+ reset() { this.activeKey = null; this.activeContext = null; this.tenants = [] },
22
+ },
23
+
24
+ persist: {
25
+ pick: ['activeKey', 'activeContext'],
26
+ },
27
+ })
@@ -1,35 +0,0 @@
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
- }
package/stores/gym.js DELETED
@@ -1,29 +0,0 @@
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, activeContext: 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
- // Contexto elegido (backoffice/student/…) — un gym puede otorgar varios.
21
- setActiveContext(context) { this.activeContext = context },
22
- reset() { this.activeKey = null; this.activeContext = null; this.gyms = [] },
23
- },
24
-
25
- persist: {
26
- // Key + contexto activos sobreviven el refresh; la lista se recarga del backend.
27
- pick: ['activeKey', 'activeContext'],
28
- },
29
- })