@edc-motor/ui 0.2.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/LICENSE +674 -0
- package/README.md +45 -0
- package/package.json +47 -0
- package/scss/_base.scss +33 -0
- package/scss/_forms.scss +33 -0
- package/scss/_shared-components.scss +1 -0
- package/scss/_theme.scss +74 -0
- package/scss/_tokens.scss +114 -0
- package/scss/components/_base-button.scss +113 -0
- package/scss/components/_blocks.scss +211 -0
- package/scss/components/_breadcrumbs.scss +31 -0
- package/scss/components/_checkbox.scss +42 -0
- package/scss/components/_chip.scss +23 -0
- package/scss/components/_confirm.scss +3 -0
- package/scss/components/_edit-modal.scss +11 -0
- package/scss/components/_font-upload.scss +91 -0
- package/scss/components/_form-field.scss +85 -0
- package/scss/components/_icon-button.scss +23 -0
- package/scss/components/_image-upload.scss +85 -0
- package/scss/components/_index.scss +24 -0
- package/scss/components/_locale-selector.scss +89 -0
- package/scss/components/_modal.scss +68 -0
- package/scss/components/_motor-badge.scss +21 -0
- package/scss/components/_numeric-input.scss +53 -0
- package/scss/components/_page-background.scss +22 -0
- package/scss/components/_palette-color-picker.scss +69 -0
- package/scss/components/_rich-text.scss +144 -0
- package/scss/components/_search-select.scss +138 -0
- package/scss/components/_tabs.scss +120 -0
- package/scss/components/_theme-selector.scss +34 -0
- package/scss/components/_toast-container.scss +16 -0
- package/scss/components/_toast.scss +25 -0
- package/scss/components/_translatable-input.scss +73 -0
- package/src/blocks/BlockCta.vue +32 -0
- package/src/blocks/BlockFaq.vue +25 -0
- package/src/blocks/BlockHeader.vue +13 -0
- package/src/blocks/BlockIndex.vue +20 -0
- package/src/blocks/BlockQuote.vue +16 -0
- package/src/blocks/BlockShell.vue +30 -0
- package/src/blocks/BlockText.vue +20 -0
- package/src/blocks/BlockTextCard.vue +23 -0
- package/src/blocks/PageBackground.vue +15 -0
- package/src/blocks/index.ts +25 -0
- package/src/components/AppBreadcrumbs.vue +44 -0
- package/src/components/BaseButton.vue +24 -0
- package/src/components/BaseCheckbox.vue +37 -0
- package/src/components/BaseInput.vue +53 -0
- package/src/components/BaseModal.vue +98 -0
- package/src/components/BaseSelect.vue +53 -0
- package/src/components/BaseTabs.vue +30 -0
- package/src/components/BaseTextarea.vue +45 -0
- package/src/components/BaseToast.vue +31 -0
- package/src/components/ConfirmDialog.vue +35 -0
- package/src/components/EditModal.vue +57 -0
- package/src/components/FontUpload.vue +123 -0
- package/src/components/IconButton.vue +24 -0
- package/src/components/ImageUpload.vue +142 -0
- package/src/components/LocaleSelector.vue +59 -0
- package/src/components/MotorBadge.vue +17 -0
- package/src/components/NumericInput.vue +115 -0
- package/src/components/PaletteColorPicker.vue +94 -0
- package/src/components/RichTextInput.vue +273 -0
- package/src/components/SearchSelect.vue +172 -0
- package/src/components/ThemeSelector.vue +28 -0
- package/src/components/ToastContainer.vue +23 -0
- package/src/components/TranslatableImage.vue +120 -0
- package/src/components/TranslatableInput.vue +135 -0
- package/src/composables/useConfirm.ts +49 -0
- package/src/composables/useHead.ts +54 -0
- package/src/composables/useTheme.ts +50 -0
- package/src/composables/useToast.ts +26 -0
- package/src/index.ts +39 -0
- package/src/lib/createApi.ts +42 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted, onBeforeUnmount, defineAsyncComponent } from 'vue'
|
|
3
|
+
import { ChevronDown } from '@lucide/vue'
|
|
4
|
+
import type { RichTextLabels } from './RichTextInput.vue'
|
|
5
|
+
|
|
6
|
+
// Editor WYSIWYG cargado en diferido: TipTap solo se descarga si algún campo
|
|
7
|
+
// traducible usa type="wysiwyg".
|
|
8
|
+
const RichTextInput = defineAsyncComponent(() => import('./RichTextInput.vue'))
|
|
9
|
+
|
|
10
|
+
// Campo traducible (portado de kontuan): usa el estilo `.form-field` y un
|
|
11
|
+
// selector desplegable de locale con contador de rellenados.
|
|
12
|
+
interface Locale {
|
|
13
|
+
code: string
|
|
14
|
+
name: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const props = withDefaults(
|
|
18
|
+
defineProps<{
|
|
19
|
+
modelValue?: Record<string, string>
|
|
20
|
+
locales: Locale[]
|
|
21
|
+
label?: string
|
|
22
|
+
type?: 'text' | 'textarea' | 'wysiwyg'
|
|
23
|
+
placeholder?: string
|
|
24
|
+
rows?: number
|
|
25
|
+
required?: boolean
|
|
26
|
+
error?: string
|
|
27
|
+
id?: string
|
|
28
|
+
/** Iconos del juego para el selector del editor (solo type="wysiwyg"). */
|
|
29
|
+
icons?: { name: string; url: string }[]
|
|
30
|
+
/** Textos de la barra del editor (solo type="wysiwyg"), DC-29. */
|
|
31
|
+
richLabels?: Partial<RichTextLabels>
|
|
32
|
+
}>(),
|
|
33
|
+
{
|
|
34
|
+
modelValue: () => ({}),
|
|
35
|
+
type: 'text',
|
|
36
|
+
rows: 4,
|
|
37
|
+
required: false,
|
|
38
|
+
icons: () => [],
|
|
39
|
+
richLabels: () => ({}),
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const emit = defineEmits<{ 'update:modelValue': [Record<string, string>] }>()
|
|
44
|
+
|
|
45
|
+
const codes = computed(() => props.locales.map((l) => l.code))
|
|
46
|
+
const active = ref(codes.value[0] ?? 'es')
|
|
47
|
+
const open = ref(false)
|
|
48
|
+
const dropdownRef = ref<HTMLElement>()
|
|
49
|
+
const inputId = props.id || `translatable-${Math.random().toString(36).slice(2, 9)}`
|
|
50
|
+
|
|
51
|
+
const currentValue = computed(() => props.modelValue?.[active.value] || '')
|
|
52
|
+
const filledCount = computed(() => codes.value.filter((c) => !!props.modelValue?.[c]).length)
|
|
53
|
+
const hasContent = (code: string) => !!props.modelValue?.[code]
|
|
54
|
+
|
|
55
|
+
function selectLocale(code: string) {
|
|
56
|
+
active.value = code
|
|
57
|
+
open.value = false
|
|
58
|
+
}
|
|
59
|
+
function update(value: string) {
|
|
60
|
+
emit('update:modelValue', { ...props.modelValue, [active.value]: value })
|
|
61
|
+
}
|
|
62
|
+
function onClickOutside(e: MouseEvent) {
|
|
63
|
+
if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) open.value = false
|
|
64
|
+
}
|
|
65
|
+
onMounted(() => document.addEventListener('click', onClickOutside))
|
|
66
|
+
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside))
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<div class="form-field" :class="{ 'form-field--error': error }">
|
|
71
|
+
<div class="translatable-header">
|
|
72
|
+
<label v-if="label" :for="`${inputId}-${active}`" class="form-field__label">
|
|
73
|
+
{{ label }}<span v-if="required" class="form-field__required">*</span>
|
|
74
|
+
</label>
|
|
75
|
+
<div ref="dropdownRef" class="translatable-selector">
|
|
76
|
+
<button type="button" class="translatable-trigger" @click="open = !open">
|
|
77
|
+
<span class="translatable-trigger__code">{{ active.toUpperCase() }}</span>
|
|
78
|
+
<span class="translatable-trigger__count">{{ filledCount }}/{{ codes.length }}</span>
|
|
79
|
+
<ChevronDown class="translatable-trigger__chevron" :size="12" />
|
|
80
|
+
</button>
|
|
81
|
+
<Transition name="dropdown">
|
|
82
|
+
<div v-if="open" class="translatable-dropdown">
|
|
83
|
+
<button
|
|
84
|
+
v-for="loc in locales"
|
|
85
|
+
:key="loc.code"
|
|
86
|
+
type="button"
|
|
87
|
+
:class="[
|
|
88
|
+
'translatable-option',
|
|
89
|
+
{
|
|
90
|
+
'translatable-option--active': loc.code === active,
|
|
91
|
+
'translatable-option--empty': !hasContent(loc.code),
|
|
92
|
+
},
|
|
93
|
+
]"
|
|
94
|
+
@click="selectLocale(loc.code)"
|
|
95
|
+
>
|
|
96
|
+
<span class="translatable-option__code">{{ loc.code.toUpperCase() }}</span>
|
|
97
|
+
<span class="translatable-option__label">{{ loc.name }}</span>
|
|
98
|
+
<span v-if="hasContent(loc.code)" class="translatable-option__filled" />
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
</Transition>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<RichTextInput
|
|
106
|
+
v-if="type === 'wysiwyg'"
|
|
107
|
+
:key="active"
|
|
108
|
+
:model-value="currentValue"
|
|
109
|
+
:placeholder="placeholder"
|
|
110
|
+
:icons="icons"
|
|
111
|
+
:labels="richLabels"
|
|
112
|
+
@update:model-value="update"
|
|
113
|
+
/>
|
|
114
|
+
<textarea
|
|
115
|
+
v-else-if="type === 'textarea'"
|
|
116
|
+
:id="`${inputId}-${active}`"
|
|
117
|
+
:value="currentValue"
|
|
118
|
+
:placeholder="placeholder"
|
|
119
|
+
:rows="rows"
|
|
120
|
+
class="form-field__textarea"
|
|
121
|
+
@input="update(($event.target as HTMLTextAreaElement).value)"
|
|
122
|
+
/>
|
|
123
|
+
<input
|
|
124
|
+
v-else
|
|
125
|
+
:id="`${inputId}-${active}`"
|
|
126
|
+
type="text"
|
|
127
|
+
:value="currentValue"
|
|
128
|
+
:placeholder="placeholder"
|
|
129
|
+
class="form-field__input"
|
|
130
|
+
@input="update(($event.target as HTMLInputElement).value)"
|
|
131
|
+
/>
|
|
132
|
+
|
|
133
|
+
<p v-if="error" class="form-field__error">{{ error }}</p>
|
|
134
|
+
</div>
|
|
135
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { reactive } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface ConfirmOptions {
|
|
4
|
+
title?: string
|
|
5
|
+
message: string
|
|
6
|
+
confirmLabel?: string
|
|
7
|
+
cancelLabel?: string
|
|
8
|
+
variant?: 'primary' | 'danger' | 'success'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ConfirmState extends Required<Omit<ConfirmOptions, 'message'>> {
|
|
12
|
+
open: boolean
|
|
13
|
+
message: string
|
|
14
|
+
resolver: ((value: boolean) => void) | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const state = reactive<ConfirmState>({
|
|
18
|
+
open: false,
|
|
19
|
+
title: '',
|
|
20
|
+
message: '',
|
|
21
|
+
confirmLabel: 'Confirmar',
|
|
22
|
+
cancelLabel: 'Cancelar',
|
|
23
|
+
variant: 'primary',
|
|
24
|
+
resolver: null,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export function useConfirm() {
|
|
28
|
+
/** Muestra un diálogo de confirmación y resuelve a true/false. */
|
|
29
|
+
function confirm(options: ConfirmOptions): Promise<boolean> {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
// Si había otro diálogo pendiente, se resuelve como cancelado para no
|
|
32
|
+
// dejar su promesa colgada para siempre.
|
|
33
|
+
state.resolver?.(false)
|
|
34
|
+
state.open = true
|
|
35
|
+
state.title = options.title ?? ''
|
|
36
|
+
state.message = options.message
|
|
37
|
+
state.confirmLabel = options.confirmLabel ?? 'Confirmar'
|
|
38
|
+
state.cancelLabel = options.cancelLabel ?? 'Cancelar'
|
|
39
|
+
state.variant = options.variant ?? 'primary'
|
|
40
|
+
state.resolver = resolve
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
function resolve(value: boolean) {
|
|
44
|
+
state.open = false
|
|
45
|
+
state.resolver?.(value)
|
|
46
|
+
state.resolver = null
|
|
47
|
+
}
|
|
48
|
+
return { state, confirm, resolve }
|
|
49
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SEO de la SPA pública (doc 10, DC-18): fija <title>, meta description,
|
|
2
|
+
// canonical y alternates hreflang de la ruta actual manipulando el <head>.
|
|
3
|
+
// Sin dependencias: cada vista lo llama al cargar sus datos (y el prerender
|
|
4
|
+
// captura el head ya resuelto).
|
|
5
|
+
|
|
6
|
+
export interface HeadInput {
|
|
7
|
+
title?: string
|
|
8
|
+
description?: string
|
|
9
|
+
/** URL canónica absoluta de la ruta en el locale activo. */
|
|
10
|
+
canonical?: string
|
|
11
|
+
/** URL absoluta por locale (hreflang). */
|
|
12
|
+
alternates?: Record<string, string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function upsertMeta(name: string, content: string | undefined) {
|
|
16
|
+
let tag = document.head.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
|
|
17
|
+
if (!content) {
|
|
18
|
+
tag?.remove()
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
if (!tag) {
|
|
22
|
+
tag = document.createElement('meta')
|
|
23
|
+
tag.setAttribute('name', name)
|
|
24
|
+
document.head.appendChild(tag)
|
|
25
|
+
}
|
|
26
|
+
tag.setAttribute('content', content)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function upsertLink(rel: string, href: string, hreflang?: string) {
|
|
30
|
+
const selector = hreflang ? `link[rel="${rel}"][hreflang="${hreflang}"]` : `link[rel="${rel}"]`
|
|
31
|
+
let tag = document.head.querySelector<HTMLLinkElement>(selector)
|
|
32
|
+
if (!tag) {
|
|
33
|
+
tag = document.createElement('link')
|
|
34
|
+
tag.setAttribute('rel', rel)
|
|
35
|
+
if (hreflang) tag.setAttribute('hreflang', hreflang)
|
|
36
|
+
document.head.appendChild(tag)
|
|
37
|
+
}
|
|
38
|
+
tag.setAttribute('href', href)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useHead(input: HeadInput): void {
|
|
42
|
+
if (input.title !== undefined) document.title = input.title
|
|
43
|
+
upsertMeta('description', input.description)
|
|
44
|
+
|
|
45
|
+
// Canonical y alternates se reponen enteros en cada ruta (los sobrantes
|
|
46
|
+
// de la ruta anterior se retiran).
|
|
47
|
+
document.head
|
|
48
|
+
.querySelectorAll('link[rel="canonical"], link[rel="alternate"][hreflang]')
|
|
49
|
+
.forEach((tag) => tag.remove())
|
|
50
|
+
if (input.canonical) upsertLink('canonical', input.canonical)
|
|
51
|
+
for (const [locale, href] of Object.entries(input.alternates ?? {})) {
|
|
52
|
+
upsertLink('alternate', href, locale)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
|
2
|
+
|
|
3
|
+
// Modo de tema. 'system' sigue la preferencia del SO. Copiado de kontuan.
|
|
4
|
+
export type ThemeMode = 'light' | 'dark' | 'system'
|
|
5
|
+
|
|
6
|
+
function storedTheme(): ThemeMode {
|
|
7
|
+
// Guarda ante entornos sin window (prerender en build, DC-18).
|
|
8
|
+
if (typeof window === 'undefined') return 'dark'
|
|
9
|
+
return (localStorage.getItem('theme') as ThemeMode) || 'dark'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const themeMode = ref<ThemeMode>(storedTheme())
|
|
13
|
+
|
|
14
|
+
function getSystemTheme(): 'light' | 'dark' {
|
|
15
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function applyTheme(mode: ThemeMode) {
|
|
19
|
+
const resolved = mode === 'system' ? getSystemTheme() : mode
|
|
20
|
+
document.documentElement.setAttribute('data-theme', resolved)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useTheme() {
|
|
24
|
+
function setTheme(mode: ThemeMode) {
|
|
25
|
+
themeMode.value = mode
|
|
26
|
+
localStorage.setItem('theme', mode)
|
|
27
|
+
applyTheme(mode)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
watch(themeMode, (mode) => applyTheme(mode))
|
|
31
|
+
|
|
32
|
+
const media =
|
|
33
|
+
typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)') : null
|
|
34
|
+
const onSystemChange = () => {
|
|
35
|
+
if (themeMode.value === 'system') {
|
|
36
|
+
applyTheme('system')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
onMounted(() => {
|
|
41
|
+
applyTheme(themeMode.value)
|
|
42
|
+
media?.addEventListener('change', onSystemChange)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
onUnmounted(() => {
|
|
46
|
+
media?.removeEventListener('change', onSystemChange)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return { themeMode, setTheme }
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { reactive } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface Toast {
|
|
4
|
+
id: number
|
|
5
|
+
variant: 'success' | 'warning' | 'danger' | 'info'
|
|
6
|
+
message: string
|
|
7
|
+
duration: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const toasts = reactive<Toast[]>([])
|
|
11
|
+
let nextId = 0
|
|
12
|
+
|
|
13
|
+
export function useToast() {
|
|
14
|
+
function add(message: string, variant: Toast['variant'] = 'info', duration = 4000) {
|
|
15
|
+
toasts.push({ id: nextId++, variant, message, duration })
|
|
16
|
+
}
|
|
17
|
+
function remove(id: number) {
|
|
18
|
+
const i = toasts.findIndex((t) => t.id === id)
|
|
19
|
+
if (i > -1) toasts.splice(i, 1)
|
|
20
|
+
}
|
|
21
|
+
const success = (m: string) => add(m, 'success')
|
|
22
|
+
const warning = (m: string) => add(m, 'warning')
|
|
23
|
+
const danger = (m: string) => add(m, 'danger')
|
|
24
|
+
const info = (m: string) => add(m, 'info')
|
|
25
|
+
return { toasts, add, remove, success, warning, danger, info }
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @edc-motor/ui — librería de componentes base + tokens + utilidades del motor.
|
|
2
|
+
|
|
3
|
+
import { defineAsyncComponent } from 'vue'
|
|
4
|
+
|
|
5
|
+
export { default as BaseButton } from './components/BaseButton.vue'
|
|
6
|
+
export { default as IconButton } from './components/IconButton.vue'
|
|
7
|
+
export { default as MotorBadge } from './components/MotorBadge.vue'
|
|
8
|
+
export { default as BaseInput } from './components/BaseInput.vue'
|
|
9
|
+
export { default as BaseTextarea } from './components/BaseTextarea.vue'
|
|
10
|
+
export { default as BaseSelect, type SelectOption } from './components/BaseSelect.vue'
|
|
11
|
+
export { default as SearchSelect, type SearchSelectOption } from './components/SearchSelect.vue'
|
|
12
|
+
export { default as BaseCheckbox } from './components/BaseCheckbox.vue'
|
|
13
|
+
export { default as NumericInput } from './components/NumericInput.vue'
|
|
14
|
+
export { default as PaletteColorPicker } from './components/PaletteColorPicker.vue'
|
|
15
|
+
export { default as TranslatableInput } from './components/TranslatableInput.vue'
|
|
16
|
+
export { default as TranslatableImage } from './components/TranslatableImage.vue'
|
|
17
|
+
// El WYSIWYG carga DIFERIDO (TipTap pesa ~450 KB): así la web pública no lo
|
|
18
|
+
// arrastra a su bundle y el admin lo trocea en su propio chunk.
|
|
19
|
+
export const RichTextInput = defineAsyncComponent(() => import('./components/RichTextInput.vue'))
|
|
20
|
+
export type { RichIcon, RichTextLabels } from './components/RichTextInput.vue'
|
|
21
|
+
export { default as ImageUpload } from './components/ImageUpload.vue'
|
|
22
|
+
export { default as FontUpload } from './components/FontUpload.vue'
|
|
23
|
+
export { default as BaseModal } from './components/BaseModal.vue'
|
|
24
|
+
export { default as EditModal } from './components/EditModal.vue'
|
|
25
|
+
export { default as BaseTabs } from './components/BaseTabs.vue'
|
|
26
|
+
export { default as BaseToast } from './components/BaseToast.vue'
|
|
27
|
+
export { default as ToastContainer } from './components/ToastContainer.vue'
|
|
28
|
+
export { default as ConfirmDialog } from './components/ConfirmDialog.vue'
|
|
29
|
+
export { default as ThemeSelector } from './components/ThemeSelector.vue'
|
|
30
|
+
export { default as LocaleSelector } from './components/LocaleSelector.vue'
|
|
31
|
+
export { default as AppBreadcrumbs, type Crumb } from './components/AppBreadcrumbs.vue'
|
|
32
|
+
export { createApi, type CreateApiOptions } from './lib/createApi'
|
|
33
|
+
export { useToast, type Toast } from './composables/useToast'
|
|
34
|
+
export { useConfirm, type ConfirmOptions } from './composables/useConfirm'
|
|
35
|
+
export { useTheme, type ThemeMode } from './composables/useTheme'
|
|
36
|
+
export { useHead, type HeadInput } from './composables/useHead'
|
|
37
|
+
|
|
38
|
+
// Bloques de presentación del CRM (doc 03).
|
|
39
|
+
export * from './blocks'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import axios, { type AxiosInstance } from 'axios'
|
|
2
|
+
|
|
3
|
+
export interface CreateApiOptions {
|
|
4
|
+
/** URL base de la API (p. ej. http://localhost:8010/api). */
|
|
5
|
+
baseURL: string
|
|
6
|
+
/** Clave de localStorage donde se guarda el token. */
|
|
7
|
+
tokenKey: string
|
|
8
|
+
/** Se invoca cuando la API responde 401 (token inválido/expirado). */
|
|
9
|
+
onUnauthorized?: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Crea una instancia de axios con el token Bearer inyectado desde localStorage
|
|
14
|
+
* y manejo de 401. Patrón compartido entre admin y app (estilo kontuan).
|
|
15
|
+
*/
|
|
16
|
+
export function createApi(options: CreateApiOptions): AxiosInstance {
|
|
17
|
+
const api = axios.create({
|
|
18
|
+
baseURL: options.baseURL,
|
|
19
|
+
headers: { Accept: 'application/json' },
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
api.interceptors.request.use((config) => {
|
|
23
|
+
const token = localStorage.getItem(options.tokenKey)
|
|
24
|
+
if (token) {
|
|
25
|
+
config.headers.Authorization = `Bearer ${token}`
|
|
26
|
+
}
|
|
27
|
+
return config
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
api.interceptors.response.use(
|
|
31
|
+
(response) => response,
|
|
32
|
+
(error) => {
|
|
33
|
+
if (error.response?.status === 401) {
|
|
34
|
+
localStorage.removeItem(options.tokenKey)
|
|
35
|
+
options.onUnauthorized?.()
|
|
36
|
+
}
|
|
37
|
+
return Promise.reject(error)
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return api
|
|
42
|
+
}
|