@asteby/metacore-runtime-react 23.9.3 → 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.
- package/CHANGELOG.md +13 -0
- package/README.md +43 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +122 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/license/index.d.ts +5 -0
- package/dist/license/index.d.ts.map +1 -0
- package/dist/license/index.js +4 -0
- package/dist/license/license-expiry-banner.d.ts +16 -0
- package/dist/license/license-expiry-banner.d.ts.map +1 -0
- package/dist/license/license-expiry-banner.js +85 -0
- package/dist/license/license-gate.d.ts +40 -0
- package/dist/license/license-gate.d.ts.map +1 -0
- package/dist/license/license-gate.js +108 -0
- package/dist/license/license-status-badge.d.ts +7 -0
- package/dist/license/license-status-badge.d.ts.map +1 -0
- package/dist/license/license-status-badge.js +57 -0
- package/dist/license/types.d.ts +63 -0
- package/dist/license/types.d.ts.map +1 -0
- package/dist/license/types.js +45 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/license-gate.test.tsx +242 -0
- package/src/__tests__/wizard-action-modal.test.tsx +121 -0
- package/src/action-modal-dispatcher.tsx +233 -0
- package/src/index.ts +15 -0
- package/src/license/index.ts +21 -0
- package/src/license/license-expiry-banner.tsx +151 -0
- package/src/license/license-gate.tsx +277 -0
- package/src/license/license-status-badge.tsx +93 -0
- package/src/license/types.ts +90 -0
- package/src/types.ts +9 -0
- package/LICENSE +0 -201
|
@@ -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,
|
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
|
+
}
|