@innertia-solutions/innertia-nuxt 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -24,6 +24,10 @@ export default defineNuxtConfig({
24
24
  innertia: {
25
25
  mode: 'saas', // 'saas' (default) | 'app'
26
26
 
27
+ branding: { name: 'MyApp', version: '1.0.0' },
28
+ colors: { primary: 'violet', secondary: 'slate' },
29
+ darkTone: 'slate', // 'neutral' (default) | 'slate' | 'gray' | 'zinc' | 'stone'
30
+
27
31
  apps: {
28
32
  backoffice: {
29
33
  path: '/backoffice',
package/app.config.ts CHANGED
@@ -20,6 +20,76 @@ export default defineAppConfig({
20
20
  */
21
21
  mode: 'saas' as InnertiaMode,
22
22
 
23
+ /**
24
+ * Branding del producto (nombre + versión). Los logos viven en `/public/isologo-light.png`
25
+ * y `/public/isologo-dark.png` de cada producto — la librería los referencia por path fijo.
26
+ */
27
+ branding: {
28
+ name: 'Innertia',
29
+ version: '1.0.0',
30
+ },
31
+
32
+ /**
33
+ * Colores primario y secundario. Acepta:
34
+ * - Nombre de color de Tailwind (string): 'violet', 'indigo', 'emerald', etc.
35
+ * - Scale custom (object): { 50: '#f5f3ff', ..., 950: '#2e1065' }
36
+ *
37
+ * El plugin `colors` aplica estos valores a las CSS vars `--primary-{50..950}`
38
+ * y `--secondary-{50..950}` en SSR y cliente.
39
+ */
40
+ colors: {
41
+ primary: 'blue' as ColorOption,
42
+ secondary: 'slate' as ColorOption,
43
+ },
44
+
45
+ /**
46
+ * Tono neutral usado en modo dark (backgrounds, borders, surfaces).
47
+ * Cambia el "feel" del modo oscuro sin tocar el color de marca.
48
+ *
49
+ * - 'neutral' (default) → gris puro, neutral
50
+ * - 'slate' → gris con leve tinte azul (más frío)
51
+ * - 'gray' → gris levemente más cálido que slate
52
+ * - 'zinc' → gris cálido
53
+ * - 'stone' → gris muy cálido (con tinte tierra)
54
+ */
55
+ darkTone: 'neutral' as DarkTone,
56
+
57
+ /**
58
+ * Contenido del panel marketing del layout `auth`. Si no se declara, el panel queda vacío.
59
+ */
60
+ marketing: {
61
+ /** Palabras del typewriter. Vacío = sin typewriter (muestra heading estático). */
62
+ words: [] as string[],
63
+ /** Tagline mostrada después del typewriter. Soporta `\n` para saltos de línea. */
64
+ tagline: '',
65
+ /** Descripción debajo del tagline. */
66
+ description: '',
67
+ /** Footer con lista de items (ej. normas, certificaciones, integraciones). */
68
+ footer: {
69
+ title: '',
70
+ items: [] as string[],
71
+ description: '',
72
+ },
73
+ },
74
+
75
+ /**
76
+ * Proveedores OAuth para modo `app`. En modo `saas` se ignora — la lista viene
77
+ * de `tenantStore.config.oauth` que se carga por SSR desde el backend.
78
+ */
79
+ oauth: [] as OAuthProvider[],
80
+
81
+ /**
82
+ * Items principales del menú del layout `backoffice`.
83
+ * Cada item: { label, icon ('IconName' de @tabler/icons-vue), route, pattern? }.
84
+ */
85
+ menu: [] as MenuItem[],
86
+
87
+ /**
88
+ * Items secundarios del menú backoffice (apps / módulos). Renderizan junto a `menu`
89
+ * pero permiten diferenciar visualmente en el futuro (ej. sub-secciones modulares).
90
+ */
91
+ menuApps: [] as MenuItem[],
92
+
23
93
  /**
24
94
  * Declaración de "apps" (contextos) del producto.
25
95
  * Cada app define un prefijo de URL que mapea a un contexto del backend
@@ -46,6 +116,26 @@ export type InnertiaMode = 'saas' | 'app'
46
116
 
47
117
  export type AppMobileMode = 'allow' | 'block' | 'redirect'
48
118
 
119
+ /** Color: nombre de paleta Tailwind o scale custom 50→950. */
120
+ export type ColorScale = Partial<Record<50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950, string>>
121
+ export type ColorOption = string | ColorScale
122
+
123
+ /** Tono neutral usado en modo dark — debe ser una paleta gris de Tailwind. */
124
+ export type DarkTone = 'neutral' | 'slate' | 'gray' | 'zinc' | 'stone'
125
+
126
+ /** Proveedor OAuth — slug del provider (matchea con backend SocialAuthController). */
127
+ export type OAuthProvider = 'google' | 'microsoft' | 'apple' | 'github'
128
+
129
+ /** Item de menú del layout backoffice. */
130
+ export interface MenuItem {
131
+ label: string
132
+ /** Nombre de icono de @tabler/icons-vue (con prefijo Icon). Ej: 'IconHome'. */
133
+ icon: string
134
+ route: string
135
+ /** Pattern (glob style con `*`) para resaltar item activo. Si no se pasa, usa route. */
136
+ pattern?: string
137
+ }
138
+
49
139
  export interface AppDefinition {
50
140
  /** Prefijo de URL — la ruta debe empezar con esto para considerarse "dentro" del app. */
51
141
  path: string
@@ -0,0 +1,225 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Formulario de login estándar reutilizable.
4
+ *
5
+ * - Email + password + botón ingresar
6
+ * - Botones OAuth dinámicos según tenant (saas) o appConfig (app)
7
+ * - Banner de modo demo si tenant.config.demo está presente
8
+ * - Cross-links a otros contextos mobile-friendly (configurable via prop)
9
+ *
10
+ * Uso:
11
+ * <AuthLoginForm context="backoffice" />
12
+ */
13
+ import { IconBrandGoogle, IconBrandWindows, IconBrandApple, IconBrandGithub, IconArrowRight, IconEye, IconEyeOff } from '@tabler/icons-vue'
14
+ import type { OAuthProvider } from '../../app.config'
15
+
16
+ const props = defineProps<{
17
+ /** Contexto del backend al que el form hace login (ej. 'backoffice', 'technician'). */
18
+ context: string
19
+ /** Título del form. */
20
+ title?: string
21
+ /** Subtítulo / descripción. */
22
+ description?: string
23
+ /** Mostrar cross-links a otros apps mobile-friendly. */
24
+ showCrossLinks?: boolean
25
+ }>()
26
+
27
+ const heading = computed(() => props.title ?? 'Bienvenido de nuevo')
28
+ const subheading = computed(() => props.description ?? 'Ingresa tus credenciales para acceder a tu espacio de trabajo.')
29
+
30
+ const { performLogin, getOauthRedirectUrl } = useAuth()
31
+ const config = useRuntimeConfig()
32
+
33
+ // ── Demo credentials & OAuth providers (vienen del tenant config en saas, del appConfig en app) ──
34
+ const { hasTenant, isApp } = useInnertiaMode()
35
+ const appConfig = useAppConfig()
36
+ const tenantStore = hasTenant() ? useTenantStore() : null
37
+
38
+ const demo = computed<{ email?: string; password?: string } | null>(() => {
39
+ if (tenantStore) return (tenantStore as any).config?.demo ?? null
40
+ return null
41
+ })
42
+
43
+ const oauthProviders = computed<OAuthProvider[]>(() => {
44
+ if (tenantStore) {
45
+ const fromTenant = (tenantStore as any).config?.oauth as OAuthProvider[] | undefined
46
+ return Array.isArray(fromTenant) ? fromTenant : []
47
+ }
48
+ return (appConfig.innertia?.oauth ?? []) as OAuthProvider[]
49
+ })
50
+
51
+ const oauthIcon = (provider: OAuthProvider) => ({
52
+ google: IconBrandGoogle,
53
+ microsoft: IconBrandWindows,
54
+ apple: IconBrandApple,
55
+ github: IconBrandGithub,
56
+ }[provider])
57
+
58
+ const oauthLabel = (provider: OAuthProvider) => ({
59
+ google: 'Google',
60
+ microsoft: 'Microsoft',
61
+ apple: 'Apple',
62
+ github: 'GitHub',
63
+ }[provider])
64
+
65
+ // ── Form state ─────────────────────────────────────────────────────────────
66
+ const form = useForm({
67
+ email: { value: demo.value?.email ?? '', rules: ['required', 'email'] },
68
+ password: { value: demo.value?.password ?? '', rules: ['required', { name: 'min', arg: 8 }] },
69
+ })
70
+
71
+ const processing = ref(false)
72
+ const showPassword = ref(false)
73
+
74
+ async function handleSubmit() {
75
+ if (!form.validate()) return
76
+ processing.value = true
77
+ try {
78
+ const data = await performLogin(props.context, form.values.email, form.values.password)
79
+ if (data?.requires_password_change) {
80
+ await navigateTo(`/${props.context}/auth/change-password`)
81
+ } else {
82
+ await navigateTo(config.public.homePath || `/${props.context}`)
83
+ }
84
+ } catch (e: any) {
85
+ form.addError('password', e?.data?.message ?? 'Credenciales incorrectas.')
86
+ } finally {
87
+ processing.value = false
88
+ }
89
+ }
90
+
91
+ async function handleOauth(provider: OAuthProvider) {
92
+ try {
93
+ const url = await getOauthRedirectUrl(props.context, provider)
94
+ if (url) window.location.href = url
95
+ } catch (e: any) {
96
+ form.addError('email', e?.data?.message ?? `Error al iniciar sesión con ${oauthLabel(provider)}.`)
97
+ }
98
+ }
99
+
100
+ // ── Cross-links a otros contextos mobile-friendly (opcional) ───────────────
101
+ const { isMobile } = useMobileGuard()
102
+ const { all } = useApp()
103
+ const otherMobileApps = computed(() => {
104
+ if (!props.showCrossLinks) return []
105
+ return all.value.filter((a: any) => a.path !== `/${props.context}` && a.mobile?.mode === 'allow')
106
+ })
107
+ </script>
108
+
109
+ <template>
110
+ <div class="space-y-6">
111
+ <!-- Heading -->
112
+ <div>
113
+ <h1 class="text-2xl font-bold text-slate-900 dark:text-white">{{ heading }}</h1>
114
+ <p class="mt-1.5 text-sm text-slate-500 dark:text-slate-400">{{ subheading }}</p>
115
+ </div>
116
+
117
+ <!-- OAuth providers (dinámicos según tenant/app config) -->
118
+ <div v-if="oauthProviders.length > 0" class="space-y-3">
119
+ <div :class="oauthProviders.length === 1 ? '' : 'grid grid-cols-2 gap-3'">
120
+ <button
121
+ v-for="provider in oauthProviders"
122
+ :key="provider"
123
+ type="button"
124
+ @click="handleOauth(provider)"
125
+ class="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 shadow-xs hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 transition-colors"
126
+ >
127
+ <component :is="oauthIcon(provider)" class="size-4 shrink-0" />
128
+ {{ oauthLabel(provider) }}
129
+ </button>
130
+ </div>
131
+ <div class="relative">
132
+ <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200 dark:border-slate-700" /></div>
133
+ <div class="relative flex justify-center"><span class="bg-white dark:bg-slate-900 px-3 text-xs text-slate-400">O continúa con tu correo</span></div>
134
+ </div>
135
+ </div>
136
+
137
+ <!-- Demo banner -->
138
+ <div v-if="demo" class="flex items-start gap-2.5 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900/20 px-3.5 py-3">
139
+ <svg class="shrink-0 mt-0.5 size-4 text-amber-600 dark:text-amber-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
140
+ <circle cx="12" cy="12" r="10" /><line x1="12" x2="12" y1="8" y2="12" /><line x1="12" x2="12.01" y1="16" y2="16" />
141
+ </svg>
142
+ <div>
143
+ <p class="text-xs font-semibold text-amber-700 dark:text-amber-400">Modo demo</p>
144
+ <p class="text-xs text-amber-600 dark:text-amber-500 mt-0.5">Credenciales precargadas. Haz clic en <strong>Ingresar</strong> para explorar.</p>
145
+ </div>
146
+ </div>
147
+
148
+ <!-- Form -->
149
+ <form class="space-y-4" @submit.prevent="handleSubmit" novalidate>
150
+ <div>
151
+ <label class="block mb-2 text-sm font-medium text-slate-800 dark:text-white">Correo electrónico</label>
152
+ <input
153
+ v-model="form.values.email"
154
+ type="email"
155
+ :disabled="processing"
156
+ autocomplete="email"
157
+ placeholder="tu@correo.com"
158
+ @blur="form.validate('email')"
159
+ class="py-2.5 px-3 block w-full border rounded-lg sm:text-sm placeholder:text-slate-400 focus:ring-1 disabled:opacity-50 dark:bg-transparent dark:text-slate-300"
160
+ :class="form.errors.email?.length ? 'border-red-400 focus:border-red-400 focus:ring-red-400' : 'border-slate-200 focus:border-primary focus:ring-primary dark:border-slate-700'"
161
+ />
162
+ <p v-if="form.errors.email?.length" class="mt-1.5 text-xs text-red-500">{{ form.errors.email[0] }}</p>
163
+ </div>
164
+
165
+ <div>
166
+ <div class="flex justify-between items-center mb-2">
167
+ <label class="text-sm font-medium text-slate-800 dark:text-white">Contraseña</label>
168
+ <NuxtLink :to="`/${context}/auth/forgot-password`" class="text-xs text-primary hover:underline">
169
+ ¿Olvidaste tu contraseña?
170
+ </NuxtLink>
171
+ </div>
172
+ <div class="relative">
173
+ <input
174
+ v-model="form.values.password"
175
+ :type="showPassword ? 'text' : 'password'"
176
+ :disabled="processing"
177
+ autocomplete="current-password"
178
+ placeholder="••••••••"
179
+ @blur="form.validate('password')"
180
+ class="py-2.5 px-3 block w-full border rounded-lg sm:text-sm placeholder:text-slate-400 focus:ring-1 disabled:opacity-50 dark:bg-transparent dark:text-slate-300"
181
+ :class="form.errors.password?.length ? 'border-red-400 focus:border-red-400 focus:ring-red-400' : 'border-slate-200 focus:border-primary focus:ring-primary dark:border-slate-700'"
182
+ />
183
+ <button
184
+ type="button"
185
+ tabindex="-1"
186
+ @click="showPassword = !showPassword"
187
+ class="absolute inset-y-0 end-0 flex items-center px-3 cursor-pointer text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
188
+ >
189
+ <IconEyeOff v-if="showPassword" class="size-4" />
190
+ <IconEye v-else class="size-4" />
191
+ </button>
192
+ </div>
193
+ <p v-if="form.errors.password?.length" class="mt-1.5 text-xs text-red-500">{{ form.errors.password[0] }}</p>
194
+ </div>
195
+
196
+ <button
197
+ type="submit"
198
+ :disabled="processing"
199
+ class="py-2.5 px-3 w-full inline-flex justify-center items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent bg-primary hover:bg-primary-hover text-white disabled:opacity-50"
200
+ >
201
+ <span v-if="processing" class="animate-spin inline-block size-4 border-[2px] border-t-transparent border-white rounded-full" />
202
+ <span v-else class="flex items-center gap-x-2">
203
+ Ingresar
204
+ <IconArrowRight class="size-4" />
205
+ </span>
206
+ </button>
207
+ </form>
208
+
209
+ <!-- Cross-links a otros contextos mobile-friendly -->
210
+ <div v-if="isMobile && otherMobileApps.length > 0" class="space-y-2 pt-2">
211
+ <div class="relative py-1">
212
+ <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200 dark:border-slate-700" /></div>
213
+ <div class="relative flex justify-center"><span class="bg-white dark:bg-slate-900 px-3 text-[11px] uppercase tracking-wider text-slate-400">o</span></div>
214
+ </div>
215
+ <NuxtLink
216
+ v-for="other in otherMobileApps"
217
+ :key="other.path"
218
+ :to="other.loginPath"
219
+ class="w-full inline-flex items-center justify-center gap-2 py-2.5 px-3 rounded-lg border border-primary-200 dark:border-primary-800 bg-primary-50 dark:bg-primary-900/20 text-sm font-medium text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/40 transition-colors"
220
+ >
221
+ Entrar como {{ other.label }}
222
+ </NuxtLink>
223
+ </div>
224
+ </div>
225
+ </template>
@@ -0,0 +1,180 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Panel marketing del auth layout — typewriter + tagline + footer con items.
4
+ *
5
+ * Lee contenido desde `appConfig.innertia.marketing`. Si no hay contenido configurado,
6
+ * renderiza vacío para que el producto pueda llenarlo via slots del layout o no mostrarlo.
7
+ *
8
+ * Colores: usa --primary (configurable via appConfig.innertia.colors.primary).
9
+ */
10
+
11
+ const appConfig = useAppConfig()
12
+ const marketing = computed(() => appConfig.innertia?.marketing ?? {
13
+ words: [],
14
+ tagline: '',
15
+ description: '',
16
+ footer: { title: '', items: [], description: '' },
17
+ })
18
+
19
+ const hasContent = computed(() =>
20
+ (marketing.value.words ?? []).length > 0 ||
21
+ !!marketing.value.tagline ||
22
+ !!marketing.value.description ||
23
+ !!marketing.value.footer?.title
24
+ )
25
+
26
+ // ── Typewriter ─────────────────────────────────────────────────────────────
27
+ const typed = ref('')
28
+ const cursor = ref(true)
29
+ let wordIdx = 0
30
+ let charIdx = 0
31
+ let deleting = false
32
+ let typeTimeout: ReturnType<typeof setTimeout> | null = null
33
+ let cursorInterval: ReturnType<typeof setInterval> | null = null
34
+
35
+ function typeStep() {
36
+ const words = marketing.value.words ?? []
37
+ if (words.length === 0) return
38
+
39
+ const word = words[wordIdx]
40
+ if (!deleting) {
41
+ charIdx++
42
+ typed.value = word.slice(0, charIdx)
43
+ if (charIdx === word.length) {
44
+ deleting = true
45
+ typeTimeout = setTimeout(typeStep, 1600)
46
+ return
47
+ }
48
+ typeTimeout = setTimeout(typeStep, 90)
49
+ } else {
50
+ charIdx--
51
+ typed.value = word.slice(0, charIdx)
52
+ if (charIdx === 0) {
53
+ deleting = false
54
+ wordIdx = (wordIdx + 1) % words.length
55
+ typeTimeout = setTimeout(typeStep, 300)
56
+ return
57
+ }
58
+ typeTimeout = setTimeout(typeStep, 50)
59
+ }
60
+ }
61
+
62
+ onMounted(() => {
63
+ if ((marketing.value.words ?? []).length > 0) {
64
+ typeStep()
65
+ cursorInterval = setInterval(() => { cursor.value = !cursor.value }, 530)
66
+ }
67
+ })
68
+
69
+ onUnmounted(() => {
70
+ if (typeTimeout) clearTimeout(typeTimeout)
71
+ if (cursorInterval) clearInterval(cursorInterval)
72
+ })
73
+
74
+ // ── Mouse glow ─────────────────────────────────────────────────────────────
75
+ const mouseX = ref(50)
76
+ const mouseY = ref(50)
77
+ const glowVisible = ref(false)
78
+
79
+ function onMouseMove(e: MouseEvent) {
80
+ const target = e.currentTarget as HTMLElement
81
+ const rect = target.getBoundingClientRect()
82
+ mouseX.value = ((e.clientX - rect.left) / rect.width) * 100
83
+ mouseY.value = ((e.clientY - rect.top) / rect.height) * 100
84
+ glowVisible.value = true
85
+ }
86
+
87
+ function onMouseLeave() {
88
+ glowVisible.value = false
89
+ }
90
+
91
+ const dotMaskStyle = computed(() => ({
92
+ opacity: glowVisible.value ? 1 : 0,
93
+ backgroundImage: 'radial-gradient(circle, var(--primary-300, rgba(196,181,253,0.55)) 1.5px, transparent 1.5px)',
94
+ backgroundSize: '22px 22px',
95
+ WebkitMaskImage: `radial-gradient(circle 180px at ${mouseX.value}% ${mouseY.value}%, black 0%, transparent 70%)`,
96
+ maskImage: `radial-gradient(circle 180px at ${mouseX.value}% ${mouseY.value}%, black 0%, transparent 70%)`,
97
+ transition: 'opacity 0.5s ease',
98
+ }))
99
+
100
+ // Tagline puede tener \n para saltos de línea
101
+ const taglineLines = computed(() => (marketing.value.tagline ?? '').split('\n'))
102
+ </script>
103
+
104
+ <template>
105
+ <div
106
+ class="hidden lg:flex lg:w-[42%] xl:w-[38%] bg-primary-50 dark:bg-slate-950 flex-col justify-between p-10 relative overflow-hidden shrink-0"
107
+ @mousemove="onMouseMove"
108
+ @mouseleave="onMouseLeave"
109
+ >
110
+ <!-- Decoración de fondo -->
111
+ <div class="absolute inset-0 pointer-events-none">
112
+ <!-- Cuadrícula de puntos base — light mode -->
113
+ <div
114
+ class="absolute inset-0 dark:hidden"
115
+ :style="{
116
+ backgroundImage: 'radial-gradient(circle, var(--primary-900, rgba(76,29,149,0.5)) 1px, transparent 1px)',
117
+ backgroundSize: '22px 22px',
118
+ opacity: 0.18,
119
+ }"
120
+ />
121
+ <!-- Cuadrícula de puntos base — dark mode -->
122
+ <div
123
+ class="absolute inset-0 hidden dark:block"
124
+ style="background-image: radial-gradient(circle, rgba(255,255,255,0.5) 1px, transparent 1px); background-size: 22px 22px; opacity: 0.18;"
125
+ />
126
+
127
+ <!-- Cuadrícula brillante enmascarada al cursor -->
128
+ <div class="absolute inset-0" :style="dotMaskStyle" />
129
+
130
+ <!-- Blobs estáticos -->
131
+ <div class="absolute -top-32 -left-32 size-96 rounded-full bg-primary-400/25 dark:bg-primary-600/10 blur-3xl" />
132
+ <div class="absolute bottom-0 right-0 size-80 rounded-full bg-primary-500/20 dark:bg-primary-800/10 blur-3xl" />
133
+ </div>
134
+
135
+ <!-- Logo (slot opcional, default: logos del /public del producto) -->
136
+ <div class="relative z-10">
137
+ <slot name="logo">
138
+ <img src="/isologo-light.png" :alt="appConfig.innertia?.branding?.name ?? 'Logo'" class="h-8 dark:hidden" />
139
+ <img src="/isologo-dark.png" :alt="appConfig.innertia?.branding?.name ?? 'Logo'" class="h-8 hidden dark:block" />
140
+ </slot>
141
+ </div>
142
+
143
+ <!-- Tagline + typewriter -->
144
+ <div v-if="hasContent" class="relative z-10 space-y-5">
145
+ <h2 class="text-4xl xl:text-5xl font-bold text-slate-900 dark:text-white leading-snug">
146
+ <template v-if="(marketing.words ?? []).length > 0">
147
+ <span class="text-primary-700 dark:text-primary-300">
148
+ {{ typed }}<span
149
+ :class="cursor ? 'opacity-60' : 'opacity-0'"
150
+ class="inline-block w-px h-[0.85em] bg-primary-700/60 dark:bg-primary-300/60 align-middle ml-0.5 transition-opacity duration-100"
151
+ />
152
+ </span><br />
153
+ </template>
154
+ <template v-for="(line, i) in taglineLines" :key="i">
155
+ {{ line }}<br v-if="i < taglineLines.length - 1" />
156
+ </template>
157
+ </h2>
158
+ <p v-if="marketing.description" class="text-slate-700 dark:text-slate-400 text-sm leading-relaxed max-w-xs">
159
+ {{ marketing.description }}
160
+ </p>
161
+ </div>
162
+
163
+ <!-- Footer items -->
164
+ <div v-if="marketing.footer?.title || (marketing.footer?.items ?? []).length > 0" class="relative z-10 space-y-3">
165
+ <p v-if="marketing.footer.title" class="text-xs font-medium text-slate-600 dark:text-slate-500 uppercase tracking-widest">
166
+ {{ marketing.footer.title }}
167
+ </p>
168
+ <div v-if="(marketing.footer.items ?? []).length > 0" class="flex flex-wrap items-center gap-2">
169
+ <span
170
+ v-for="item in marketing.footer.items"
171
+ :key="item"
172
+ class="inline-flex items-center rounded-md border border-slate-900/10 bg-white/40 px-2.5 py-1 text-xs font-medium text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-400"
173
+ >{{ item }}</span>
174
+ </div>
175
+ <p v-if="marketing.footer.description" class="text-xs text-slate-600 dark:text-slate-600 leading-relaxed">
176
+ {{ marketing.footer.description }}
177
+ </p>
178
+ </div>
179
+ </div>
180
+ </template>
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Layout para flujos de autenticación (login, recovery, register).
4
+ *
5
+ * Estructura:
6
+ * [Panel marketing (lg+ only)] [Form area: header (theme switch) | slot | footer (© + version)]
7
+ *
8
+ * Configurable via:
9
+ * - `appConfig.innertia.branding.name` y `branding.version` (footer)
10
+ * - `appConfig.innertia.marketing.*` (panel izquierdo — typewriter, tagline, footer)
11
+ * - `appConfig.innertia.colors.primary` (color de acentos)
12
+ *
13
+ * Slots disponibles:
14
+ * - `marketing` — sobreescribe el panel izquierdo entero
15
+ * - `logo` — sobreescribe el logo del topbar mobile (default: /isologo-{light,dark}.png)
16
+ * - default — contenido del formulario (login/recovery/register)
17
+ */
18
+
19
+ onMounted(() => {
20
+ if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem('auth-entered')
21
+ })
22
+
23
+ const appConfig = useAppConfig()
24
+ const branding = computed(() => appConfig.innertia?.branding ?? { name: 'Innertia', version: '' })
25
+ </script>
26
+
27
+ <template>
28
+ <div class="min-h-screen flex bg-white dark:bg-slate-900">
29
+
30
+ <!-- ── Columna izquierda: marketing panel ─────────────────────────────── -->
31
+ <slot name="marketing">
32
+ <AuthMarketingPanel />
33
+ </slot>
34
+
35
+ <!-- ── Columna derecha: formulario ────────────────────────────────────── -->
36
+ <div class="flex flex-1 flex-col">
37
+
38
+ <!-- Barra superior -->
39
+ <div class="relative flex items-center justify-end px-6 pt-5">
40
+ <!-- Logo centrado en mobile (oculto en lg+) -->
41
+ <div class="lg:hidden absolute inset-x-0 px-6 flex justify-center pointer-events-none">
42
+ <div class="w-full max-w-sm flex">
43
+ <NuxtLink to="/" class="pointer-events-auto">
44
+ <slot name="logo">
45
+ <img src="/isologo-light.png" :alt="branding.name" class="h-7 dark:hidden" />
46
+ <img src="/isologo-dark.png" :alt="branding.name" class="h-7 hidden dark:block" />
47
+ </slot>
48
+ </NuxtLink>
49
+ </div>
50
+ </div>
51
+ <AppSwitchColorTheme />
52
+ </div>
53
+
54
+ <!-- Contenido del formulario -->
55
+ <div class="flex flex-1 items-center justify-center px-6 py-10">
56
+ <div class="w-full max-w-sm">
57
+ <Transition name="auth-content" mode="out-in">
58
+ <slot />
59
+ </Transition>
60
+ </div>
61
+ </div>
62
+
63
+ <!-- Pie de página: © {nombre} - v{version} -->
64
+ <div class="flex items-center justify-end gap-x-2 px-6 pb-5">
65
+ <span class="text-xs text-slate-400 dark:text-slate-600">
66
+ © {{ new Date().getFullYear() }} {{ branding.name }}
67
+ </span>
68
+ <template v-if="branding.version">
69
+ <span class="text-xs text-slate-300 dark:text-slate-700">-</span>
70
+ <span class="text-xs text-slate-400 dark:text-slate-600">v{{ branding.version }}</span>
71
+ </template>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </template>
76
+
77
+ <style>
78
+ .auth-content-enter-active,
79
+ .auth-content-leave-active {
80
+ transition: opacity 200ms ease, transform 200ms ease;
81
+ }
82
+ .auth-content-enter-from,
83
+ .auth-content-leave-to {
84
+ opacity: 0;
85
+ transform: translateY(6px);
86
+ }
87
+ </style>
@@ -0,0 +1,209 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Layout backoffice estándar — topbar oscuro con logo + menú + user dropdown.
4
+ *
5
+ * Configurable via `appConfig.innertia.menu` (items principales) y
6
+ * `appConfig.innertia.menuApps` (apps secundarias, opcional para layouts dobles).
7
+ *
8
+ * Slots disponibles:
9
+ * - `search` — contenido del centro del topbar (ej. buscador AI). Default: vacío.
10
+ * - `logo` — sobreescribe el logo. Default: /isologo-{light,dark}.png + link a home del primer contexto.
11
+ * - default — contenido principal de la página.
12
+ */
13
+ import * as icons from '@tabler/icons-vue'
14
+
15
+ interface MenuItem {
16
+ label: string
17
+ icon: string
18
+ route: string
19
+ pattern?: string
20
+ }
21
+
22
+ const { logout } = useAuth()
23
+ const authStore = useAuthStore()
24
+ const { isDark, toggle } = useTheme()
25
+ const { docked } = useDockedPreviews()
26
+ const route = useRoute()
27
+ const appConfig = useAppConfig()
28
+
29
+ const branding = computed(() => appConfig.innertia?.branding ?? { name: 'Innertia' })
30
+ const menuItems = computed<MenuItem[]>(() => (appConfig.innertia?.menu ?? []) as MenuItem[])
31
+ const menuApps = computed<MenuItem[]>(() => (appConfig.innertia?.menuApps ?? []) as MenuItem[])
32
+
33
+ const matchPattern = (pattern: string | undefined, fallback: string) => {
34
+ const p = pattern || fallback
35
+ if (!p) return false
36
+ const regex = new RegExp('^' + p.replace(/\*/g, '.*') + '$')
37
+ return regex.test(route.path)
38
+ }
39
+
40
+ const mobileOpen = ref(false)
41
+ watch(() => route.path, () => { mobileOpen.value = false })
42
+
43
+ const userInitial = computed(() =>
44
+ (authStore.user as any)?.name?.charAt(0)?.toUpperCase()
45
+ ?? (authStore.user as any)?.email?.charAt(0)?.toUpperCase()
46
+ ?? 'U'
47
+ )
48
+
49
+ // Default home — primer item del menu o '/'
50
+ const homeRoute = computed(() => menuItems.value[0]?.route ?? '/')
51
+ </script>
52
+
53
+ <template>
54
+ <div class="relative bg-background min-h-screen">
55
+ <!-- Dot pattern background -->
56
+ <div
57
+ class="fixed inset-0 pointer-events-none dark:hidden"
58
+ style="background-image: radial-gradient(circle, rgba(0,0,0,0.07) 1px, transparent 1px); background-size: 20px 20px; opacity: 0.5;"
59
+ />
60
+ <div
61
+ class="fixed inset-0 pointer-events-none hidden dark:block"
62
+ style="background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px); background-size: 20px 20px;"
63
+ />
64
+
65
+ <!-- ========== HEADER ========== -->
66
+ <header class="flex flex-col z-50 sticky top-0">
67
+
68
+ <!-- Top bar -->
69
+ <div class="relative overflow-hidden bg-navbar-inverse border-b border-navbar-divider">
70
+ <div class="max-w-[85rem] flex justify-between lg:grid lg:grid-cols-3 basis-full items-center w-full mx-auto py-1.5 px-4 sm:px-6 lg:px-8">
71
+
72
+ <!-- Left: Logo + mobile hamburger -->
73
+ <div class="flex items-center gap-x-3">
74
+ <slot name="logo">
75
+ <NuxtLink :to="homeRoute" class="flex-none ml-1">
76
+ <img src="/isologo-light.png" class="h-6 w-auto dark:hidden" :alt="branding.name" />
77
+ <img src="/isologo-dark.png" class="h-6 w-auto hidden dark:block" :alt="branding.name" />
78
+ </NuxtLink>
79
+ </slot>
80
+ <button
81
+ type="button"
82
+ class="lg:hidden inline-flex justify-center items-center size-9 rounded-lg text-foreground-inverse hover:bg-plain/10 focus:outline-hidden"
83
+ @click="mobileOpen = !mobileOpen"
84
+ :aria-expanded="mobileOpen"
85
+ aria-label="Toggle navigation"
86
+ >
87
+ <icons.IconMenu2 v-if="!mobileOpen" class="size-4 shrink-0" />
88
+ <icons.IconX v-else class="size-4 shrink-0" />
89
+ </button>
90
+ </div>
91
+
92
+ <!-- Center: slot custom (ej. buscador AI) -->
93
+ <div class="hidden lg:flex justify-center">
94
+ <slot name="search" />
95
+ </div>
96
+
97
+ <!-- Right: theme toggle + alerts + user -->
98
+ <div class="flex items-center justify-end gap-x-1">
99
+
100
+ <button
101
+ type="button"
102
+ class="inline-flex justify-center items-center size-9 rounded-full text-foreground-inverse hover:bg-plain/10 focus:outline-hidden"
103
+ @click="toggle"
104
+ :title="isDark ? 'Modo claro' : 'Modo oscuro'"
105
+ >
106
+ <icons.IconSun v-if="isDark" class="size-4 shrink-0" />
107
+ <icons.IconMoon v-else class="size-4 shrink-0" />
108
+ </button>
109
+
110
+ <slot name="topbar-actions" />
111
+
112
+ <div class="hidden sm:block w-px h-6 bg-navbar-divider/20 mx-1" />
113
+
114
+ <!-- User dropdown -->
115
+ <div class="hs-dropdown [--placement:bottom-right] relative inline-flex">
116
+ <button
117
+ type="button"
118
+ class="inline-flex shrink-0 items-center rounded-full focus:outline-hidden"
119
+ aria-haspopup="menu"
120
+ aria-label="Cuenta"
121
+ >
122
+ <div class="size-9 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-bold select-none">
123
+ {{ userInitial }}
124
+ </div>
125
+ </button>
126
+
127
+ <div
128
+ class="hs-dropdown-menu hs-dropdown-open:opacity-100 w-60 transition-[opacity,margin] opacity-0 hidden z-20 bg-dropdown border border-dropdown-line rounded-xl shadow-xl"
129
+ role="menu"
130
+ >
131
+ <div class="p-1 border-b border-dropdown-divider">
132
+ <div class="py-2 px-3 flex items-center gap-x-3">
133
+ <div class="size-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-bold shrink-0">
134
+ {{ userInitial }}
135
+ </div>
136
+ <div class="grow min-w-0">
137
+ <p class="text-sm font-semibold text-foreground truncate">
138
+ {{ (authStore.user as any)?.name ?? (authStore.user as any)?.email }}
139
+ </p>
140
+ <p v-if="(authStore.user as any)?.name && (authStore.user as any)?.email" class="text-xs text-muted-foreground truncate">
141
+ {{ (authStore.user as any).email }}
142
+ </p>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ <div class="p-1">
147
+ <button
148
+ type="button"
149
+ class="w-full flex items-center gap-x-3 py-2 px-3 rounded-lg text-sm text-dropdown-item-foreground hover:bg-dropdown-item-hover focus:outline-hidden"
150
+ @click="logout"
151
+ >
152
+ <icons.IconLogout class="shrink-0 size-4" />
153
+ Cerrar sesión
154
+ </button>
155
+ </div>
156
+ </div>
157
+ </div>
158
+
159
+ </div>
160
+ </div>
161
+ </div>
162
+
163
+ <!-- Secondary navbar (menu items + app items) -->
164
+ <nav v-if="menuItems.length > 0 || menuApps.length > 0" class="bg-navbar border-b border-navbar-line">
165
+ <div class="max-w-[85rem] w-full mx-auto px-4 sm:px-6 lg:px-8">
166
+ <div
167
+ class="overflow-hidden transition-all duration-300 lg:block"
168
+ :class="mobileOpen ? 'block' : 'hidden'"
169
+ >
170
+ <div class="flex flex-col lg:flex-row lg:items-center lg:gap-x-1 py-1.5 space-y-0.5 lg:space-y-0">
171
+ <NuxtLink
172
+ v-for="item in menuItems"
173
+ :key="item.route"
174
+ :to="item.route"
175
+ class="py-1.5 px-2.5 flex items-center gap-x-2 text-[13px] text-nowrap text-navbar-nav-foreground rounded-lg hover:bg-navbar-nav-hover focus:outline-hidden"
176
+ :class="{ 'bg-navbar-nav-active': matchPattern(item.pattern, item.route) }"
177
+ >
178
+ <component :is="(icons as any)[item.icon]" class="shrink-0 size-4" />
179
+ {{ item.label }}
180
+ </NuxtLink>
181
+ <NuxtLink
182
+ v-for="item in menuApps"
183
+ :key="item.route"
184
+ :to="item.route"
185
+ class="py-1.5 px-2.5 flex items-center gap-x-2 text-[13px] text-nowrap text-navbar-nav-foreground rounded-lg hover:bg-navbar-nav-hover focus:outline-hidden"
186
+ :class="{ 'bg-navbar-nav-active': matchPattern(item.pattern, item.route + '/*') }"
187
+ >
188
+ <component :is="(icons as any)[item.icon]" class="shrink-0 size-4" />
189
+ {{ item.label }}
190
+ </NuxtLink>
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </nav>
195
+
196
+ </header>
197
+
198
+ <!-- Main content -->
199
+ <main
200
+ class="max-w-[85rem] w-full mx-auto px-4 sm:px-6 lg:px-8 py-4 transition-[padding]"
201
+ :class="docked.length ? 'pb-20' : ''"
202
+ >
203
+ <slot />
204
+ </main>
205
+
206
+ <!-- Preview dock (previews minimizados de tablas) -->
207
+ <AppPreviewDock />
208
+ </div>
209
+ </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -0,0 +1,145 @@
1
+ import type { ColorOption, ColorScale, DarkTone } from '../app.config'
2
+
3
+ const SCALE_LEVELS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const
4
+
5
+ /**
6
+ * Aplica las CSS vars de theming desde `appConfig.innertia`:
7
+ * - `colors.primary` y `colors.secondary` → --primary-{50..950}, --secondary-{50..950}
8
+ * - `darkTone` → reemplaza el neutral default en modo dark (backgrounds, borders, surfaces)
9
+ *
10
+ * Funciona en SSR (inyecta <style> en head) y cliente.
11
+ *
12
+ * colors acepta:
13
+ * - Nombre Tailwind: 'violet' → mapea a var(--color-violet-50..950)
14
+ * - Scale custom: { 50: '#f5f3ff', ..., 950: '#2e1065' }
15
+ *
16
+ * darkTone acepta:
17
+ * 'neutral' | 'slate' | 'gray' | 'zinc' | 'stone'
18
+ */
19
+ export default defineNuxtPlugin(() => {
20
+ const appConfig = useAppConfig()
21
+ const colors = appConfig.innertia?.colors as { primary?: ColorOption; secondary?: ColorOption } | undefined
22
+ const darkTone = appConfig.innertia?.darkTone as DarkTone | undefined
23
+
24
+ const css: string[] = []
25
+
26
+ if (colors) css.push(buildColorCSS(colors))
27
+ // Solo aplicar override si el producto eligió algo distinto del default
28
+ if (darkTone && darkTone !== 'neutral') css.push(buildDarkToneCSS(darkTone))
29
+
30
+ if (css.length === 0) return
31
+
32
+ useHead({
33
+ style: [
34
+ {
35
+ id: 'innertia-colors',
36
+ children: css.join('\n'),
37
+ },
38
+ ],
39
+ })
40
+ })
41
+
42
+ function buildColorCSS(colors: { primary?: ColorOption; secondary?: ColorOption }): string {
43
+ const lines: string[] = [':root {']
44
+
45
+ if (colors.primary !== undefined) {
46
+ lines.push(...buildRoleVars('primary', colors.primary))
47
+ }
48
+ if (colors.secondary !== undefined) {
49
+ lines.push(...buildRoleVars('secondary', colors.secondary))
50
+ }
51
+
52
+ lines.push('}')
53
+ return lines.join('\n')
54
+ }
55
+
56
+ /**
57
+ * Genera overrides para modo dark reemplazando el `neutral` default por el tono elegido.
58
+ * Cubre backgrounds, foregrounds, borders, layers, surfaces y cards — todos los lugares
59
+ * donde el theme.css base usa `--color-neutral-*` en `.dark`.
60
+ *
61
+ * Por especificidad, `.dark` aquí gana sobre `.dark` en theme.css porque se inyecta
62
+ * después en el head (last-declared wins).
63
+ */
64
+ function buildDarkToneCSS(tone: DarkTone): string {
65
+ return `.dark {
66
+ --background: var(--color-${tone}-800);
67
+ --background-1: var(--color-${tone}-900);
68
+ --background-2: var(--color-${tone}-900);
69
+ --background-plain: var(--color-${tone}-950);
70
+ --foreground: var(--color-${tone}-200);
71
+ --foreground-inverse: var(--color-white);
72
+ --inverse: var(--color-${tone}-950);
73
+ --border: var(--color-${tone}-700);
74
+ --border-line-1: var(--color-${tone}-800);
75
+ --border-line-2: var(--color-${tone}-700);
76
+ --border-line-3: var(--color-${tone}-600);
77
+ --layer: var(--color-${tone}-800);
78
+ --layer-line: var(--color-${tone}-700);
79
+ --layer-hover: var(--color-${tone}-700);
80
+ --layer-focus: var(--color-${tone}-700);
81
+ --layer-active: var(--color-${tone}-700);
82
+ --surface: var(--color-${tone}-700);
83
+ --surface-1: var(--color-${tone}-600);
84
+ --surface-2: var(--color-${tone}-500);
85
+ --surface-3: var(--color-${tone}-600);
86
+ --muted: var(--color-${tone}-700);
87
+ --muted-foreground: var(--color-${tone}-400);
88
+ --muted-foreground-1: var(--color-${tone}-300);
89
+ --muted-hover: var(--color-${tone}-600);
90
+ --card: var(--color-${tone}-800);
91
+ --card-line: var(--color-${tone}-700);
92
+ --card-divider: var(--color-${tone}-700);
93
+ --card-header: var(--color-${tone}-700);
94
+ --card-footer: var(--color-${tone}-700);
95
+ --dropdown: var(--color-${tone}-800);
96
+ --dropdown-line: var(--color-${tone}-700);
97
+ --dropdown-divider: var(--color-${tone}-700);
98
+ --dropdown-item-hover: var(--color-${tone}-700);
99
+ --dropdown-item-foreground: var(--color-${tone}-200);
100
+ --tooltip: var(--color-${tone}-100);
101
+ --tooltip-foreground: var(--color-${tone}-900);
102
+ --navbar: var(--color-${tone}-900);
103
+ --navbar-line: var(--color-${tone}-800);
104
+ --navbar-divider: var(--color-${tone}-800);
105
+ --navbar-nav-foreground: var(--color-${tone}-300);
106
+ --navbar-nav-hover: var(--color-${tone}-800);
107
+ --navbar-nav-active: var(--color-${tone}-700);
108
+ --sidebar: var(--color-${tone}-900);
109
+ --sidebar-line: var(--color-${tone}-800);
110
+ }`
111
+ }
112
+
113
+ function buildRoleVars(role: 'primary' | 'secondary', value: ColorOption): string[] {
114
+ const out: string[] = []
115
+
116
+ if (typeof value === 'string') {
117
+ // Tailwind color name → referenciar las vars que Tailwind ya define
118
+ const tone = value
119
+ for (const lvl of SCALE_LEVELS) {
120
+ out.push(` --${role}-${lvl}: var(--color-${tone}-${lvl});`)
121
+ }
122
+ // Semantic tokens — niveles default 600 (base), 700 (hover/focus/active)
123
+ out.push(` --${role}: var(--color-${tone}-600);`)
124
+ out.push(` --${role}-hover: var(--color-${tone}-700);`)
125
+ out.push(` --${role}-focus: var(--color-${tone}-700);`)
126
+ out.push(` --${role}-active: var(--color-${tone}-800);`)
127
+ out.push(` --${role}-checked: var(--color-${tone}-600);`)
128
+ out.push(` --${role}-foreground: #ffffff;`)
129
+ } else {
130
+ // Scale custom — usar los valores explícitos
131
+ const scale = value as ColorScale
132
+ for (const lvl of SCALE_LEVELS) {
133
+ const hex = scale[lvl]
134
+ if (hex) out.push(` --${role}-${lvl}: ${hex};`)
135
+ }
136
+ if (scale[600]) out.push(` --${role}: ${scale[600]};`)
137
+ if (scale[700]) out.push(` --${role}-hover: ${scale[700]};`)
138
+ if (scale[700]) out.push(` --${role}-focus: ${scale[700]};`)
139
+ if (scale[800]) out.push(` --${role}-active: ${scale[800]};`)
140
+ if (scale[600]) out.push(` --${role}-checked: ${scale[600]};`)
141
+ out.push(` --${role}-foreground: #ffffff;`)
142
+ }
143
+
144
+ return out
145
+ }