@softdin/shared 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.
Files changed (40) hide show
  1. package/README.md +48 -0
  2. package/package.json +50 -0
  3. package/src/a11y/announcer.ts +43 -0
  4. package/src/a11y/apply.ts +83 -0
  5. package/src/a11y/index.ts +31 -0
  6. package/src/a11y/state.ts +40 -0
  7. package/src/a11y/storage.ts +106 -0
  8. package/src/a11y/types.ts +187 -0
  9. package/src/api.ts +89 -0
  10. package/src/assets/softdin-logo.png +0 -0
  11. package/src/auth.ts +134 -0
  12. package/src/components/a11y/A11yLiveRegion.vue +11 -0
  13. package/src/components/a11y/AccessibilityMenuControls.vue +426 -0
  14. package/src/components/a11y/AccessibilityPreferencesPanel.vue +331 -0
  15. package/src/components/a11y/ColorSchemeQuickToggle.vue +105 -0
  16. package/src/components/a11y/SkipLink.vue +41 -0
  17. package/src/components/auth/AuthBrandHeader.vue +129 -0
  18. package/src/components/auth/AuthShell.vue +52 -0
  19. package/src/components/auth/DataProcessingConsentCheckbox.vue +303 -0
  20. package/src/components/auth/DataProcessingConsentForm.vue +111 -0
  21. package/src/components/forms/ErrorAlert.vue +170 -0
  22. package/src/components/forms/FormCancelButton.vue +87 -0
  23. package/src/components/forms/FormCloseButton.vue +114 -0
  24. package/src/components/forms/FormHelpButton.vue +109 -0
  25. package/src/components/forms/FormHelpModal.vue +193 -0
  26. package/src/components/forms/FormMaximizeButton.vue +143 -0
  27. package/src/components/forms/FormRefreshButton.vue +152 -0
  28. package/src/components/forms/FormSaveButton.vue +113 -0
  29. package/src/components/forms/PasswordInput.vue +154 -0
  30. package/src/components/navigation/CatalogListPagination.vue +113 -0
  31. package/src/config/apiBase.ts +37 -0
  32. package/src/lib/catalog-pagination.ts +27 -0
  33. package/src/lib/pagination.ts +86 -0
  34. package/src/lib/softdin-libreria.ts +50 -0
  35. package/src/style.css +278 -0
  36. package/src/types/auth.ts +146 -0
  37. package/src/types/softdin-libreria.d.ts +41 -0
  38. package/src/utils/apiError.ts +55 -0
  39. package/src/utils/connectivity.ts +33 -0
  40. package/src/utils/tenantLogo.ts +6 -0
@@ -0,0 +1,303 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, onUnmounted, ref, watch } from 'vue'
3
+ import { authService } from '../../auth'
4
+ import type { DataProcessingConsentPolicy } from '../../types/auth'
5
+
6
+ const model = defineModel<boolean>({ default: false })
7
+
8
+ const loading = ref(true)
9
+ const showModal = ref(false)
10
+ const error = ref<string | null>(null)
11
+ const policy = ref<DataProcessingConsentPolicy | null>(null)
12
+
13
+ onMounted(async () => {
14
+ loading.value = true
15
+ error.value = null
16
+
17
+ try {
18
+ policy.value = await authService.fetchDataProcessingConsentPolicy()
19
+ } catch {
20
+ error.value = 'No se pudo cargar el texto del consentimiento.'
21
+ } finally {
22
+ loading.value = false
23
+ }
24
+ })
25
+
26
+ function openModal(): void {
27
+ if (!policy.value) {
28
+ return
29
+ }
30
+ showModal.value = true
31
+ }
32
+
33
+ function closeModal(): void {
34
+ showModal.value = false
35
+ }
36
+
37
+ function onKeydown(event: KeyboardEvent): void {
38
+ if (event.key === 'Escape' && showModal.value) {
39
+ closeModal()
40
+ }
41
+ }
42
+
43
+ watch(showModal, (open) => {
44
+ if (open) {
45
+ document.addEventListener('keydown', onKeydown)
46
+ document.body.style.overflow = 'hidden'
47
+ } else {
48
+ document.removeEventListener('keydown', onKeydown)
49
+ document.body.style.overflow = ''
50
+ }
51
+ })
52
+
53
+ onUnmounted(() => {
54
+ document.removeEventListener('keydown', onKeydown)
55
+ document.body.style.overflow = ''
56
+ })
57
+ </script>
58
+
59
+ <template>
60
+ <div class="consent-box">
61
+ <label class="consent-check">
62
+ <input
63
+ v-model="model"
64
+ type="checkbox"
65
+ class="consent-input"
66
+ required
67
+ />
68
+ <span class="consent-label">
69
+ Acepto el
70
+ <strong>Consentimiento de Tratamiento de Datos Personales</strong>
71
+ <template v-if="policy"> (v{{ policy.version }})</template>.
72
+ Sin esta aceptación no podrá ingresar. Solo se solicita una vez.
73
+ </span>
74
+ </label>
75
+
76
+ <button
77
+ type="button"
78
+ class="consent-toggle"
79
+ :disabled="loading || !policy"
80
+ @click="openModal"
81
+ >
82
+ Leer consentimiento
83
+ </button>
84
+
85
+ <p v-if="error" class="consent-error">{{ error }}</p>
86
+ </div>
87
+
88
+ <Teleport to="body">
89
+ <div
90
+ v-if="showModal && policy"
91
+ class="consent-backdrop"
92
+ @click.self="closeModal"
93
+ >
94
+ <section
95
+ class="consent-modal"
96
+ role="dialog"
97
+ aria-modal="true"
98
+ aria-labelledby="consent-modal-title"
99
+ >
100
+ <header class="consent-modal-header">
101
+ <div>
102
+ <p class="consent-modal-kicker">Política de privacidad</p>
103
+ <h2 id="consent-modal-title" class="consent-modal-title">
104
+ {{ policy.title }}
105
+ </h2>
106
+ <p class="consent-modal-version">Versión {{ policy.version }}</p>
107
+ </div>
108
+ <button
109
+ type="button"
110
+ class="consent-modal-close"
111
+ aria-label="Cerrar"
112
+ @click="closeModal"
113
+ >
114
+ ×
115
+ </button>
116
+ </header>
117
+
118
+ <div class="consent-modal-body">
119
+ <p class="consent-summary">{{ policy.summary }}</p>
120
+ <p
121
+ v-for="(paragraph, index) in policy.paragraphs"
122
+ :key="index"
123
+ class="consent-paragraph"
124
+ >
125
+ {{ paragraph }}
126
+ </p>
127
+ </div>
128
+
129
+ <footer class="consent-modal-footer">
130
+ <button type="button" class="consent-modal-confirm" @click="closeModal">
131
+ Cerrar
132
+ </button>
133
+ </footer>
134
+ </section>
135
+ </div>
136
+ </Teleport>
137
+ </template>
138
+
139
+ <style scoped>
140
+ .consent-box {
141
+ border-radius: 0.5rem;
142
+ border: 1px solid #cbd5e1;
143
+ background: #f8fafc;
144
+ padding: 0.75rem;
145
+ display: flex;
146
+ flex-direction: column;
147
+ gap: 0.5rem;
148
+ }
149
+
150
+ .consent-check {
151
+ display: flex;
152
+ align-items: flex-start;
153
+ gap: 0.625rem;
154
+ cursor: pointer;
155
+ }
156
+
157
+ .consent-input {
158
+ margin-top: 0.2rem;
159
+ width: 1rem;
160
+ height: 1rem;
161
+ flex-shrink: 0;
162
+ accent-color: var(--color-brand-600, #16a34a);
163
+ }
164
+
165
+ .consent-label {
166
+ font-size: 0.8125rem;
167
+ line-height: 1.4;
168
+ color: #334155;
169
+ }
170
+
171
+ .consent-toggle {
172
+ align-self: flex-start;
173
+ border: 0;
174
+ background: transparent;
175
+ padding: 0;
176
+ font-size: 0.75rem;
177
+ font-weight: 600;
178
+ color: var(--color-brand-700, #15803d);
179
+ text-decoration: underline;
180
+ cursor: pointer;
181
+ }
182
+
183
+ .consent-toggle:disabled {
184
+ opacity: 0.5;
185
+ cursor: not-allowed;
186
+ }
187
+
188
+ .consent-error {
189
+ margin: 0;
190
+ font-size: 0.75rem;
191
+ color: #b91c1c;
192
+ }
193
+
194
+ .consent-backdrop {
195
+ position: fixed;
196
+ inset: 0;
197
+ z-index: 80;
198
+ display: flex;
199
+ align-items: center;
200
+ justify-content: center;
201
+ padding: 1rem;
202
+ background: rgba(15, 23, 42, 0.55);
203
+ }
204
+
205
+ .consent-modal {
206
+ width: min(36rem, 100%);
207
+ max-height: min(85vh, 40rem);
208
+ display: flex;
209
+ flex-direction: column;
210
+ border-radius: 1rem;
211
+ background: #fff;
212
+ box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.35);
213
+ }
214
+
215
+ .consent-modal-header {
216
+ display: flex;
217
+ align-items: flex-start;
218
+ justify-content: space-between;
219
+ gap: 1rem;
220
+ padding: 1.25rem 1.25rem 0.75rem;
221
+ border-bottom: 1px solid #e2e8f0;
222
+ }
223
+
224
+ .consent-modal-kicker {
225
+ margin: 0;
226
+ font-size: 0.6875rem;
227
+ font-weight: 600;
228
+ letter-spacing: 0.04em;
229
+ text-transform: uppercase;
230
+ color: var(--color-brand-700, #15803d);
231
+ }
232
+
233
+ .consent-modal-title {
234
+ margin: 0.25rem 0 0;
235
+ font-size: 1.05rem;
236
+ font-weight: 700;
237
+ line-height: 1.3;
238
+ color: #0f172a;
239
+ }
240
+
241
+ .consent-modal-version {
242
+ margin: 0.35rem 0 0;
243
+ font-size: 0.75rem;
244
+ color: #64748b;
245
+ }
246
+
247
+ .consent-modal-close {
248
+ flex-shrink: 0;
249
+ width: 2rem;
250
+ height: 2rem;
251
+ border: 0;
252
+ border-radius: 9999px;
253
+ background: #f1f5f9;
254
+ color: #334155;
255
+ font-size: 1.35rem;
256
+ line-height: 1;
257
+ cursor: pointer;
258
+ }
259
+
260
+ .consent-modal-close:hover {
261
+ background: #e2e8f0;
262
+ }
263
+
264
+ .consent-modal-body {
265
+ flex: 1;
266
+ overflow-y: auto;
267
+ padding: 1rem 1.25rem;
268
+ }
269
+
270
+ .consent-summary,
271
+ .consent-paragraph {
272
+ margin: 0 0 0.75rem;
273
+ font-size: 0.875rem;
274
+ line-height: 1.55;
275
+ color: #475569;
276
+ }
277
+
278
+ .consent-paragraph:last-child {
279
+ margin-bottom: 0;
280
+ }
281
+
282
+ .consent-modal-footer {
283
+ display: flex;
284
+ justify-content: flex-end;
285
+ padding: 0.875rem 1.25rem 1.25rem;
286
+ border-top: 1px solid #e2e8f0;
287
+ }
288
+
289
+ .consent-modal-confirm {
290
+ border: 0;
291
+ border-radius: 0.5rem;
292
+ background: var(--color-brand-600, #16a34a);
293
+ padding: 0.55rem 1.1rem;
294
+ font-size: 0.875rem;
295
+ font-weight: 600;
296
+ color: #fff;
297
+ cursor: pointer;
298
+ }
299
+
300
+ .consent-modal-confirm:hover {
301
+ background: var(--color-brand-700, #15803d);
302
+ }
303
+ </style>
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from 'vue'
3
+ import { authService } from '../../auth'
4
+ import type { DataProcessingConsentPolicy, User } from '../../types/auth'
5
+
6
+ const emit = defineEmits<{
7
+ accepted: [user?: User]
8
+ rejected: [message: string]
9
+ }>()
10
+
11
+ const loading = ref(true)
12
+ const submitting = ref(false)
13
+ const error = ref<string | null>(null)
14
+ const policy = ref<DataProcessingConsentPolicy | null>(null)
15
+
16
+ onMounted(async () => {
17
+ loading.value = true
18
+ error.value = null
19
+
20
+ try {
21
+ policy.value = await authService.fetchDataProcessingConsent()
22
+ } catch {
23
+ error.value = 'No se pudo cargar el consentimiento de tratamiento de datos.'
24
+ } finally {
25
+ loading.value = false
26
+ }
27
+ })
28
+
29
+ async function accept(): Promise<void> {
30
+ submitting.value = true
31
+ error.value = null
32
+
33
+ try {
34
+ const data = await authService.submitDataProcessingConsent(true)
35
+ if (!data.accepted) {
36
+ error.value = data.message || 'No se pudo registrar el consentimiento.'
37
+ return
38
+ }
39
+ emit('accepted', data.user)
40
+ } catch {
41
+ error.value = 'No se pudo registrar el consentimiento. Intente nuevamente.'
42
+ } finally {
43
+ submitting.value = false
44
+ }
45
+ }
46
+
47
+ async function reject(): Promise<void> {
48
+ submitting.value = true
49
+ error.value = null
50
+
51
+ try {
52
+ const data = await authService.submitDataProcessingConsent(false)
53
+ emit('rejected', data.message || 'Debe aceptar el consentimiento para ingresar.')
54
+ } catch {
55
+ emit('rejected', 'Debe aceptar el consentimiento de tratamiento de datos personales para ingresar.')
56
+ } finally {
57
+ submitting.value = false
58
+ }
59
+ }
60
+ </script>
61
+
62
+ <template>
63
+ <div class="space-y-5">
64
+ <div v-if="loading" class="rounded-lg bg-slate-50 px-4 py-6 text-center text-sm text-slate-500">
65
+ Cargando consentimiento…
66
+ </div>
67
+
68
+ <template v-else-if="policy">
69
+ <p class="text-sm leading-relaxed text-slate-600">
70
+ {{ policy.summary }}
71
+ </p>
72
+
73
+ <div
74
+ class="max-h-64 space-y-3 overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-relaxed text-slate-700"
75
+ >
76
+ <p v-for="(paragraph, index) in policy.paragraphs" :key="index">
77
+ {{ paragraph }}
78
+ </p>
79
+ </div>
80
+
81
+ <p class="text-xs text-slate-400">Versión {{ policy.version }}</p>
82
+ </template>
83
+
84
+ <p
85
+ v-if="error"
86
+ class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
87
+ role="alert"
88
+ >
89
+ {{ error }}
90
+ </p>
91
+
92
+ <div class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
93
+ <button
94
+ type="button"
95
+ class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
96
+ :disabled="loading || submitting || !policy"
97
+ @click="reject"
98
+ >
99
+ No acepto
100
+ </button>
101
+ <button
102
+ type="button"
103
+ class="rounded-lg bg-[var(--color-brand-700,#166534)] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-[var(--color-brand-800,#14532d)] disabled:cursor-not-allowed disabled:opacity-60"
104
+ :disabled="loading || submitting || !policy"
105
+ @click="accept"
106
+ >
107
+ {{ submitting ? 'Procesando…' : 'Acepto' }}
108
+ </button>
109
+ </div>
110
+ </div>
111
+ </template>
@@ -0,0 +1,170 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+
4
+ const props = withDefaults(
5
+ defineProps<{
6
+ message: string
7
+ detail?: string | null
8
+ }>(),
9
+ {
10
+ detail: null,
11
+ },
12
+ )
13
+
14
+ const copied = ref(false)
15
+ let copiedTimer: ReturnType<typeof setTimeout> | null = null
16
+
17
+ const copyText = computed(() => {
18
+ const parts = [props.message.trim()]
19
+ if (props.detail && props.detail.trim() !== '') {
20
+ parts.push(props.detail.trim())
21
+ }
22
+ return parts.join('\n\n')
23
+ })
24
+
25
+ async function copyContent(): Promise<void> {
26
+ const text = copyText.value
27
+ if (!text) {
28
+ return
29
+ }
30
+
31
+ try {
32
+ if (navigator.clipboard?.writeText) {
33
+ await navigator.clipboard.writeText(text)
34
+ } else {
35
+ const textarea = document.createElement('textarea')
36
+ textarea.value = text
37
+ textarea.setAttribute('readonly', '')
38
+ textarea.style.position = 'fixed'
39
+ textarea.style.left = '-9999px'
40
+ document.body.appendChild(textarea)
41
+ textarea.select()
42
+ document.execCommand('copy')
43
+ document.body.removeChild(textarea)
44
+ }
45
+
46
+ copied.value = true
47
+ if (copiedTimer) {
48
+ clearTimeout(copiedTimer)
49
+ }
50
+ copiedTimer = setTimeout(() => {
51
+ copied.value = false
52
+ }, 2000)
53
+ } catch {
54
+ copied.value = false
55
+ }
56
+ }
57
+ </script>
58
+
59
+ <template>
60
+ <div class="softdin-error-alert" role="alert">
61
+ <div class="softdin-error-alert__body">
62
+ <p class="softdin-error-alert__message">{{ message }}</p>
63
+ <pre v-if="detail" class="softdin-error-alert__detail">{{ detail }}</pre>
64
+ </div>
65
+ <button
66
+ type="button"
67
+ class="softdin-error-alert__copy"
68
+ :class="{ 'is-copied': copied }"
69
+ :aria-label="copied ? 'Contenido copiado' : 'Copiar contenido del error'"
70
+ :title="copied ? 'Copiado' : 'Copiar'"
71
+ @click="copyContent"
72
+ >
73
+ <svg
74
+ v-if="copied"
75
+ class="softdin-error-alert__icon"
76
+ viewBox="0 0 20 20"
77
+ aria-hidden="true"
78
+ >
79
+ <path
80
+ fill="currentColor"
81
+ d="M8.3 14.7a1 1 0 0 1-.7-.3l-3-3a1 1 0 1 1 1.4-1.4l2.3 2.3 5.3-5.3a1 1 0 1 1 1.4 1.4l-6 6a1 1 0 0 1-.7.3Z"
82
+ />
83
+ </svg>
84
+ <svg
85
+ v-else
86
+ class="softdin-error-alert__icon"
87
+ viewBox="0 0 20 20"
88
+ aria-hidden="true"
89
+ >
90
+ <path
91
+ fill="currentColor"
92
+ d="M7 3a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2V3Zm2 2h4v8h1V3H9v2ZM5 7v10h6V7H5Z"
93
+ />
94
+ </svg>
95
+ </button>
96
+ </div>
97
+ </template>
98
+
99
+ <style scoped>
100
+ .softdin-error-alert {
101
+ display: flex;
102
+ align-items: flex-start;
103
+ justify-content: space-between;
104
+ gap: 0.75rem;
105
+ margin-bottom: 0.875rem;
106
+ border-radius: 0.5rem;
107
+ background: #fef2f2;
108
+ padding: 0.625rem 0.75rem;
109
+ color: #b91c1c;
110
+ }
111
+
112
+ .softdin-error-alert__body {
113
+ min-width: 0;
114
+ flex: 1;
115
+ }
116
+
117
+ .softdin-error-alert__message {
118
+ margin: 0;
119
+ font-size: 0.875rem;
120
+ line-height: 1.4;
121
+ white-space: pre-wrap;
122
+ word-break: break-word;
123
+ }
124
+
125
+ .softdin-error-alert__detail {
126
+ margin: 0.75rem 0 0;
127
+ padding: 0.5rem 0.625rem;
128
+ border-radius: 0.375rem;
129
+ background: #fff1f2;
130
+ border: 1px solid #fecaca;
131
+ font-size: 0.6875rem;
132
+ line-height: 1.45;
133
+ color: #7f1d1d;
134
+ white-space: pre-wrap;
135
+ word-break: break-word;
136
+ max-height: 16rem;
137
+ overflow: auto;
138
+ }
139
+
140
+ .softdin-error-alert__copy {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ flex-shrink: 0;
145
+ width: 1.875rem;
146
+ height: 1.875rem;
147
+ border-radius: 0.375rem;
148
+ border: 1px solid #fecaca;
149
+ background: #fff;
150
+ padding: 0;
151
+ color: #b91c1c;
152
+ cursor: pointer;
153
+ }
154
+
155
+ .softdin-error-alert__copy:hover {
156
+ background: #fff1f2;
157
+ }
158
+
159
+ .softdin-error-alert__copy.is-copied {
160
+ border-color: #86efac;
161
+ color: #15803d;
162
+ background: #f0fdf4;
163
+ }
164
+
165
+ .softdin-error-alert__icon {
166
+ width: 1rem;
167
+ height: 1rem;
168
+ display: block;
169
+ }
170
+ </style>
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ withDefaults(
3
+ defineProps<{
4
+ disabled?: boolean
5
+ label?: string
6
+ }>(),
7
+ {
8
+ disabled: false,
9
+ label: 'Cancelar',
10
+ },
11
+ )
12
+
13
+ defineEmits<{
14
+ click: [event: MouseEvent]
15
+ }>()
16
+ </script>
17
+
18
+ <template>
19
+ <button
20
+ type="button"
21
+ class="softdin-form-cancel"
22
+ :disabled="disabled"
23
+ @click="$emit('click', $event)"
24
+ >
25
+ <svg class="softdin-form-cancel__icon" viewBox="0 0 20 20" aria-hidden="true">
26
+ <circle cx="10" cy="10" r="8" />
27
+ <path d="M6.5 6.5l7 7M13.5 6.5l-7 7" />
28
+ </svg>
29
+ <span>{{ label }}</span>
30
+ </button>
31
+ </template>
32
+
33
+ <style scoped>
34
+ .softdin-form-cancel {
35
+ display: inline-flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ gap: 0.5rem;
39
+ min-height: 2.25rem;
40
+ border-radius: 9999px;
41
+ border: 1px solid #fbbf24;
42
+ background: #fffbeb;
43
+ padding: 0.5rem 1rem;
44
+ font-size: 0.875rem;
45
+ font-weight: 600;
46
+ color: #92400e;
47
+ cursor: pointer;
48
+ transition: background 150ms ease, box-shadow 150ms ease, transform 150ms ease;
49
+ }
50
+
51
+ .softdin-form-cancel:hover:not(:disabled) {
52
+ background: #fef3c7;
53
+ box-shadow: 0 4px 10px rgba(146, 64, 14, 0.12);
54
+ transform: translateY(-2px);
55
+ }
56
+
57
+ .softdin-form-cancel:focus-visible {
58
+ outline: 2px solid #d97706;
59
+ outline-offset: 2px;
60
+ }
61
+
62
+ .softdin-form-cancel:disabled {
63
+ opacity: 0.6;
64
+ cursor: not-allowed;
65
+ }
66
+
67
+ .softdin-form-cancel__icon {
68
+ width: 1rem;
69
+ height: 1rem;
70
+ flex-shrink: 0;
71
+ fill: none;
72
+ stroke: currentColor;
73
+ stroke-width: 1.8;
74
+ stroke-linecap: round;
75
+ stroke-linejoin: round;
76
+ }
77
+
78
+ @media (prefers-reduced-motion: reduce) {
79
+ .softdin-form-cancel {
80
+ transition: none;
81
+ }
82
+
83
+ .softdin-form-cancel:hover:not(:disabled) {
84
+ transform: none;
85
+ }
86
+ }
87
+ </style>