@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.
Files changed (38) hide show
  1. package/README.md +46 -0
  2. package/package.json +42 -0
  3. package/src/base-components/Accordion.tsx +561 -0
  4. package/src/base-components/Badge.tsx +191 -0
  5. package/src/base-components/Button.tsx +331 -0
  6. package/src/base-components/ButtonGroup.tsx +149 -0
  7. package/src/base-components/Card.tsx +250 -0
  8. package/src/base-components/Checkbox.tsx +49 -0
  9. package/src/base-components/ChipInput.tsx +208 -0
  10. package/src/base-components/CommonButton.tsx +33 -0
  11. package/src/base-components/DataTable.tsx +82 -0
  12. package/src/base-components/Divider.tsx +82 -0
  13. package/src/base-components/Dropdown.tsx +85 -0
  14. package/src/base-components/EmptyState.tsx +18 -0
  15. package/src/base-components/FilterPopover.tsx +50 -0
  16. package/src/base-components/Input.tsx +60 -0
  17. package/src/base-components/Modal.tsx +107 -0
  18. package/src/base-components/OtpVerificationModal.tsx +251 -0
  19. package/src/base-components/Pagination.tsx +51 -0
  20. package/src/base-components/PhoneInput.tsx +142 -0
  21. package/src/base-components/PopConfirm.tsx +350 -0
  22. package/src/base-components/SearchPopover.tsx +70 -0
  23. package/src/base-components/SearchableSelect.tsx +734 -0
  24. package/src/base-components/Select.tsx +49 -0
  25. package/src/base-components/Table.tsx +78 -0
  26. package/src/base-components/Textarea.tsx +45 -0
  27. package/src/base-components/ThemeProvider.tsx +92 -0
  28. package/src/base-components/Toaster.tsx +198 -0
  29. package/src/base-components/index.ts +32 -0
  30. package/src/components/DashboardLayout.tsx +326 -0
  31. package/src/components/ListPage.tsx +140 -0
  32. package/src/components/QuickAccess.tsx +118 -0
  33. package/src/components/UserMenu.tsx +138 -0
  34. package/src/helpers/bem.ts +13 -0
  35. package/src/helpers/cn.ts +9 -0
  36. package/src/index.ts +16 -0
  37. package/src/theme.css +285 -0
  38. 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";