@asteby/metacore-runtime-react 23.9.2 → 23.10.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +43 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +122 -0
  5. package/dist/dynamic-kanban.d.ts.map +1 -1
  6. package/dist/dynamic-kanban.js +6 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -0
  10. package/dist/license/index.d.ts +5 -0
  11. package/dist/license/index.d.ts.map +1 -0
  12. package/dist/license/index.js +4 -0
  13. package/dist/license/license-expiry-banner.d.ts +16 -0
  14. package/dist/license/license-expiry-banner.d.ts.map +1 -0
  15. package/dist/license/license-expiry-banner.js +85 -0
  16. package/dist/license/license-gate.d.ts +40 -0
  17. package/dist/license/license-gate.d.ts.map +1 -0
  18. package/dist/license/license-gate.js +108 -0
  19. package/dist/license/license-status-badge.d.ts +7 -0
  20. package/dist/license/license-status-badge.d.ts.map +1 -0
  21. package/dist/license/license-status-badge.js +57 -0
  22. package/dist/license/types.d.ts +63 -0
  23. package/dist/license/types.d.ts.map +1 -0
  24. package/dist/license/types.js +45 -0
  25. package/dist/types.d.ts +8 -0
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/use-i18n-resource-version.d.ts +2 -0
  28. package/dist/use-i18n-resource-version.d.ts.map +1 -0
  29. package/dist/use-i18n-resource-version.js +42 -0
  30. package/package.json +3 -3
  31. package/src/__tests__/license-gate.test.tsx +242 -0
  32. package/src/__tests__/wizard-action-modal.test.tsx +121 -0
  33. package/src/action-modal-dispatcher.tsx +233 -0
  34. package/src/dynamic-kanban.tsx +6 -0
  35. package/src/index.ts +15 -0
  36. package/src/license/index.ts +21 -0
  37. package/src/license/license-expiry-banner.tsx +151 -0
  38. package/src/license/license-gate.tsx +277 -0
  39. package/src/license/license-status-badge.tsx +93 -0
  40. package/src/license/types.ts +90 -0
  41. package/src/types.ts +9 -0
  42. package/src/use-i18n-resource-version.ts +42 -0
  43. package/LICENSE +0 -201
@@ -0,0 +1,121 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // WizardActionModal contract (the third render-path of ActionModalDispatcher):
4
+ // when an action declares `steps: [{title, fields[]}]`, the dispatcher renders a
5
+ // multi-step wizard instead of the single-page GenericActionModal. It:
6
+ // - shows only the current step's fields,
7
+ // - gates advancing on the current step's required fields,
8
+ // - accumulates every step's values and POSTs all of them, once, on submit.
9
+ //
10
+ // happy-dom gotcha: Radix Select resets its value in this environment, so these
11
+ // tests gate on visible text/behavior (which fields render, what POST body is
12
+ // sent), never on a Select's value.
13
+ import { afterEach, describe, expect, it, vi } from 'vitest'
14
+ import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'
15
+
16
+ vi.mock('react-i18next', () => ({
17
+ useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k }),
18
+ }))
19
+
20
+ import { ActionModalDispatcher } from '../action-modal-dispatcher'
21
+ import { ApiProvider, type ApiClient } from '../api-context'
22
+ import type { ActionMetadata } from '@asteby/metacore-sdk'
23
+
24
+ afterEach(cleanup)
25
+
26
+ function makeApi() {
27
+ const post = vi.fn().mockResolvedValue({ data: { success: true } })
28
+ const api = {
29
+ get: vi.fn().mockResolvedValue({ data: {} }),
30
+ post,
31
+ } as unknown as ApiClient
32
+ return { api, post }
33
+ }
34
+
35
+ const action: ActionMetadata = {
36
+ key: 'workshop_checklist',
37
+ label: 'Checklist de taller',
38
+ icon: 'wrench',
39
+ steps: [
40
+ {
41
+ title: 'Recepción',
42
+ fields: [
43
+ { key: 'plate', label: 'Placa', type: 'text', required: true },
44
+ { key: 'mileage', label: 'Kilometraje', type: 'number' },
45
+ ],
46
+ },
47
+ {
48
+ title: 'Diagnóstico',
49
+ fields: [{ key: 'notes', label: 'Notas', type: 'textarea', required: true }],
50
+ },
51
+ ],
52
+ } as unknown as ActionMetadata
53
+
54
+ function renderModal(api: ApiClient) {
55
+ return render(
56
+ <ApiProvider client={api}>
57
+ <ActionModalDispatcher
58
+ open
59
+ onOpenChange={() => {}}
60
+ action={action}
61
+ model="service_order"
62
+ record={{ id: '42' } as any}
63
+ endpoint="/data/service_order"
64
+ onSuccess={() => {}}
65
+ />
66
+ </ApiProvider>,
67
+ )
68
+ }
69
+
70
+ describe('WizardActionModal', () => {
71
+ it('renders only the first step initially and shows a progress indicator', () => {
72
+ const { api } = makeApi()
73
+ renderModal(api)
74
+ // First step fields present, later step field absent.
75
+ expect(screen.getByText('Placa')).toBeTruthy()
76
+ expect(screen.queryByText('Notas')).toBeNull()
77
+ // Progress label "Paso 1/2".
78
+ expect(screen.getByText(/1\/2/)).toBeTruthy()
79
+ })
80
+
81
+ it('blocks advancing when the current step has an unmet required field', () => {
82
+ const { api } = makeApi()
83
+ renderModal(api)
84
+ fireEvent.click(screen.getByText('Siguiente'))
85
+ // Still on step 1 (Notas not shown) because Placa is empty+required.
86
+ expect(screen.queryByText('Notas')).toBeNull()
87
+ })
88
+
89
+ it('advances to the next step once required fields pass', () => {
90
+ const { api } = makeApi()
91
+ renderModal(api)
92
+ fireEvent.change(document.getElementById('plate') as HTMLInputElement, {
93
+ target: { value: 'ABC-123' },
94
+ })
95
+ fireEvent.click(screen.getByText('Siguiente'))
96
+ expect(screen.getByText('Notas')).toBeTruthy()
97
+ // Back returns to step 1 with the value retained.
98
+ fireEvent.click(screen.getByText('Atrás'))
99
+ expect((document.getElementById('plate') as HTMLInputElement).value).toBe('ABC-123')
100
+ })
101
+
102
+ it('submits ALL accumulated fields once, to the record action endpoint', async () => {
103
+ const { api, post } = makeApi()
104
+ renderModal(api)
105
+ fireEvent.change(document.getElementById('plate') as HTMLInputElement, {
106
+ target: { value: 'ABC-123' },
107
+ })
108
+ fireEvent.click(screen.getByText('Siguiente'))
109
+ fireEvent.change(document.getElementById('notes') as HTMLTextAreaElement, {
110
+ target: { value: 'frenos' },
111
+ })
112
+ // Final step shows the action label as the submit button (the label also
113
+ // appears in the dialog title, so click the last match — the button).
114
+ const labels = screen.getAllByText('Checklist de taller')
115
+ fireEvent.click(labels[labels.length - 1])
116
+ await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
117
+ const [url, body] = post.mock.calls[0]
118
+ expect(url).toBe('/data/service_order/42/action/workshop_checklist')
119
+ expect(body).toMatchObject({ plate: 'ABC-123', notes: 'frenos' })
120
+ })
121
+ })
@@ -170,6 +170,20 @@ export function ActionModalDispatcher({
170
170
  )
171
171
  }
172
172
 
173
+ if (action.steps && action.steps.length > 0) {
174
+ return (
175
+ <WizardActionModal
176
+ open={open}
177
+ onOpenChange={onOpenChange}
178
+ action={action}
179
+ model={model}
180
+ record={record}
181
+ endpoint={endpoint}
182
+ onSuccess={onSuccess}
183
+ />
184
+ )
185
+ }
186
+
173
187
  if (action.fields && action.fields.length > 0) {
174
188
  return (
175
189
  <GenericActionModal
@@ -447,6 +461,225 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
447
461
  )
448
462
  }
449
463
 
464
+ // buildFieldDefaults seeds formData for a set of action fields, honoring the
465
+ // same line-items prefill spec + boolean/empty rules GenericActionModal uses, so
466
+ // wizard steps and single-page forms initialize identically.
467
+ function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<string, any> {
468
+ const defaults: Record<string, any> = {}
469
+ for (const field of fields) {
470
+ if (isLineItemsField(field)) {
471
+ const dv = lineItemsDefault(field)
472
+ defaults[field.key] = isPrefillSpec(dv)
473
+ ? buildPrefillRows(dv, record)
474
+ : Array.isArray(dv)
475
+ ? dv
476
+ : []
477
+ continue
478
+ }
479
+ defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '')
480
+ }
481
+ return defaults
482
+ }
483
+
484
+ // validateFields returns the first validation error for a set of required
485
+ // fields, or null when they all pass. Shared by the wizard (per-step gate) and
486
+ // mirrors GenericActionModal's inline checks. `tl` localizes the field label.
487
+ function validateFields(
488
+ fields: ActionFieldDef[],
489
+ formData: Record<string, any>,
490
+ tl: (s: string) => string,
491
+ ): string | null {
492
+ for (const field of fields) {
493
+ if (!field.required) continue
494
+ if (isLineItemsField(field)) {
495
+ const rows = formData[field.key]
496
+ if (!Array.isArray(rows) || rows.length === 0) {
497
+ return `${tl(field.label)} requiere al menos un renglón`
498
+ }
499
+ continue
500
+ }
501
+ if (!formData[field.key] && formData[field.key] !== false) {
502
+ return `${tl(field.label)} es requerido`
503
+ }
504
+ }
505
+ return null
506
+ }
507
+
508
+ // WizardActionModal — the third render-path: a multi-step form. It accumulates
509
+ // every step's fields into ONE formData object and, on the final step, POSTs all
510
+ // of them to the same endpoint GenericActionModal uses (buildActionUrl). Each
511
+ // step is validated before "Siguiente" advances; a progress/step bar shows where
512
+ // the user is. Widgets are rendered by the same renderField/resolveWidget path,
513
+ // so line-items, dynamic_select, uploads and dates behave identically to a
514
+ // single-page action form — no widget is duplicated.
515
+ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
516
+ const { t } = useTranslation()
517
+ const tl = (s: string) => t(s, { defaultValue: s })
518
+ const api = useApi()
519
+ const steps = action.steps ?? []
520
+ const [stepIndex, setStepIndex] = useState(0)
521
+ const [formData, setFormData] = useState<Record<string, any>>({})
522
+ const [executing, setExecuting] = useState(false)
523
+
524
+ // Reset to the first step and seed defaults for EVERY step's fields whenever
525
+ // the modal (re)opens, so accumulated values from a prior run don't leak.
526
+ useEffect(() => {
527
+ if (!open) return
528
+ const allFields = steps.flatMap((s) => s.fields ?? [])
529
+ setFormData(buildFieldDefaults(allFields, record))
530
+ setStepIndex(0)
531
+ }, [open, action.steps, record])
532
+
533
+ const updateField = (key: string, value: any) =>
534
+ setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
535
+
536
+ const step = steps[stepIndex]
537
+ const isLast = stepIndex === steps.length - 1
538
+ const stepFields = step?.fields ?? []
539
+
540
+ const hasLineItems = useMemo(
541
+ () => stepFields.some(isLineItemsField),
542
+ [stepFields],
543
+ )
544
+ const widthPx = hasLineItems ? '820px' : undefined
545
+
546
+ const goNext = () => {
547
+ const err = validateFields(stepFields, formData, tl)
548
+ if (err) {
549
+ toast.error(err)
550
+ return
551
+ }
552
+ setStepIndex((i) => Math.min(i + 1, steps.length - 1))
553
+ }
554
+
555
+ const goBack = () => setStepIndex((i) => Math.max(i - 1, 0))
556
+
557
+ const submit = async () => {
558
+ // Guard every step's required fields on final submit (a user could reach
559
+ // the last step with an untouched earlier line-items grid otherwise).
560
+ for (const s of steps) {
561
+ const err = validateFields(s.fields ?? [], formData, tl)
562
+ if (err) {
563
+ toast.error(err)
564
+ return
565
+ }
566
+ }
567
+ setExecuting(true)
568
+ try {
569
+ const url = buildActionUrl(endpoint, model, record?.id, action.key)
570
+ const res = await api.post(url, formData)
571
+ if (res.data.success) {
572
+ toast.success(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.success'))
573
+ onOpenChange(false)
574
+ onSuccess()
575
+ } else {
576
+ toast.error(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.error'))
577
+ }
578
+ } catch (err: any) {
579
+ toast.error(err?.response?.data?.message || t('common.error'))
580
+ } finally {
581
+ setExecuting(false)
582
+ }
583
+ }
584
+
585
+ if (steps.length === 0) return null
586
+
587
+ return (
588
+ <Dialog open={open} onOpenChange={onOpenChange}>
589
+ <DialogContent
590
+ className={'flex max-h-[90vh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-xl')}
591
+ style={{ maxHeight: '90vh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }}
592
+ >
593
+ <DialogHeader className="shrink-0">
594
+ <DialogTitle className="flex items-center gap-2">
595
+ <DynamicIcon name={action.icon} className="h-5 w-5" />
596
+ {tl(action.label)}
597
+ </DialogTitle>
598
+ {/* Progress/step bar: one segment per step. The current and
599
+ completed segments are filled; a numbered marker + the
600
+ current step's title tell the user where they are. Inline
601
+ styles for the fill color guarantee it shows even if the
602
+ host's Tailwind scan drops an arbitrary class. */}
603
+ <div className="pt-2">
604
+ <div className="flex items-center gap-1.5" role="list" aria-label="progress">
605
+ {steps.map((s, i) => (
606
+ <div
607
+ key={i}
608
+ role="listitem"
609
+ aria-current={i === stepIndex ? 'step' : undefined}
610
+ className="h-1.5 flex-1 rounded-full"
611
+ style={{
612
+ backgroundColor: i <= stepIndex ? (action.color || 'hsl(var(--primary))') : 'hsl(var(--muted))',
613
+ }}
614
+ />
615
+ ))}
616
+ </div>
617
+ <DialogDescription className="pt-2">
618
+ {t('common.step', { defaultValue: 'Paso' })} {stepIndex + 1}/{steps.length}
619
+ {step?.title ? ` · ${tl(step.title)}` : ''}
620
+ </DialogDescription>
621
+ {step?.description && (
622
+ <p className="pt-1 text-sm text-muted-foreground">{tl(step.description)}</p>
623
+ )}
624
+ </div>
625
+ </DialogHeader>
626
+ {/* Body: only the current step's fields, laid out on the same
627
+ shared FieldGrid the single-page form uses. Every step's values
628
+ persist in the one formData object, so navigating back and
629
+ forth keeps entries intact. */}
630
+ <div className="-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4">
631
+ <FieldGrid>
632
+ {stepFields.map((field) => {
633
+ const fullWidth =
634
+ isLineItemsField(field) ||
635
+ resolveWidget(field) === 'textarea' ||
636
+ resolveWidget(field) === 'richtext'
637
+ return (
638
+ <FieldCell key={field.key} fullWidth={fullWidth}>
639
+ <FieldLabel htmlFor={field.key} required={field.required}>
640
+ {tl(field.label)}
641
+ </FieldLabel>
642
+ {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData)}
643
+ </FieldCell>
644
+ )
645
+ })}
646
+ </FieldGrid>
647
+ </div>
648
+ <DialogFooter className="shrink-0">
649
+ {/* Back is available from the second step on; Cancel closes. */}
650
+ {stepIndex > 0 ? (
651
+ <Button variant="outline" onClick={goBack} disabled={executing}>
652
+ {t('common.back', { defaultValue: 'Atrás' })}
653
+ </Button>
654
+ ) : (
655
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={executing}>
656
+ {t('common.cancel')}
657
+ </Button>
658
+ )}
659
+ {isLast ? (
660
+ <Button
661
+ onClick={submit}
662
+ disabled={executing}
663
+ style={action.color ? { backgroundColor: action.color, color: 'white' } : undefined}
664
+ >
665
+ {executing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <DynamicIcon name={action.icon} className="mr-2 h-4 w-4" />}
666
+ {tl(action.label)}
667
+ </Button>
668
+ ) : (
669
+ <Button
670
+ onClick={goNext}
671
+ disabled={executing}
672
+ style={action.color ? { backgroundColor: action.color, color: 'white' } : undefined}
673
+ >
674
+ {t('common.next', { defaultValue: 'Siguiente' })}
675
+ </Button>
676
+ )}
677
+ </DialogFooter>
678
+ </DialogContent>
679
+ </Dialog>
680
+ )
681
+ }
682
+
450
683
  function renderField(
451
684
  field: ActionFieldDef,
452
685
  value: any,
@@ -27,6 +27,7 @@
27
27
  import * as React from 'react'
28
28
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
29
29
  import { useTranslation } from 'react-i18next'
30
+ import { useI18nResourceVersion } from './use-i18n-resource-version'
30
31
  import {
31
32
  DndContext,
32
33
  DragOverlay,
@@ -1919,6 +1920,11 @@ function KanbanLane({
1919
1920
  children,
1920
1921
  }: KanbanLaneProps) {
1921
1922
  const { t } = useTranslation()
1923
+ // Re-resolve the lane label when an addon i18n bundle lands after the board
1924
+ // painted (async addResourceBundle) — otherwise a manifest-key label like
1925
+ // "integration_github.stage.in_progress" stays raw until an unrelated
1926
+ // re-render. Independent of the host's react-i18next bindI18nStore config.
1927
+ useI18nResourceVersion()
1922
1928
  // Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
1923
1929
  // container; a load in flight or an exhausted stage disables it.
1924
1930
  const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
package/src/index.ts CHANGED
@@ -4,6 +4,21 @@
4
4
  // duplicate-symbol conflict; consumers who want the canonical SDK type
5
5
  // should import from `@asteby/metacore-sdk` directly.
6
6
  export * from './types'
7
+ export {
8
+ LicenseGate,
9
+ LicenseExpiryBanner,
10
+ LicenseStatusBadge,
11
+ isLicenseOperable,
12
+ isLicenseBlocking,
13
+ isPresetEntitled,
14
+ isTrialExpired,
15
+ type LicenseGateProps,
16
+ type LicenseExpiryBannerProps,
17
+ type LicenseStatusBadgeProps,
18
+ type LicenseState,
19
+ type LicenseStatus,
20
+ type LicenseBranding,
21
+ } from './license'
7
22
  export * from './options-context'
8
23
  export * from './dynamic-table'
9
24
  export {
@@ -0,0 +1,21 @@
1
+ export {
2
+ LicenseGate,
3
+ type LicenseGateProps,
4
+ } from './license-gate'
5
+ export {
6
+ LicenseExpiryBanner,
7
+ type LicenseExpiryBannerProps,
8
+ } from './license-expiry-banner'
9
+ export {
10
+ LicenseStatusBadge,
11
+ type LicenseStatusBadgeProps,
12
+ } from './license-status-badge'
13
+ export {
14
+ isLicenseOperable,
15
+ isLicenseBlocking,
16
+ isPresetEntitled,
17
+ isTrialExpired,
18
+ type LicenseState,
19
+ type LicenseStatus,
20
+ type LicenseBranding,
21
+ } from './types'
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Banner de vencimiento de licencia — la señal degradada que acompaña a las
3
+ * posturas que NO bloquean la app pero exigen atención:
4
+ * - valid && days_remaining <= threshold → aviso de renovación próxima
5
+ * - stale → check-in con el hub pendiente
6
+ * - grace → vencida, dentro de la gracia
7
+ * - expired → vencida en duro (persistente)
8
+ *
9
+ * Silencioso sin enforcement o si el estado aún no cargó — nunca rompe el shell.
10
+ *
11
+ * Descartabilidad: todas las posturas son descartables con TTL en localStorage
12
+ * (re-aparecen pasado el TTL) MENOS `expired`, que se queda fijo como recordatorio
13
+ * porque el backend ya bloquea escrituras nuevas.
14
+ */
15
+ import { useState } from 'react'
16
+ import { useTranslation } from 'react-i18next'
17
+ import { AlertTriangle, Clock, CloudOff, X } from 'lucide-react'
18
+ import { Button } from '@asteby/metacore-ui'
19
+ import type { LicenseState } from './types'
20
+
21
+ const DISMISS_KEY = 'license_banner_dismissed_at'
22
+ const DISMISS_TTL_MS = 12 * 60 * 60 * 1000 // re-mostrar cada 12h aunque se descarte
23
+ const UPCOMING_THRESHOLD_DAYS = 15
24
+
25
+ function cx(...parts: Array<string | false | undefined>): string {
26
+ return parts.filter(Boolean).join(' ')
27
+ }
28
+
29
+ export interface LicenseExpiryBannerProps {
30
+ state: LicenseState | undefined
31
+ /** Días de anticipación para el aviso de renovación. Default 15. */
32
+ upcomingThresholdDays?: number
33
+ /** Clave localStorage para recordar el descarte. */
34
+ dismissStorageKey?: string
35
+ /** Acción "Gestionar licencia" — el host enruta a sus Ajustes. Si se omite,
36
+ * no se muestra el botón (el gate no asume rutas del host). */
37
+ onManage?: () => void
38
+ /** Etiqueta del botón de gestión (default localizado). */
39
+ manageLabel?: string
40
+ className?: string
41
+ }
42
+
43
+ export function LicenseExpiryBanner({
44
+ state,
45
+ upcomingThresholdDays = UPCOMING_THRESHOLD_DAYS,
46
+ dismissStorageKey = DISMISS_KEY,
47
+ onManage,
48
+ manageLabel,
49
+ className,
50
+ }: LicenseExpiryBannerProps) {
51
+ const { t } = useTranslation()
52
+ const [dismissed, setDismissed] = useState(() => {
53
+ if (typeof window === 'undefined') return false
54
+ const raw = window.localStorage.getItem(dismissStorageKey)
55
+ if (!raw) return false
56
+ return Date.now() - Number(raw) < DISMISS_TTL_MS
57
+ })
58
+
59
+ if (!state || !state.enforced) return null
60
+
61
+ const isUpcoming =
62
+ state.status === 'valid' && state.days_remaining <= upcomingThresholdDays
63
+ const isStale = state.status === 'stale'
64
+ const isGrace = state.status === 'grace'
65
+ const isExpired = state.status === 'expired'
66
+ if (!isUpcoming && !isStale && !isGrace && !isExpired) return null
67
+
68
+ // Expired no es descartable — se queda como recordatorio constante.
69
+ if (dismissed && !isExpired) return null
70
+
71
+ const dismiss = () => {
72
+ setDismissed(true)
73
+ try {
74
+ window.localStorage.setItem(dismissStorageKey, String(Date.now()))
75
+ } catch {
76
+ // cuota/entorno sin storage — el descarte solo dura la sesión.
77
+ }
78
+ }
79
+
80
+ const message = isExpired
81
+ ? t('license.banner.expired', {
82
+ defaultValue:
83
+ 'Tu licencia venció. Algunas acciones pueden estar bloqueadas.',
84
+ })
85
+ : isStale
86
+ ? t('license.banner.stale', {
87
+ defaultValue:
88
+ 'Esta instancia no ha podido verificar su licencia con el hub. Reconéctala para completar el check-in.',
89
+ })
90
+ : isGrace
91
+ ? t('license.banner.grace', {
92
+ defaultValue:
93
+ 'Tu licencia venció y está en periodo de gracia hasta {{date}} ({{days}} días restantes).',
94
+ date: state.grace_until
95
+ ? new Date(state.grace_until).toLocaleDateString()
96
+ : '—',
97
+ days: state.days_remaining,
98
+ })
99
+ : t('license.banner.upcoming', {
100
+ defaultValue: 'Tu licencia vence en {{days}} días.',
101
+ days: state.days_remaining,
102
+ })
103
+
104
+ const Icon = isExpired ? AlertTriangle : isStale ? CloudOff : Clock
105
+
106
+ return (
107
+ <div
108
+ role="alert"
109
+ data-license-status={state.status}
110
+ className={cx(
111
+ 'flex items-center justify-between gap-3 border-b px-4 py-2.5 text-sm',
112
+ isExpired
113
+ ? 'border-destructive/30 bg-destructive text-white'
114
+ : 'border-amber-500/30 bg-amber-500/10 text-foreground',
115
+ className,
116
+ )}
117
+ >
118
+ <div className="flex min-w-0 items-center gap-2">
119
+ <Icon className="h-4 w-4 shrink-0" aria-hidden />
120
+ <span className="truncate">{message}</span>
121
+ </div>
122
+ <div className="flex shrink-0 items-center gap-2">
123
+ {onManage && (
124
+ <Button
125
+ size="sm"
126
+ variant={isExpired ? 'secondary' : 'outline'}
127
+ onClick={onManage}
128
+ >
129
+ {manageLabel ??
130
+ t('license.banner.manage', {
131
+ defaultValue: 'Gestionar licencia',
132
+ })}
133
+ </Button>
134
+ )}
135
+ {!isExpired && (
136
+ <Button
137
+ size="icon"
138
+ variant="ghost"
139
+ className="h-7 w-7"
140
+ onClick={dismiss}
141
+ title={t('license.banner.dismiss', {
142
+ defaultValue: 'Descartar',
143
+ })}
144
+ >
145
+ <X className="h-4 w-4" />
146
+ </Button>
147
+ )}
148
+ </div>
149
+ </div>
150
+ )
151
+ }