@exotic-holidays/ui 0.1.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/README.md +46 -0
- package/package.json +42 -0
- package/src/base-components/Accordion.tsx +561 -0
- package/src/base-components/Badge.tsx +191 -0
- package/src/base-components/Button.tsx +331 -0
- package/src/base-components/ButtonGroup.tsx +149 -0
- package/src/base-components/Card.tsx +250 -0
- package/src/base-components/Checkbox.tsx +49 -0
- package/src/base-components/ChipInput.tsx +208 -0
- package/src/base-components/CommonButton.tsx +33 -0
- package/src/base-components/DataTable.tsx +82 -0
- package/src/base-components/Divider.tsx +82 -0
- package/src/base-components/Dropdown.tsx +85 -0
- package/src/base-components/EmptyState.tsx +18 -0
- package/src/base-components/FilterPopover.tsx +50 -0
- package/src/base-components/Input.tsx +60 -0
- package/src/base-components/Modal.tsx +107 -0
- package/src/base-components/OtpVerificationModal.tsx +251 -0
- package/src/base-components/Pagination.tsx +51 -0
- package/src/base-components/PhoneInput.tsx +142 -0
- package/src/base-components/PopConfirm.tsx +350 -0
- package/src/base-components/SearchPopover.tsx +70 -0
- package/src/base-components/SearchableSelect.tsx +734 -0
- package/src/base-components/Select.tsx +49 -0
- package/src/base-components/Table.tsx +78 -0
- package/src/base-components/Textarea.tsx +45 -0
- package/src/base-components/ThemeProvider.tsx +92 -0
- package/src/base-components/Toaster.tsx +198 -0
- package/src/base-components/index.ts +32 -0
- package/src/components/DashboardLayout.tsx +326 -0
- package/src/components/ListPage.tsx +140 -0
- package/src/components/QuickAccess.tsx +118 -0
- package/src/components/UserMenu.tsx +138 -0
- package/src/helpers/bem.ts +13 -0
- package/src/helpers/cn.ts +9 -0
- package/src/index.ts +16 -0
- package/src/theme.css +285 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { cn } from '../helpers/cn';
|
|
5
|
+
import { ChevronDown } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
|
8
|
+
label?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
isRequired?: boolean;
|
|
11
|
+
options: { label: string; value: string | number }[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|
15
|
+
({ label, error, isRequired, options, className, ...props }, ref) => {
|
|
16
|
+
return (
|
|
17
|
+
<div className={cn("flex flex-col gap-1.5 w-full", className)}>
|
|
18
|
+
{label && (
|
|
19
|
+
<label className="text-[12px] font-semibold text-foreground-subtle tracking-[0.3px] uppercase">
|
|
20
|
+
{label} {isRequired && <span className="text-danger-alt ml-0.5">*</span>}
|
|
21
|
+
</label>
|
|
22
|
+
)}
|
|
23
|
+
<div className="relative group">
|
|
24
|
+
<select
|
|
25
|
+
ref={ref}
|
|
26
|
+
className={cn(
|
|
27
|
+
"w-full bg-surface-0 border-[1.5px] border-border-subtle rounded-[10px] px-3.5 py-2.5 text-[13.5px] text-foreground-1 outline-none transition-all focus:bg-surface-1 appearance-none cursor-pointer",
|
|
28
|
+
error && "border-danger-alt focus:border-danger-alt"
|
|
29
|
+
)}
|
|
30
|
+
{...props}
|
|
31
|
+
>
|
|
32
|
+
<option value="" disabled>Select an option</option>
|
|
33
|
+
{options.map((opt) => (
|
|
34
|
+
<option key={opt.value} value={opt.value}>
|
|
35
|
+
{opt.label}
|
|
36
|
+
</option>
|
|
37
|
+
))}
|
|
38
|
+
</select>
|
|
39
|
+
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-foreground-disabled group-focus-within:text-primary transition-colors">
|
|
40
|
+
<ChevronDown size={16} />
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
{error && <span className="text-[11px] text-danger-alt font-medium">{error}</span>}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
Select.displayName = 'Select';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { cn } from '../helpers/cn';
|
|
5
|
+
|
|
6
|
+
export interface TableProps {
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const Table = ({ children, className }: TableProps) => {
|
|
12
|
+
return (
|
|
13
|
+
<div className={cn("w-full overflow-x-auto", className)}>
|
|
14
|
+
<table className="w-full border-separate border-spacing-0 text-left min-w-[600px]">
|
|
15
|
+
{children}
|
|
16
|
+
</table>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const THeader = ({ children, className }: TableProps) => (
|
|
22
|
+
<thead className={cn(className)}>
|
|
23
|
+
{children}
|
|
24
|
+
</thead>
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export const TBody = ({ children, className }: TableProps) => (
|
|
28
|
+
<tbody className={cn(className)}>
|
|
29
|
+
{children}
|
|
30
|
+
</tbody>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const TRow = ({ children, className, onClick }: TableProps & { onClick?: () => void }) => (
|
|
34
|
+
<tr
|
|
35
|
+
className={cn(
|
|
36
|
+
"group transition-colors",
|
|
37
|
+
onClick && "cursor-pointer hover:bg-surface-hover-table",
|
|
38
|
+
className
|
|
39
|
+
)}
|
|
40
|
+
onClick={onClick}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
</tr>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export const TCell = ({ children, className, isHeader = false, colSpan, rowSpan }: TableProps & { isHeader?: boolean; colSpan?: number; rowSpan?: number }) => {
|
|
47
|
+
const Tag = isHeader ? 'th' : 'td';
|
|
48
|
+
return (
|
|
49
|
+
<Tag
|
|
50
|
+
colSpan={colSpan}
|
|
51
|
+
rowSpan={rowSpan}
|
|
52
|
+
className={cn(
|
|
53
|
+
"transition-all",
|
|
54
|
+
isHeader
|
|
55
|
+
? "bg-default-100 py-2.5 px-3 sm:px-4 text-[12px] font-semibold text-foreground-subtle capitalize tracking-wide first:rounded-l-[8px] last:rounded-r-[8px]"
|
|
56
|
+
: "py-2.5 px-3 sm:px-4 text-[13px] font-medium text-foreground-1 border-b border-border-table",
|
|
57
|
+
className
|
|
58
|
+
)}>
|
|
59
|
+
{children}
|
|
60
|
+
</Tag>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const TLoader = ({ rows = 8, cols = 4 }: { rows?: number; cols?: number }) => {
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
68
|
+
<TRow key={i}>
|
|
69
|
+
{Array.from({ length: cols }).map((_, j) => (
|
|
70
|
+
<TCell key={j}>
|
|
71
|
+
<div className="h-4 bg-surface-0 animate-pulse rounded w-full" />
|
|
72
|
+
</TCell>
|
|
73
|
+
))}
|
|
74
|
+
</TRow>
|
|
75
|
+
))}
|
|
76
|
+
</>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { cn } from '../helpers/cn';
|
|
5
|
+
|
|
6
|
+
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
7
|
+
label?: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
icon?: React.ReactNode;
|
|
10
|
+
isRequired?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
14
|
+
({ label, error, icon, isRequired, className, rows = 3, ...props }, ref) => {
|
|
15
|
+
return (
|
|
16
|
+
<div className={cn("flex flex-col gap-1.5 w-full", className)}>
|
|
17
|
+
{label && (
|
|
18
|
+
<label className="text-[12px] font-normal text-foreground-subtle tracking-[0.3px] capitalize">
|
|
19
|
+
{label} {isRequired && <span className="text-danger-alt ml-0.5">*</span>}
|
|
20
|
+
</label>
|
|
21
|
+
)}
|
|
22
|
+
<div className="relative group">
|
|
23
|
+
{icon && (
|
|
24
|
+
<div className="absolute left-3 top-3 w-4 h-4 text-foreground-disabled group-focus-within:text-primary transition-colors">
|
|
25
|
+
{icon}
|
|
26
|
+
</div>
|
|
27
|
+
)}
|
|
28
|
+
<textarea
|
|
29
|
+
ref={ref}
|
|
30
|
+
rows={rows}
|
|
31
|
+
className={cn(
|
|
32
|
+
"w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[10px] px-3.5 py-2.5 text-[13.5px] text-foreground-1 outline-none transition-all focus:border-primary focus:bg-surface-1 placeholder:text-foreground-disabled resize-y",
|
|
33
|
+
icon && "pl-10",
|
|
34
|
+
error && "border-danger-alt focus:border-danger-alt"
|
|
35
|
+
)}
|
|
36
|
+
{...props}
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
{error && <span className="text-[11px] text-danger-alt font-medium">{error}</span>}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
Textarea.displayName = 'Textarea';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
export type ThemePreference = 'light' | 'dark' | 'system';
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'ehi_theme_preference';
|
|
8
|
+
|
|
9
|
+
interface ThemeContextType {
|
|
10
|
+
theme: ThemePreference;
|
|
11
|
+
resolvedTheme: 'light' | 'dark';
|
|
12
|
+
setTheme: (theme: ThemePreference) => void;
|
|
13
|
+
toggleTheme: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|
17
|
+
|
|
18
|
+
function getSystemTheme(): 'light' | 'dark' {
|
|
19
|
+
if (typeof window === 'undefined') return 'light';
|
|
20
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ThemeProvider({
|
|
24
|
+
children,
|
|
25
|
+
defaultTheme = 'light',
|
|
26
|
+
}: {
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
defaultTheme?: ThemePreference;
|
|
29
|
+
}) {
|
|
30
|
+
const [theme, setThemeState] = useState<ThemePreference>(defaultTheme);
|
|
31
|
+
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
|
32
|
+
const [mounted, setMounted] = useState(false);
|
|
33
|
+
|
|
34
|
+
// Initialize from localStorage
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
let stored: ThemePreference | null = null;
|
|
37
|
+
try {
|
|
38
|
+
stored = localStorage.getItem(STORAGE_KEY) as ThemePreference | null;
|
|
39
|
+
} catch {
|
|
40
|
+
stored = null;
|
|
41
|
+
}
|
|
42
|
+
const pref = stored || defaultTheme;
|
|
43
|
+
setThemeState(pref);
|
|
44
|
+
setResolvedTheme(pref === 'system' ? getSystemTheme() : pref);
|
|
45
|
+
setMounted(true);
|
|
46
|
+
}, [defaultTheme]);
|
|
47
|
+
|
|
48
|
+
// Listen for system theme changes when preference is 'system'
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!mounted) return;
|
|
51
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
52
|
+
const handleChange = () => {
|
|
53
|
+
if (theme === 'system') setResolvedTheme(getSystemTheme());
|
|
54
|
+
};
|
|
55
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
56
|
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
57
|
+
}, [theme, mounted]);
|
|
58
|
+
|
|
59
|
+
// Sync .dark class on <html> so portalled elements (dropdowns, modals) inherit dark mode
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!mounted) return;
|
|
62
|
+
document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
|
|
63
|
+
}, [resolvedTheme, mounted]);
|
|
64
|
+
|
|
65
|
+
const setTheme = useCallback((newTheme: ThemePreference) => {
|
|
66
|
+
setThemeState(newTheme);
|
|
67
|
+
try {
|
|
68
|
+
localStorage.setItem(STORAGE_KEY, newTheme);
|
|
69
|
+
} catch {
|
|
70
|
+
/* ignore */
|
|
71
|
+
}
|
|
72
|
+
setResolvedTheme(newTheme === 'system' ? getSystemTheme() : newTheme);
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const toggleTheme = useCallback(() => {
|
|
76
|
+
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
|
77
|
+
}, [resolvedTheme, setTheme]);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
|
|
81
|
+
{children}
|
|
82
|
+
</ThemeContext.Provider>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function useTheme() {
|
|
87
|
+
const context = useContext(ThemeContext);
|
|
88
|
+
if (!context) {
|
|
89
|
+
throw new Error('useTheme must be used within a ThemeProvider');
|
|
90
|
+
}
|
|
91
|
+
return context;
|
|
92
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { AnimatePresence, motion } from 'framer-motion';
|
|
6
|
+
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react';
|
|
7
|
+
|
|
8
|
+
/* ─── Types ─── */
|
|
9
|
+
type ToastVariant = 'success' | 'error' | 'warning' | 'info';
|
|
10
|
+
|
|
11
|
+
interface ToastAction {
|
|
12
|
+
label: string;
|
|
13
|
+
onClick: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Toast {
|
|
17
|
+
id: string;
|
|
18
|
+
message: string;
|
|
19
|
+
variant: ToastVariant;
|
|
20
|
+
duration: number;
|
|
21
|
+
action?: ToastAction;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ToastContextValue {
|
|
25
|
+
success: (message: string, duration?: number) => void;
|
|
26
|
+
error: (message: string, options?: { duration?: number; action?: ToastAction }) => void;
|
|
27
|
+
warning: (message: string, duration?: number) => void;
|
|
28
|
+
info: (message: string, duration?: number) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* ─── Variant config ─── */
|
|
32
|
+
const variantConfig: Record<ToastVariant, {
|
|
33
|
+
icon: React.ReactNode;
|
|
34
|
+
bg: string;
|
|
35
|
+
border: string;
|
|
36
|
+
text: string;
|
|
37
|
+
progress: string;
|
|
38
|
+
iconColor: string;
|
|
39
|
+
iconBg: string;
|
|
40
|
+
}> = {
|
|
41
|
+
success: {
|
|
42
|
+
icon: <CheckCircle size={18} />,
|
|
43
|
+
bg: 'bg-surface-0',
|
|
44
|
+
border: 'border-border-subtle',
|
|
45
|
+
text: 'text-foreground-0',
|
|
46
|
+
progress: 'bg-success',
|
|
47
|
+
iconColor: 'text-success-600',
|
|
48
|
+
iconBg: 'bg-success-50',
|
|
49
|
+
},
|
|
50
|
+
error: {
|
|
51
|
+
icon: <XCircle size={18} />,
|
|
52
|
+
bg: 'bg-surface-0',
|
|
53
|
+
border: 'border-border-subtle',
|
|
54
|
+
text: 'text-foreground-0',
|
|
55
|
+
progress: 'bg-danger',
|
|
56
|
+
iconColor: 'text-danger-600',
|
|
57
|
+
iconBg: 'bg-danger-50',
|
|
58
|
+
},
|
|
59
|
+
warning: {
|
|
60
|
+
icon: <AlertTriangle size={18} />,
|
|
61
|
+
bg: 'bg-surface-0',
|
|
62
|
+
border: 'border-border-subtle',
|
|
63
|
+
text: 'text-foreground-0',
|
|
64
|
+
progress: 'bg-warning',
|
|
65
|
+
iconColor: 'text-warning-600',
|
|
66
|
+
iconBg: 'bg-warning-50',
|
|
67
|
+
},
|
|
68
|
+
info: {
|
|
69
|
+
icon: <Info size={18} />,
|
|
70
|
+
bg: 'bg-surface-0',
|
|
71
|
+
border: 'border-border-subtle',
|
|
72
|
+
text: 'text-foreground-0',
|
|
73
|
+
progress: 'bg-primary',
|
|
74
|
+
iconColor: 'text-primary-600',
|
|
75
|
+
iconBg: 'bg-primary-50',
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/* ─── Context ─── */
|
|
80
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
81
|
+
|
|
82
|
+
export function useToast(): ToastContextValue {
|
|
83
|
+
const ctx = useContext(ToastContext);
|
|
84
|
+
if (!ctx) throw new Error('useToast must be used within <ToasterProvider>');
|
|
85
|
+
return ctx;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ─── Single toast item ─── */
|
|
89
|
+
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
|
|
90
|
+
const config = variantConfig[toast.variant];
|
|
91
|
+
const [progress, setProgress] = useState(100);
|
|
92
|
+
const startRef = useRef<number>(Date.now());
|
|
93
|
+
const rafRef = useRef<number>(0);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const animate = () => {
|
|
97
|
+
const elapsed = Date.now() - startRef.current;
|
|
98
|
+
const remaining = Math.max(0, 100 - (elapsed / toast.duration) * 100);
|
|
99
|
+
setProgress(remaining);
|
|
100
|
+
|
|
101
|
+
if (remaining <= 0) {
|
|
102
|
+
onDismiss(toast.id);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
rafRef.current = requestAnimationFrame(animate);
|
|
106
|
+
};
|
|
107
|
+
rafRef.current = requestAnimationFrame(animate);
|
|
108
|
+
return () => cancelAnimationFrame(rafRef.current);
|
|
109
|
+
}, [toast.id, toast.duration, onDismiss]);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<motion.div
|
|
113
|
+
layout
|
|
114
|
+
initial={{ opacity: 0, y: -15, scale: 0.95 }}
|
|
115
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
116
|
+
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
|
|
117
|
+
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
|
118
|
+
className={`relative flex items-center gap-3.5 min-w-[300px] max-w-[480px] px-4 py-3 rounded-[14px] border shadow-[0_8px_30px_rgb(0,0,0,0.06)] overflow-hidden ${config.bg} ${config.border}`}
|
|
119
|
+
>
|
|
120
|
+
<div className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${config.iconBg} ${config.iconColor}`}>
|
|
121
|
+
{config.icon}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<p className={`text-[13px] font-medium leading-snug flex-1 pr-6 tracking-wide ${config.text}`}>
|
|
125
|
+
{toast.message}
|
|
126
|
+
</p>
|
|
127
|
+
|
|
128
|
+
{toast.action && (
|
|
129
|
+
<button
|
|
130
|
+
onClick={() => {
|
|
131
|
+
toast.action!.onClick();
|
|
132
|
+
onDismiss(toast.id);
|
|
133
|
+
}}
|
|
134
|
+
className="shrink-0 px-3 py-1.5 text-[12px] font-semibold text-primary hover:text-primary-700 bg-primary/8 hover:bg-primary/15 rounded-lg transition-all cursor-pointer mr-5"
|
|
135
|
+
>
|
|
136
|
+
{toast.action.label}
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
<button
|
|
141
|
+
onClick={() => onDismiss(toast.id)}
|
|
142
|
+
className={`absolute top-1/2 -translate-y-1/2 right-3 shrink-0 p-1 rounded-md text-foreground-disabled hover:bg-surface-hover hover:text-foreground-0 transition-colors cursor-pointer`}
|
|
143
|
+
>
|
|
144
|
+
<X size={14} />
|
|
145
|
+
</button>
|
|
146
|
+
|
|
147
|
+
{/* Progress bar */}
|
|
148
|
+
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-transparent">
|
|
149
|
+
<motion.div
|
|
150
|
+
className={`h-full ${config.progress} opacity-20`}
|
|
151
|
+
style={{ width: `${progress}%` }}
|
|
152
|
+
transition={{ duration: 0 }}
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
</motion.div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* ─── Provider ─── */
|
|
160
|
+
export function ToasterProvider({ children }: { children: React.ReactNode }) {
|
|
161
|
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
162
|
+
const [mounted, setMounted] = useState(false);
|
|
163
|
+
|
|
164
|
+
useEffect(() => setMounted(true), []);
|
|
165
|
+
|
|
166
|
+
const dismiss = useCallback((id: string) => {
|
|
167
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
const addToast = useCallback((variant: ToastVariant, message: string, duration = 4000, action?: ToastAction) => {
|
|
171
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
172
|
+
setToasts((prev) => [...prev, { id, message, variant, duration, action }]);
|
|
173
|
+
}, []);
|
|
174
|
+
|
|
175
|
+
const value = React.useMemo(() => ({
|
|
176
|
+
success: (msg: string, dur?: number) => addToast('success', msg, dur),
|
|
177
|
+
error: (msg: string, opts?: { duration?: number; action?: ToastAction }) => addToast('error', msg, opts?.duration, opts?.action),
|
|
178
|
+
warning: (msg: string, dur?: number) => addToast('warning', msg, dur),
|
|
179
|
+
info: (msg: string, dur?: number) => addToast('info', msg, dur),
|
|
180
|
+
}), [addToast]);
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<ToastContext.Provider value={value}>
|
|
184
|
+
{children}
|
|
185
|
+
{mounted &&
|
|
186
|
+
createPortal(
|
|
187
|
+
<div className="fixed bottom-5 right-5 z-[9999] flex flex-col-reverse gap-3 pointer-events-auto">
|
|
188
|
+
<AnimatePresence mode="popLayout">
|
|
189
|
+
{[...toasts].reverse().map((t) => (
|
|
190
|
+
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
|
|
191
|
+
))}
|
|
192
|
+
</AnimatePresence>
|
|
193
|
+
</div>,
|
|
194
|
+
document.body
|
|
195
|
+
)}
|
|
196
|
+
</ToastContext.Provider>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AceUI base-component library (vendored).
|
|
3
|
+
* Import design-system primitives from here, e.g.:
|
|
4
|
+
* import { Button, Input, Card, EmptyState } from "@/common/components/base-components";
|
|
5
|
+
*/
|
|
6
|
+
export * from "./Accordion";
|
|
7
|
+
export * from "./Badge";
|
|
8
|
+
export * from "./Button";
|
|
9
|
+
export * from "./ButtonGroup";
|
|
10
|
+
export * from "./Card";
|
|
11
|
+
export * from "./Checkbox";
|
|
12
|
+
export * from "./ChipInput";
|
|
13
|
+
export * from "./Divider";
|
|
14
|
+
export * from "./Dropdown";
|
|
15
|
+
export * from "./EmptyState";
|
|
16
|
+
export * from "./FilterPopover";
|
|
17
|
+
export * from "./Input";
|
|
18
|
+
export * from "./Textarea";
|
|
19
|
+
export * from "./SearchPopover";
|
|
20
|
+
export * from "./Modal";
|
|
21
|
+
export * from "./Pagination";
|
|
22
|
+
export * from "./PopConfirm";
|
|
23
|
+
export * from "./SearchableSelect";
|
|
24
|
+
export * from "./Select";
|
|
25
|
+
export * from "./Table";
|
|
26
|
+
export * from "./DataTable";
|
|
27
|
+
export * from "./ThemeProvider";
|
|
28
|
+
export * from "./Toaster";
|
|
29
|
+
export * from "./OtpVerificationModal";
|
|
30
|
+
export * from "./PhoneInput";
|
|
31
|
+
export { default as CommonButton } from "./CommonButton";
|
|
32
|
+
export * from "./CommonButton";
|