@ismaelsoilet/synapsejs 1.8.0 → 1.11.1

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 (55) hide show
  1. package/README.md +98 -6
  2. package/bin/synapse.ts +291 -241
  3. package/package.json +2 -2
  4. package/src/client/components.tsx +459 -1
  5. package/src/client/context.tsx +23 -3
  6. package/src/client/error-boundary.tsx +59 -0
  7. package/src/client/hooks.ts +100 -1
  8. package/src/client/toast.tsx +219 -0
  9. package/src/client/upload.ts +153 -0
  10. package/src/client.ts +37 -2
  11. package/src/compiler/client-bundler.ts +94 -2
  12. package/src/compiler/context-bench.ts +9 -1
  13. package/src/compiler/contract.ts +31 -4
  14. package/src/compiler/db-schema-generator.ts +39 -8
  15. package/src/compiler/fields-parser.ts +28 -1
  16. package/src/compiler/impact-analyzer.ts +31 -1
  17. package/src/compiler/migration-runner.ts +10 -2
  18. package/src/compiler/optional-coverage.ts +137 -0
  19. package/src/compiler/oracle-runner.ts +18 -4
  20. package/src/compiler/scaffolder.ts +227 -9
  21. package/src/compiler/slice-discovery.ts +8 -1
  22. package/src/compiler/slice-splitter.ts +68 -6
  23. package/src/compiler/slice-templates.ts +234 -10
  24. package/src/compiler/typescript-api.ts +33 -0
  25. package/src/core/action-context.ts +101 -15
  26. package/src/core/config.ts +88 -10
  27. package/src/core/cron-parser.ts +167 -0
  28. package/src/core/database-client.ts +103 -6
  29. package/src/core/index.ts +5 -0
  30. package/src/core/jobs.ts +37 -0
  31. package/src/core/oauth-client.ts +322 -0
  32. package/src/core/path-guard.ts +105 -0
  33. package/src/core/postgres-client.ts +37 -0
  34. package/src/core/postgres-queue.ts +76 -9
  35. package/src/core/query-builder.ts +166 -34
  36. package/src/core/session-context.ts +45 -4
  37. package/src/core/session-token.ts +124 -8
  38. package/src/core/sqlite-client.ts +20 -0
  39. package/src/core/storage.ts +60 -19
  40. package/src/core/topics.ts +84 -0
  41. package/src/core/webhook-signature.ts +151 -0
  42. package/src/index.ts +2 -1
  43. package/src/mcp/server.ts +317 -218
  44. package/src/mcp/tools.ts +329 -0
  45. package/src/project-status.ts +433 -0
  46. package/src/runtime/client-entry.ts +11 -5
  47. package/src/runtime/network-guard.ts +3 -1
  48. package/src/runtime/queue-engine.ts +167 -5
  49. package/src/runtime/rate-limiter.ts +14 -4
  50. package/src/runtime/security-headers.ts +35 -0
  51. package/src/runtime/server.ts +1174 -236
  52. package/src/runtime/uploads.ts +199 -19
  53. package/src/version.ts +9 -2
  54. package/templates/starter/package.json +2 -2
  55. package/templates/starter/tsconfig.json +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ismaelsoilet/synapsejs",
3
- "version": "1.8.0",
3
+ "version": "1.11.1",
4
4
  "description": "Bun-only fullstack framework where a feature is a single contiguous file",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -56,7 +56,7 @@
56
56
  "postgres": "^3.4.9",
57
57
  "react": "^19.0.0",
58
58
  "react-dom": "^19.0.0",
59
- "typescript": "^5.8.2"
59
+ "typescript": "~5.9.3"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/bun": "^1.4.2",
@@ -1,4 +1,13 @@
1
- import React, { type ReactNode, useMemo, useState } from 'react';
1
+ import React, {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState
10
+ } from 'react';
2
11
 
3
12
  // ============================================================================
4
13
  // 1. BADGE COMPONENT
@@ -523,3 +532,452 @@ export function DataForm({
523
532
  </form>
524
533
  );
525
534
  }
535
+
536
+ // ============================================================================
537
+ // 7. MODAL / DIALOG COMPONENT (WAI-ARIA COMPLIANT)
538
+ // ============================================================================
539
+ export interface ModalProps {
540
+ isOpen: boolean;
541
+ onClose: () => void;
542
+ title?: ReactNode;
543
+ description?: ReactNode;
544
+ size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
545
+ children?: ReactNode;
546
+ footer?: ReactNode;
547
+ className?: string;
548
+ closeOnBackdrop?: boolean;
549
+ }
550
+
551
+ export function Modal({
552
+ isOpen,
553
+ onClose,
554
+ title,
555
+ description,
556
+ size = 'md',
557
+ children,
558
+ footer,
559
+ className = '',
560
+ closeOnBackdrop = true
561
+ }: ModalProps) {
562
+ const dialogRef = useRef<HTMLDivElement>(null);
563
+
564
+ useEffect(() => {
565
+ if (!isOpen) return;
566
+
567
+ const handleKeyDown = (e: KeyboardEvent) => {
568
+ if (e.key === 'Escape') {
569
+ e.stopPropagation();
570
+ onClose();
571
+ }
572
+ };
573
+
574
+ const previousOverflow = document.body.style.overflow;
575
+ document.body.style.overflow = 'hidden';
576
+ window.addEventListener('keydown', handleKeyDown);
577
+
578
+ return () => {
579
+ document.body.style.overflow = previousOverflow;
580
+ window.removeEventListener('keydown', handleKeyDown);
581
+ };
582
+ }, [isOpen, onClose]);
583
+
584
+ if (!isOpen) return null;
585
+
586
+ const sizeStyles = {
587
+ sm: 'max-w-sm',
588
+ md: 'max-w-lg',
589
+ lg: 'max-w-2xl',
590
+ xl: 'max-w-4xl',
591
+ full: 'max-w-[95vw] h-[90vh]'
592
+ };
593
+
594
+ return (
595
+ <div
596
+ className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-slate-950/70 backdrop-blur-sm animate-in fade-in duration-200"
597
+ style={{ position: 'fixed', top: 0, right: 0, bottom: 0, left: 0, zIndex: 50 }}
598
+ onClick={closeOnBackdrop ? onClose : undefined}
599
+ onKeyDown={(e) => {
600
+ if (e.key === 'Escape') onClose();
601
+ }}
602
+ aria-modal="true"
603
+ role="dialog"
604
+ tabIndex={-1}
605
+ >
606
+ <div
607
+ ref={dialogRef}
608
+ onClick={(e) => e.stopPropagation()}
609
+ onKeyDown={(e) => e.stopPropagation()}
610
+ role="document"
611
+ className={`bg-slate-900 border border-slate-800 rounded-2xl shadow-2xl flex flex-col w-full overflow-hidden transition-all duration-200 transform scale-100 ${sizeStyles[size]} ${className}`}
612
+ >
613
+ {(title || description) && (
614
+ <div className="flex items-start justify-between p-5 border-b border-slate-800">
615
+ <div>
616
+ {title && <h3 className="text-lg font-bold text-slate-100 tracking-tight">{title}</h3>}
617
+ {description && <p className="text-xs text-slate-400 mt-1">{description}</p>}
618
+ </div>
619
+ <button
620
+ type="button"
621
+ onClick={onClose}
622
+ aria-label="Fechar diálogo"
623
+ className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors text-base font-mono leading-none"
624
+ >
625
+ ✕
626
+ </button>
627
+ </div>
628
+ )}
629
+ <div className="p-5 overflow-y-auto flex-1 text-slate-200 text-sm leading-relaxed">{children}</div>
630
+ {footer && (
631
+ <div className="p-4 border-t border-slate-800 bg-slate-950/40 flex items-center justify-end gap-2">
632
+ {footer}
633
+ </div>
634
+ )}
635
+ </div>
636
+ </div>
637
+ );
638
+ }
639
+
640
+ // ============================================================================
641
+ // 8. DRAWER / SLIDE-OVER SHEET COMPONENT
642
+ // ============================================================================
643
+ export interface DrawerProps {
644
+ isOpen: boolean;
645
+ onClose: () => void;
646
+ title?: ReactNode;
647
+ description?: ReactNode;
648
+ position?: 'left' | 'right';
649
+ size?: 'sm' | 'md' | 'lg' | 'full';
650
+ children?: ReactNode;
651
+ footer?: ReactNode;
652
+ className?: string;
653
+ closeOnBackdrop?: boolean;
654
+ }
655
+
656
+ export function Drawer({
657
+ isOpen,
658
+ onClose,
659
+ title,
660
+ description,
661
+ position = 'right',
662
+ size = 'md',
663
+ children,
664
+ footer,
665
+ className = '',
666
+ closeOnBackdrop = true
667
+ }: DrawerProps) {
668
+ const drawerRef = useRef<HTMLDivElement>(null);
669
+
670
+ useEffect(() => {
671
+ if (!isOpen) return;
672
+
673
+ const handleKeyDown = (e: KeyboardEvent) => {
674
+ if (e.key === 'Escape') {
675
+ e.stopPropagation();
676
+ onClose();
677
+ }
678
+ };
679
+
680
+ const previousOverflow = document.body.style.overflow;
681
+ document.body.style.overflow = 'hidden';
682
+ window.addEventListener('keydown', handleKeyDown);
683
+
684
+ return () => {
685
+ document.body.style.overflow = previousOverflow;
686
+ window.removeEventListener('keydown', handleKeyDown);
687
+ };
688
+ }, [isOpen, onClose]);
689
+
690
+ if (!isOpen) return null;
691
+
692
+ const sizeStyles = {
693
+ sm: 'max-w-sm',
694
+ md: 'max-w-md',
695
+ lg: 'max-w-2xl',
696
+ full: 'max-w-full'
697
+ };
698
+
699
+ const posStyles = {
700
+ right: 'inset-y-0 right-0',
701
+ left: 'inset-y-0 left-0'
702
+ };
703
+
704
+ return (
705
+ <div
706
+ className="fixed inset-0 z-50 flex bg-slate-950/70 backdrop-blur-sm animate-in fade-in duration-200"
707
+ style={{ position: 'fixed', top: 0, right: 0, bottom: 0, left: 0, zIndex: 50 }}
708
+ onClick={closeOnBackdrop ? onClose : undefined}
709
+ onKeyDown={(e) => {
710
+ if (e.key === 'Escape') onClose();
711
+ }}
712
+ aria-modal="true"
713
+ role="dialog"
714
+ tabIndex={-1}
715
+ >
716
+ <div
717
+ ref={drawerRef}
718
+ onClick={(e) => e.stopPropagation()}
719
+ onKeyDown={(e) => e.stopPropagation()}
720
+ role="document"
721
+ className={`fixed ${posStyles[position]} flex flex-col h-full w-full bg-slate-900 border-${position === 'right' ? 'l' : 'r'} border-slate-800 shadow-2xl overflow-hidden transition-transform duration-300 ${sizeStyles[size]} ${className}`}
722
+ style={{ position: 'fixed', top: 0, bottom: 0, [position]: 0 }}
723
+ >
724
+ <div className="flex items-start justify-between p-5 border-b border-slate-800">
725
+ <div>
726
+ {title && <h3 className="text-lg font-bold text-slate-100 tracking-tight">{title}</h3>}
727
+ {description && <p className="text-xs text-slate-400 mt-1">{description}</p>}
728
+ </div>
729
+ <button
730
+ type="button"
731
+ onClick={onClose}
732
+ aria-label="Fechar painel lateral"
733
+ className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors text-base font-mono leading-none"
734
+ >
735
+ ✕
736
+ </button>
737
+ </div>
738
+ <div className="p-5 overflow-y-auto flex-1 text-slate-200 text-sm leading-relaxed">{children}</div>
739
+ {footer && (
740
+ <div className="p-4 border-t border-slate-800 bg-slate-950/40 flex items-center justify-end gap-2">
741
+ {footer}
742
+ </div>
743
+ )}
744
+ </div>
745
+ </div>
746
+ );
747
+ }
748
+
749
+ // ============================================================================
750
+ // 9. TABS ACCESSIBLE COMPONENT
751
+ // ============================================================================
752
+ interface TabsContextValue {
753
+ value: string;
754
+ setValue: (val: string) => void;
755
+ }
756
+ const TabsContext = createContext<TabsContextValue | null>(null);
757
+
758
+ export interface TabsProps {
759
+ defaultValue?: string;
760
+ value?: string;
761
+ onValueChange?: (val: string) => void;
762
+ syncQueryParam?: string;
763
+ children?: ReactNode;
764
+ className?: string;
765
+ }
766
+
767
+ export function Tabs({
768
+ defaultValue = '',
769
+ value: controlledValue,
770
+ onValueChange,
771
+ syncQueryParam,
772
+ children,
773
+ className = ''
774
+ }: TabsProps) {
775
+ const [internalValue, setInternalValue] = useState<string>(() => {
776
+ if (typeof window !== 'undefined' && syncQueryParam) {
777
+ const sp = new URLSearchParams(window.location.search);
778
+ const fromUrl = sp.get(syncQueryParam);
779
+ if (fromUrl) return fromUrl;
780
+ }
781
+ return defaultValue;
782
+ });
783
+
784
+ const activeValue = controlledValue !== undefined ? controlledValue : internalValue;
785
+
786
+ const handleValueChange = useCallback(
787
+ (newVal: string) => {
788
+ if (controlledValue === undefined) {
789
+ setInternalValue(newVal);
790
+ }
791
+ onValueChange?.(newVal);
792
+
793
+ if (typeof window !== 'undefined' && syncQueryParam) {
794
+ const url = new URL(window.location.href);
795
+ url.searchParams.set(syncQueryParam, newVal);
796
+ window.history.replaceState({}, '', url.toString());
797
+ }
798
+ },
799
+ [controlledValue, onValueChange, syncQueryParam]
800
+ );
801
+
802
+ return (
803
+ <TabsContext.Provider value={{ value: activeValue, setValue: handleValueChange }}>
804
+ <div className={`space-y-4 ${className}`}>{children}</div>
805
+ </TabsContext.Provider>
806
+ );
807
+ }
808
+
809
+ export interface TabListProps {
810
+ children?: ReactNode;
811
+ className?: string;
812
+ }
813
+
814
+ export function TabList({ children, className = '' }: TabListProps) {
815
+ return (
816
+ <div
817
+ role="tablist"
818
+ className={`flex items-center gap-1.5 p-1 bg-slate-900 border border-slate-800 rounded-xl overflow-x-auto ${className}`}
819
+ >
820
+ {children}
821
+ </div>
822
+ );
823
+ }
824
+
825
+ export interface TabProps {
826
+ value: string;
827
+ children?: ReactNode;
828
+ disabled?: boolean;
829
+ className?: string;
830
+ }
831
+
832
+ export function Tab({ value, children, disabled = false, className = '' }: TabProps) {
833
+ const ctx = useContext(TabsContext);
834
+ if (!ctx) throw new Error('<Tab> must be used within <Tabs>');
835
+
836
+ const isSelected = ctx.value === value;
837
+
838
+ return (
839
+ <button
840
+ type="button"
841
+ role="tab"
842
+ aria-selected={isSelected}
843
+ disabled={disabled}
844
+ onClick={() => ctx.setValue(value)}
845
+ className={`px-3.5 py-1.5 text-xs font-semibold rounded-lg transition-all duration-150 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 whitespace-nowrap ${
846
+ isSelected
847
+ ? 'bg-slate-800 text-cyan-400 shadow-sm border border-slate-700/80 font-bold'
848
+ : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
849
+ } ${className}`}
850
+ >
851
+ {children}
852
+ </button>
853
+ );
854
+ }
855
+
856
+ export interface TabPanelProps {
857
+ value: string;
858
+ children?: ReactNode;
859
+ className?: string;
860
+ }
861
+
862
+ export function TabPanel({ value, children, className = '' }: TabPanelProps) {
863
+ const ctx = useContext(TabsContext);
864
+ if (!ctx) throw new Error('<TabPanel> must be used within <Tabs>');
865
+
866
+ if (ctx.value !== value) return null;
867
+
868
+ return (
869
+ <div role="tabpanel" className={`focus:outline-none ${className}`}>
870
+ {children}
871
+ </div>
872
+ );
873
+ }
874
+
875
+ // ============================================================================
876
+ // 10. DROPDOWN MENU ACCESSIBLE COMPONENT
877
+ // ============================================================================
878
+ export interface DropdownMenuProps {
879
+ trigger: ReactNode;
880
+ align?: 'left' | 'right';
881
+ children?: ReactNode;
882
+ className?: string;
883
+ }
884
+
885
+ export function DropdownMenu({ trigger, align = 'right', children, className = '' }: DropdownMenuProps) {
886
+ const [isOpen, setIsOpen] = useState(false);
887
+ const containerRef = useRef<HTMLDivElement>(null);
888
+
889
+ useEffect(() => {
890
+ if (!isOpen) return;
891
+
892
+ const handleClickOutside = (e: MouseEvent) => {
893
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
894
+ setIsOpen(false);
895
+ }
896
+ };
897
+
898
+ const handleKeyDown = (e: KeyboardEvent) => {
899
+ if (e.key === 'Escape') {
900
+ setIsOpen(false);
901
+ }
902
+ };
903
+
904
+ document.addEventListener('mousedown', handleClickOutside);
905
+ window.addEventListener('keydown', handleKeyDown);
906
+
907
+ return () => {
908
+ document.removeEventListener('mousedown', handleClickOutside);
909
+ window.removeEventListener('keydown', handleKeyDown);
910
+ };
911
+ }, [isOpen]);
912
+
913
+ const alignStyles = {
914
+ left: 'left-0',
915
+ right: 'right-0'
916
+ };
917
+
918
+ return (
919
+ <div ref={containerRef} className={`relative inline-block text-left ${className}`}>
920
+ {/* biome-ignore lint/a11y/useSemanticElements: custom trigger wrapper avoids button-inside-button HTML nesting */}
921
+ <div
922
+ role="button"
923
+ tabIndex={0}
924
+ onClick={() => setIsOpen((prev) => !prev)}
925
+ onKeyDown={(e) => {
926
+ if (e.key === 'Enter' || e.key === ' ') {
927
+ e.preventDefault();
928
+ setIsOpen((prev) => !prev);
929
+ }
930
+ }}
931
+ className="text-left bg-transparent border-0 p-0 m-0 cursor-pointer inline-block"
932
+ >
933
+ {trigger}
934
+ </div>
935
+ {isOpen && (
936
+ <div
937
+ role="menu"
938
+ aria-orientation="vertical"
939
+ tabIndex={-1}
940
+ className={`absolute ${alignStyles[align]} mt-2 w-52 rounded-xl bg-slate-900 border border-slate-800 shadow-2xl p-1 z-50 animate-in fade-in duration-150`}
941
+ >
942
+ {children}
943
+ </div>
944
+ )}
945
+ </div>
946
+ );
947
+ }
948
+
949
+ export interface DropdownItemProps {
950
+ onClick?: () => void;
951
+ variant?: 'default' | 'danger';
952
+ icon?: ReactNode;
953
+ disabled?: boolean;
954
+ children?: ReactNode;
955
+ className?: string;
956
+ }
957
+
958
+ export function DropdownItem({
959
+ onClick,
960
+ variant = 'default',
961
+ icon,
962
+ disabled = false,
963
+ children,
964
+ className = ''
965
+ }: DropdownItemProps) {
966
+ const variantStyles = {
967
+ default: 'text-slate-300 hover:text-white hover:bg-slate-800',
968
+ danger: 'text-rose-400 hover:text-rose-300 hover:bg-rose-950/50'
969
+ };
970
+
971
+ return (
972
+ <button
973
+ type="button"
974
+ role="menuitem"
975
+ disabled={disabled}
976
+ onClick={onClick}
977
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs font-medium rounded-lg transition-colors text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 ${variantStyles[variant]} ${className}`}
978
+ >
979
+ {icon && <span className="shrink-0">{icon}</span>}
980
+ <span className="flex-1 truncate">{children}</span>
981
+ </button>
982
+ );
983
+ }
@@ -1,9 +1,11 @@
1
1
  import { createContext, type ReactNode, useContext } from 'react';
2
2
  import type { SessionContext } from '../core/session-context';
3
+ import { SliceErrorBoundary, type SliceErrorBoundaryProps } from './error-boundary';
3
4
 
4
5
  export interface SynapseContextValue {
5
6
  props: Record<string, unknown>;
6
7
  session?: SessionContext;
8
+ url?: string;
7
9
  }
8
10
 
9
11
  const SynapseContext = createContext<SynapseContextValue | null>(null);
@@ -11,13 +13,24 @@ const SynapseContext = createContext<SynapseContextValue | null>(null);
11
13
  export interface SynapseProviderProps {
12
14
  props?: Record<string, unknown>;
13
15
  session?: SessionContext;
16
+ url?: string;
17
+ /** Defaults to true: a slice render error shows a fallback instead of a dead tree. */
18
+ errorBoundary?: boolean;
19
+ errorBoundaryFallback?: SliceErrorBoundaryProps['fallback'];
14
20
  children?: ReactNode;
15
21
  }
16
22
 
17
23
  /**
18
24
  * SynapseProvider delivers per-request SSR data isolation and seamless client hydration.
19
25
  */
20
- export function SynapseProvider({ props, session, children }: SynapseProviderProps) {
26
+ export function SynapseProvider({
27
+ props,
28
+ session,
29
+ url,
30
+ errorBoundary = true,
31
+ errorBoundaryFallback,
32
+ children
33
+ }: SynapseProviderProps) {
21
34
  // On client, if props not passed directly, hydrate from window.__SYNAPSE_PROPS__
22
35
  const clientProps =
23
36
  props ??
@@ -27,10 +40,17 @@ export function SynapseProvider({ props, session, children }: SynapseProviderPro
27
40
 
28
41
  const value: SynapseContextValue = {
29
42
  props: clientProps,
30
- session
43
+ session,
44
+ url
31
45
  };
32
46
 
33
- return <SynapseContext.Provider value={value}>{children}</SynapseContext.Provider>;
47
+ const content = errorBoundary ? (
48
+ <SliceErrorBoundary fallback={errorBoundaryFallback}>{children}</SliceErrorBoundary>
49
+ ) : (
50
+ children
51
+ );
52
+
53
+ return <SynapseContext.Provider value={value}>{content}</SynapseContext.Provider>;
34
54
  }
35
55
 
36
56
  export function useSynapseContext(): SynapseContextValue {
@@ -0,0 +1,59 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from 'react';
2
+
3
+ export interface SliceErrorBoundaryProps {
4
+ children: ReactNode;
5
+ fallback?: ReactNode | ((error: Error) => ReactNode);
6
+ onError?: (error: Error, info: ErrorInfo) => void;
7
+ }
8
+
9
+ interface SliceErrorBoundaryState {
10
+ hasError: boolean;
11
+ error: Error | null;
12
+ }
13
+
14
+ export class SliceErrorBoundary extends Component<SliceErrorBoundaryProps, SliceErrorBoundaryState> {
15
+ override state: SliceErrorBoundaryState = {
16
+ hasError: false,
17
+ error: null
18
+ };
19
+
20
+ static getDerivedStateFromError(error: Error): SliceErrorBoundaryState {
21
+ return { hasError: true, error };
22
+ }
23
+
24
+ override componentDidCatch(error: Error, info: ErrorInfo): void {
25
+ if (this.props.onError) {
26
+ this.props.onError(error, info);
27
+ } else {
28
+ console.error('[SliceErrorBoundary] Erro capturado na interface da fatia:', error, info);
29
+ }
30
+ }
31
+
32
+ override render(): ReactNode {
33
+ if (this.state.hasError && this.state.error) {
34
+ if (typeof this.props.fallback === 'function') {
35
+ return this.props.fallback(this.state.error);
36
+ }
37
+ if (this.props.fallback) {
38
+ return this.props.fallback;
39
+ }
40
+ return (
41
+ <div
42
+ role="alert"
43
+ style={{
44
+ padding: '1rem',
45
+ border: '1px solid #f43f5e',
46
+ borderRadius: '0.375rem',
47
+ background: 'rgba(244, 63, 94, 0.1)',
48
+ color: '#f43f5e',
49
+ fontFamily: 'system-ui, -apple-system, sans-serif'
50
+ }}
51
+ >
52
+ <strong style={{ display: 'block', marginBottom: '0.25rem' }}>Erro na interface da fatia:</strong>
53
+ <span>{this.state.error.message}</span>
54
+ </div>
55
+ );
56
+ }
57
+ return this.props.children;
58
+ }
59
+ }