@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +13 -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/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/license/index.d.ts +5 -0
  9. package/dist/license/index.d.ts.map +1 -0
  10. package/dist/license/index.js +4 -0
  11. package/dist/license/license-expiry-banner.d.ts +16 -0
  12. package/dist/license/license-expiry-banner.d.ts.map +1 -0
  13. package/dist/license/license-expiry-banner.js +85 -0
  14. package/dist/license/license-gate.d.ts +40 -0
  15. package/dist/license/license-gate.d.ts.map +1 -0
  16. package/dist/license/license-gate.js +108 -0
  17. package/dist/license/license-status-badge.d.ts +7 -0
  18. package/dist/license/license-status-badge.d.ts.map +1 -0
  19. package/dist/license/license-status-badge.js +57 -0
  20. package/dist/license/types.d.ts +63 -0
  21. package/dist/license/types.d.ts.map +1 -0
  22. package/dist/license/types.js +45 -0
  23. package/dist/types.d.ts +8 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/__tests__/license-gate.test.tsx +242 -0
  27. package/src/__tests__/wizard-action-modal.test.tsx +121 -0
  28. package/src/action-modal-dispatcher.tsx +233 -0
  29. package/src/index.ts +15 -0
  30. package/src/license/index.ts +21 -0
  31. package/src/license/license-expiry-banner.tsx +151 -0
  32. package/src/license/license-gate.tsx +277 -0
  33. package/src/license/license-status-badge.tsx +93 -0
  34. package/src/license/types.ts +90 -0
  35. package/src/types.ts +9 -0
  36. 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
+ }