@innertia-solutions/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ <template>
2
+ <div
3
+ class="max-w-xs relative bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl shadow-lg p-4 pr-10 flex items-start overflow-hidden"
4
+ :class="{
5
+ 'border-green-200': toast.severity === 'success',
6
+ 'border-red-200': toast.severity === 'danger',
7
+ 'border-yellow-200': toast.severity === 'warning',
8
+ 'border-blue-200': toast.severity === 'info',
9
+ }"
10
+ role="alert"
11
+ >
12
+ <div class="mr-3 mt-1">
13
+ <i v-if="toast.icon" :class="toast.icon + ' text-gray-400 text-xl'" />
14
+ </div>
15
+ <div class="flex-1 mt-1">
16
+ <h3 v-if="toast.title" class="font-semibold text-sm text-gray-800">
17
+ {{ toast.title }}
18
+ </h3>
19
+ <div class="text-sm dark:text-white text-gray-600" v-html="toast.message"></div>
20
+ </div>
21
+ <button
22
+ class="ml-3 text-gray-400 hover:text-gray-700 absolute top-2 right-2"
23
+ @click="$emit('close')"
24
+ >
25
+ <span class="sr-only">Cerrar</span>
26
+ <svg
27
+ class="size-4"
28
+ viewBox="0 0 24 24"
29
+ fill="none"
30
+ stroke="currentColor"
31
+ stroke-width="2"
32
+ stroke-linecap="round"
33
+ stroke-linejoin="round"
34
+ >
35
+ <path d="M18 6 6 18" />
36
+ <path d="m6 6 12 12" />
37
+ </svg>
38
+ </button>
39
+
40
+ <!-- Barra de progreso temporal -->
41
+ <div
42
+ v-if="toast.duration && toast.duration > 0"
43
+ class="absolute bottom-0 left-0 w-full h-1 bg-gray-200 dark:bg-gray-700"
44
+ >
45
+ <div
46
+ class="h-full bg-gradient-to-r"
47
+ :class="{
48
+ 'from-green-400 to-green-600': toast.severity === 'success',
49
+ 'from-red-400 to-red-600': toast.severity === 'danger',
50
+ 'from-yellow-400 to-yellow-600': toast.severity === 'warning',
51
+ 'from-blue-400 to-blue-600': toast.severity === 'info',
52
+ }"
53
+ :style="{ width: progressWidth + '%' }"
54
+ ></div>
55
+ </div>
56
+ </div>
57
+ </template>
58
+
59
+ <script setup>
60
+ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
61
+
62
+ const props = defineProps({ toast: Object });
63
+
64
+ const progressWidth = ref(100)
65
+ const startTime = ref(null)
66
+ let animationFrame = null
67
+
68
+ // Computed para detectar cambios en duration
69
+ const duration = computed(() => props.toast?.duration || 0)
70
+
71
+ // Función para actualizar el progreso
72
+ const updateProgress = () => {
73
+ if (!startTime.value || duration.value <= 0) {
74
+ progressWidth.value = 100
75
+ return
76
+ }
77
+
78
+ const elapsed = Date.now() - startTime.value
79
+ const remaining = Math.max(0, duration.value - elapsed)
80
+ progressWidth.value = (remaining / duration.value) * 100
81
+
82
+ if (remaining > 0) {
83
+ animationFrame = requestAnimationFrame(updateProgress)
84
+ }
85
+ }
86
+
87
+ // Watch para reiniciar cuando cambie el duration
88
+ watch(duration, (newDuration) => {
89
+ if (animationFrame) {
90
+ cancelAnimationFrame(animationFrame)
91
+ }
92
+
93
+ if (newDuration && newDuration > 0) {
94
+ startTime.value = Date.now()
95
+ updateProgress()
96
+ } else {
97
+ progressWidth.value = 100
98
+ }
99
+ }, { immediate: true })
100
+
101
+ onMounted(() => {
102
+ if (duration.value > 0) {
103
+ startTime.value = Date.now()
104
+ updateProgress()
105
+ }
106
+ })
107
+
108
+ onUnmounted(() => {
109
+ if (animationFrame) {
110
+ cancelAnimationFrame(animationFrame)
111
+ }
112
+ })
113
+ </script>
@@ -0,0 +1,45 @@
1
+ <template>
2
+ <div
3
+ class="max-w-xs bg-white border border-gray-200 rounded-xl shadow-lg p-4 flex"
4
+ role="alert"
5
+ >
6
+ <div class="shrink-0">
7
+ <svg
8
+ class="size-5 text-gray-600 mt-1"
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ width="24"
11
+ height="24"
12
+ viewBox="0 0 24 24"
13
+ fill="none"
14
+ stroke="currentColor"
15
+ stroke-width="2"
16
+ stroke-linecap="round"
17
+ stroke-linejoin="round"
18
+ >
19
+ <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
20
+ <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
21
+ </svg>
22
+ </div>
23
+ <div class="ms-4">
24
+ <h3 v-if="toast.title" class="text-gray-800 font-semibold">
25
+ {{ toast.title }}
26
+ </h3>
27
+ <div class="mt-1 text-sm text-gray-600" v-html="toast.message"></div>
28
+ <div v-if="toast.actions" class="mt-4">
29
+ <div class="flex gap-x-3">
30
+ <button
31
+ v-for="(action, i) in toast.actions"
32
+ :key="i"
33
+ @click="action.onClick"
34
+ class="text-blue-600 decoration-2 hover:underline font-medium text-sm focus:outline-hidden focus:underline"
35
+ >
36
+ {{ action.label }}
37
+ </button>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </template>
43
+ <script setup>
44
+ defineProps({ toast: Object });
45
+ </script>
@@ -0,0 +1,88 @@
1
+ <template>
2
+ <div
3
+ class="max-w-xs relative bg-white border border-gray-200 rounded-xl shadow-lg"
4
+ role="alert"
5
+ >
6
+ <div class="flex gap-x-3 p-4">
7
+ <div class="shrink-0">
8
+ <span
9
+ class="m-1 inline-flex justify-center items-center size-8 rounded-full bg-gray-100 text-gray-800"
10
+ >
11
+ <svg
12
+ class="shrink-0 size-4"
13
+ xmlns="http://www.w3.org/2000/svg"
14
+ width="24"
15
+ height="24"
16
+ viewBox="0 0 24 24"
17
+ fill="none"
18
+ stroke="currentColor"
19
+ stroke-width="2"
20
+ stroke-linecap="round"
21
+ stroke-linejoin="round"
22
+ >
23
+ <path
24
+ d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"
25
+ />
26
+ <path d="M12 12v9" />
27
+ <path d="m16 16-4-4-4 4" />
28
+ </svg>
29
+ </span>
30
+ <button
31
+ class="absolute top-3 end-3 inline-flex shrink-0 justify-center items-center size-5 rounded-lg text-gray-800 opacity-50 hover:opacity-100 focus:outline-hidden focus:opacity-100"
32
+ @click="$emit('close')"
33
+ aria-label="Close"
34
+ >
35
+ <span class="sr-only">Close</span>
36
+ <svg
37
+ class="shrink-0 size-4"
38
+ xmlns="http://www.w3.org/2000/svg"
39
+ width="24"
40
+ height="24"
41
+ viewBox="0 0 24 24"
42
+ fill="none"
43
+ stroke="currentColor"
44
+ stroke-width="2"
45
+ stroke-linecap="round"
46
+ stroke-linejoin="round"
47
+ >
48
+ <path d="M18 6 6 18" />
49
+ <path d="m6 6 12 12" />
50
+ </svg>
51
+ </button>
52
+ </div>
53
+ <div class="grow me-5">
54
+ <h3 v-if="toast.title" class="text-gray-800 font-medium text-sm">
55
+ {{ toast.title }}
56
+ </h3>
57
+ <div
58
+ v-if="toast.progress !== undefined"
59
+ class="mt-2 flex flex-col gap-x-3"
60
+ >
61
+ <span class="block mb-1.5 text-xs text-gray-500">{{
62
+ toast.progressLabel
63
+ }}</span>
64
+ <div
65
+ class="flex w-full h-1 bg-gray-200 rounded-full overflow-hidden"
66
+ role="progressbar"
67
+ :aria-valuenow="toast.progress"
68
+ aria-valuemin="0"
69
+ aria-valuemax="100"
70
+ >
71
+ <div
72
+ class="flex flex-col justify-center overflow-hidden bg-blue-600 text-xs text-white text-center whitespace-nowrap"
73
+ :style="{ width: toast.progress + '%' }"
74
+ ></div>
75
+ </div>
76
+ </div>
77
+ <div
78
+ v-else
79
+ class="mt-2 text-sm text-gray-600"
80
+ v-html="toast.message"
81
+ ></div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ </template>
86
+ <script setup>
87
+ defineProps({ toast: Object });
88
+ </script>
@@ -0,0 +1,240 @@
1
+ import { ref, onMounted, onUnmounted } from "vue";
2
+
3
+ // Store global para el tiempo actual (compartido entre todas las instancias)
4
+ const globalTimeStore = (() => {
5
+ const currentTime = ref(Date.now());
6
+ let intervalId = null;
7
+ let subscriberCount = 0;
8
+
9
+ const startGlobalTimer = () => {
10
+ if (!intervalId) {
11
+ intervalId = setInterval(() => {
12
+ currentTime.value = Date.now();
13
+ }, 5000); // Actualiza cada 5 segundos
14
+ }
15
+ };
16
+
17
+ const stopGlobalTimer = () => {
18
+ if (intervalId) {
19
+ clearInterval(intervalId);
20
+ intervalId = null;
21
+ }
22
+ };
23
+
24
+ const subscribe = () => {
25
+ subscriberCount++;
26
+ if (subscriberCount === 1) {
27
+ startGlobalTimer();
28
+ }
29
+ };
30
+
31
+ const unsubscribe = () => {
32
+ subscriberCount--;
33
+ if (subscriberCount === 0) {
34
+ stopGlobalTimer();
35
+ }
36
+ };
37
+
38
+ return {
39
+ currentTime,
40
+ subscribe,
41
+ unsubscribe,
42
+ };
43
+ })();
44
+
45
+ /**
46
+ * Composable para manejo de fechas y tiempos relativos
47
+ */
48
+ export const useDate = () => {
49
+ // Configuración base de la zona horaria del tenant
50
+ const tenantTimeZone = 'America/Santiago';
51
+
52
+ // Suscribirse al timer global cuando se monta el componente
53
+ onMounted(() => {
54
+ globalTimeStore.subscribe();
55
+ });
56
+
57
+ onUnmounted(() => {
58
+ globalTimeStore.unsubscribe();
59
+ });
60
+
61
+ /**
62
+ * Como la DB de Laravel suele enviar fechas "YYYY-MM-DD HH:mm:ss" sin zona horaria,
63
+ * y siempre están en UTC, forzamos la lectura como UTC si no viene indicada.
64
+ */
65
+ const parseAsUTC = (input) => {
66
+ if (!input) return new Date("");
67
+ if (input instanceof Date) return input;
68
+
69
+ let dateStr = String(input);
70
+ if (!dateStr.includes('T') && dateStr.includes(' ')) {
71
+ dateStr = dateStr.replace(' ', 'T');
72
+ }
73
+ // Si no termina en Z, ni tiene offset tipo +00:00, agregar Z
74
+ if (!/(Z|[+-]\d{2}(:\d{2})?)$/.test(dateStr)) {
75
+ dateStr += 'Z';
76
+ }
77
+ return new Date(dateStr);
78
+ };
79
+
80
+ /**
81
+ * Obtiene la fecha formato YYYY-MM-DD según la zona horaria para comparaciones justas
82
+ */
83
+ const getTimeZoneDateString = (date) => {
84
+ if (isNaN(date.getTime())) return "";
85
+ const formatter = new Intl.DateTimeFormat('en-CA', {
86
+ timeZone: tenantTimeZone,
87
+ year: 'numeric',
88
+ month: '2-digit',
89
+ day: '2-digit'
90
+ });
91
+ return formatter.format(date);
92
+ };
93
+
94
+ /**
95
+ * Devuelve "hace X minutos/horas/días" en español.
96
+ */
97
+ const relativeTime = (input, watch = false) => {
98
+ if (watch) {
99
+ globalTimeStore.currentTime.value;
100
+ }
101
+
102
+ if (!input) return "";
103
+
104
+ const then = parseAsUTC(input);
105
+ if (isNaN(then.getTime())) return "";
106
+
107
+ const now = new Date();
108
+ const diffMs = now - then;
109
+ const diffMin = Math.floor(diffMs / 60000);
110
+
111
+ if (diffMin < 1) return "hace unos segundos";
112
+ if (diffMin < 60) return `hace ${diffMin} ${diffMin === 1 ? "minuto" : "minutos"}`;
113
+
114
+ const diffHrs = Math.floor(diffMin / 60);
115
+ if (diffHrs < 24) return `hace ${diffHrs} ${diffHrs === 1 ? "hora" : "horas"}`;
116
+
117
+ const diffDays = Math.floor(diffHrs / 24);
118
+ if (diffDays < 30) return `hace ${diffDays} ${diffDays === 1 ? "día" : "días"}`;
119
+
120
+ const diffMonths = Math.floor(diffDays / 30);
121
+ if (diffMonths < 12) return `hace ${diffMonths} ${diffMonths === 1 ? "mes" : "meses"}`;
122
+
123
+ const diffYears = Math.floor(diffMonths / 12);
124
+ return `hace ${diffYears} ${diffYears === 1 ? "año" : "años"}`;
125
+ };
126
+
127
+ /**
128
+ * Formatea la fecha para mostrar asegurando parseo UTC y formateo Santiago.
129
+ */
130
+ const formatDate = (input, options = {}) => {
131
+ if (!input) return "";
132
+
133
+ const d = parseAsUTC(input);
134
+ if (isNaN(d.getTime())) return input;
135
+
136
+ const formatter = new Intl.DateTimeFormat('es-CL', {
137
+ timeZone: tenantTimeZone,
138
+ year: 'numeric',
139
+ month: '2-digit',
140
+ day: '2-digit',
141
+ hour: '2-digit',
142
+ minute: '2-digit',
143
+ hour12: false
144
+ });
145
+
146
+ const parts = formatter.formatToParts(d);
147
+ let day, month, year, hours, minutes;
148
+
149
+ for (const part of parts) {
150
+ if (part.type === 'day') day = part.value;
151
+ if (part.type === 'month') month = part.value;
152
+ if (part.type === 'year') year = part.value;
153
+ if (part.type === 'hour') hours = part.value;
154
+ if (part.type === 'minute') minutes = part.value;
155
+ }
156
+
157
+ if (hours === '24') hours = '00';
158
+
159
+ if (options.onlyDate) return `${day} / ${month} / ${year}`;
160
+ if (options.onlyTime) return `${hours}:${minutes}`;
161
+
162
+ return `${day} / ${month} / ${year} ${hours}:${minutes}`;
163
+ };
164
+
165
+ const formatDateOnly = (input) => formatDate(input, { onlyDate: true });
166
+ const formatTimeOnly = (input) => formatDate(input, { onlyTime: true });
167
+
168
+ const isToday = (input) => {
169
+ if (!input) return false;
170
+ const date = parseAsUTC(input);
171
+ return getTimeZoneDateString(new Date()) === getTimeZoneDateString(date);
172
+ };
173
+
174
+ const isYesterday = (input) => {
175
+ if (!input) return false;
176
+ const date = parseAsUTC(input);
177
+ const yesterday = new Date(Date.now() - 86400000);
178
+ return getTimeZoneDateString(yesterday) === getTimeZoneDateString(date);
179
+ };
180
+
181
+ const formatSmart = (input) => {
182
+ if (!input) return "";
183
+
184
+ if (isToday(input)) {
185
+ return `Hoy a las ${formatTimeOnly(input)}`;
186
+ }
187
+
188
+ if (isYesterday(input)) {
189
+ return `Ayer a las ${formatTimeOnly(input)}`;
190
+ }
191
+
192
+ return formatDate(input);
193
+ };
194
+
195
+ const getDayName = (input) => {
196
+ if (!input) return "";
197
+ const date = parseAsUTC(input);
198
+ if (isNaN(date.getTime())) return "";
199
+ return new Intl.DateTimeFormat('es-ES', { timeZone: tenantTimeZone, weekday: 'long' }).format(date);
200
+ };
201
+
202
+ const getMonthName = (input) => {
203
+ if (!input) return "";
204
+ const date = parseAsUTC(input);
205
+ if (isNaN(date.getTime())) return "";
206
+ return new Intl.DateTimeFormat('es-ES', { timeZone: tenantTimeZone, month: 'long' }).format(date);
207
+ };
208
+
209
+ const daysDiff = (date1, date2 = new Date()) => {
210
+ const d1 = parseAsUTC(date1);
211
+ const d2 = typeof date2 === 'string' ? parseAsUTC(date2) : new Date(date2);
212
+ const diffTime = Math.abs(d2 - d1);
213
+ return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
214
+ };
215
+
216
+ const isFuture = (input) => {
217
+ if (!input) return false;
218
+ return parseAsUTC(input) > new Date();
219
+ };
220
+
221
+ const isPast = (input) => {
222
+ if (!input) return false;
223
+ return parseAsUTC(input) < new Date();
224
+ };
225
+
226
+ return {
227
+ relativeTime,
228
+ formatDate,
229
+ formatDateOnly,
230
+ formatTimeOnly,
231
+ formatSmart,
232
+ isToday,
233
+ isYesterday,
234
+ getDayName,
235
+ getMonthName,
236
+ daysDiff,
237
+ isFuture,
238
+ isPast,
239
+ };
240
+ };
@@ -0,0 +1,21 @@
1
+ import { onMounted } from 'vue';
2
+
3
+ export const useDevice = () => {
4
+ const getDeviceId = () => {
5
+ if (process.server) return null;
6
+
7
+ let deviceId = localStorage.getItem('app_device_id');
8
+
9
+ if (!deviceId) {
10
+ // Generate a random UUID-like string
11
+ deviceId = 'dev_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
12
+ localStorage.setItem('app_device_id', deviceId);
13
+ }
14
+
15
+ return deviceId;
16
+ };
17
+
18
+ return {
19
+ getDeviceId
20
+ };
21
+ };
@@ -0,0 +1,67 @@
1
+ // app/composables/useDownload.js
2
+ import { useAuthStore } from '@/stores/auth'
3
+
4
+ export function useDownload() {
5
+ const auth = useAuthStore()
6
+ const config = useRuntimeConfig()
7
+ const baseUrl = config.public.apiBaseUrl
8
+
9
+ function buildHeaders(useToken = true) {
10
+ const headers = {}
11
+ if (useToken && auth.getToken()) {
12
+ headers['Authorization'] = `Bearer ${auth.getToken()}`
13
+ }
14
+ return headers
15
+ }
16
+
17
+ /**
18
+ * Descarga un archivo usando XHR para obtener progreso y headers
19
+ * @param {string} url
20
+ * @param {object} params
21
+ * @param {object} options { onProgress, useToken, method, headers }
22
+ * @returns {Promise<{ blob: Blob, headers: object }>}
23
+ */
24
+ function download(url, params = {}, options = {}) {
25
+ const {
26
+ onProgress = null,
27
+ useToken = true,
28
+ method = 'GET',
29
+ headers = {},
30
+ } = options
31
+
32
+ return new Promise((resolve, reject) => {
33
+ const xhr = new XMLHttpRequest()
34
+ let query = ''
35
+ if (method === 'GET' && Object.keys(params).length) {
36
+ query = '?' + new URLSearchParams(params).toString()
37
+ }
38
+ xhr.open(method, `${baseUrl}/${url}${query}`)
39
+ const allHeaders = { ...buildHeaders(useToken), ...headers }
40
+ Object.entries(allHeaders).forEach(([k, v]) => xhr.setRequestHeader(k, v))
41
+ xhr.responseType = 'blob'
42
+ xhr.onload = function () {
43
+ const responseHeaders = {}
44
+ xhr.getAllResponseHeaders().split('\r\n').forEach(line => {
45
+ const [key, value] = line.split(': ')
46
+ if (key) responseHeaders[key.toLowerCase()] = value
47
+ })
48
+ if (xhr.status >= 200 && xhr.status < 300) {
49
+ resolve({ blob: xhr.response, headers: responseHeaders })
50
+ } else {
51
+ reject(new Error('Download failed'))
52
+ }
53
+ }
54
+ xhr.onerror = function () {
55
+ reject(new Error('Network error'))
56
+ }
57
+ xhr.onprogress = function (event) {
58
+ if (onProgress && event.lengthComputable) {
59
+ onProgress(Math.round((event.loaded / event.total) * 100), event)
60
+ }
61
+ }
62
+ xhr.send(method === 'GET' ? null : JSON.stringify(params))
63
+ })
64
+ }
65
+
66
+ return { download }
67
+ }