@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,82 @@
1
+ 'use client';
2
+
3
+ import { block, modifier } from '../helpers/bem';
4
+ import { cn } from '../helpers/cn';
5
+
6
+ /**
7
+ * Divider component props interface
8
+ */
9
+ export interface DividerProps {
10
+ /** Orientation of the divider */
11
+ orientation?: 'horizontal' | 'vertical';
12
+
13
+ /** Additional CSS class names */
14
+ className?: string;
15
+
16
+ /** BEM class name prefix (default 'aceui'). Usually set via AceUIProvider when using @aceuidev/core. */
17
+ prefix?: string;
18
+
19
+ /** Accessible label for screen readers */
20
+ ariaLabel?: string;
21
+
22
+ /** Test ID for testing */
23
+ testId?: string;
24
+ }
25
+
26
+ const orientationStyles = {
27
+ horizontal: 'w-full h-0 border-t border-solid border-border-default',
28
+ vertical: 'w-0 min-h-4 h-full border-l border-solid border-border-default self-stretch',
29
+ } as const;
30
+
31
+ /**
32
+ * Divider component for separating content with horizontal or vertical lines
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * <Divider />
37
+ * <Divider orientation="vertical" />
38
+ * ```
39
+ */
40
+ export const Divider = ({
41
+ orientation = 'horizontal',
42
+ className,
43
+ prefix = 'aceui',
44
+ ariaLabel,
45
+ testId,
46
+ }: DividerProps) => {
47
+ const blockClass = block(prefix, 'divider');
48
+ const bemClasses = [
49
+ blockClass,
50
+ modifier(prefix, 'divider', orientation),
51
+ ].filter(Boolean) as string[];
52
+
53
+ const classes = cn(
54
+ bemClasses,
55
+ 'border-none m-0 shrink-0 box-border',
56
+ orientationStyles[orientation],
57
+ className,
58
+ );
59
+
60
+ if (orientation === 'horizontal') {
61
+ return (
62
+ <hr
63
+ role="separator"
64
+ className={classes}
65
+ aria-label={ariaLabel}
66
+ data-testid={testId}
67
+ />
68
+ );
69
+ }
70
+
71
+ return (
72
+ <div
73
+ role="separator"
74
+ aria-orientation="vertical"
75
+ className={classes}
76
+ aria-label={ariaLabel}
77
+ data-testid={testId}
78
+ />
79
+ );
80
+ };
81
+
82
+ Divider.displayName = 'Divider';
@@ -0,0 +1,85 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef, useEffect, ReactNode } from 'react';
4
+ import { cn } from '../helpers/cn';
5
+
6
+ interface DropdownProps {
7
+ trigger: ReactNode;
8
+ children: ReactNode;
9
+ align?: 'left' | 'right';
10
+ className?: string;
11
+ contentClassName?: string;
12
+ }
13
+
14
+ export function Dropdown({ trigger, children, align = 'left', className, contentClassName }: DropdownProps) {
15
+ const [isOpen, setIsOpen] = useState(false);
16
+ const dropdownRef = useRef<HTMLDivElement>(null);
17
+
18
+ const toggleDropdown = () => setIsOpen((prev) => !prev);
19
+ const closeDropdown = () => setIsOpen(false);
20
+
21
+ useEffect(() => {
22
+ const handleClickOutside = (event: MouseEvent) => {
23
+ const target = event.target as Element;
24
+ if (target?.closest?.('.aceui-pop-confirm') || target?.closest?.('[role="dialog"]')) {
25
+ return;
26
+ }
27
+
28
+ if (dropdownRef.current && !dropdownRef.current.contains(target as Node)) {
29
+ setIsOpen(false);
30
+ }
31
+ };
32
+
33
+ if (isOpen) {
34
+ document.addEventListener('mousedown', handleClickOutside);
35
+ }
36
+ return () => {
37
+ document.removeEventListener('mousedown', handleClickOutside);
38
+ };
39
+ }, [isOpen]);
40
+
41
+ return (
42
+ <div ref={dropdownRef} className={cn("relative inline-block text-left", className)}>
43
+ <div onClick={toggleDropdown} className="cursor-pointer">
44
+ {trigger}
45
+ </div>
46
+
47
+ {isOpen && (
48
+ <div
49
+ className={cn(
50
+ "absolute z-50 mt-1.5 min-w-[160px] rounded-[10px] bg-surface-0 border border-border-default shadow-[0_4px_12px_rgba(0,0,0,0.05)] p-1.5 animate-in fade-in zoom-in-95 duration-200",
51
+ align === 'right' ? 'right-0' : 'left-0',
52
+ contentClassName
53
+ )}
54
+ onClick={closeDropdown}
55
+ >
56
+ {children}
57
+ </div>
58
+ )}
59
+ </div>
60
+ );
61
+ }
62
+
63
+ interface DropdownItemProps {
64
+ children: ReactNode;
65
+ onClick?: () => void;
66
+ className?: string;
67
+ isActive?: boolean;
68
+ }
69
+
70
+ export function DropdownItem({ children, onClick, className, isActive }: DropdownItemProps) {
71
+ return (
72
+ <div
73
+ onClick={onClick}
74
+ className={cn(
75
+ "px-3 py-2 text-[13px] rounded-[6px] cursor-pointer transition-colors flex items-center gap-2",
76
+ isActive
77
+ ? "bg-primary-100 text-primary-700 font-medium"
78
+ : "text-foreground-1 hover:bg-surface-hover hover:text-foreground-0",
79
+ className
80
+ )}
81
+ >
82
+ {children}
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,18 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Inbox } from 'lucide-react';
5
+
6
+ export interface EmptyStateProps {
7
+ message?: string;
8
+ icon?: React.ReactNode;
9
+ }
10
+
11
+ export const EmptyState = ({ message = "No records found.", icon }: EmptyStateProps) => {
12
+ return (
13
+ <div className="flex flex-col items-center justify-center opacity-40">
14
+ {icon || <Inbox size={42} strokeWidth={1.5} className="mb-3 text-foreground-disabled" />}
15
+ <p className="text-[14px] font-medium text-foreground-disabled">{message}</p>
16
+ </div>
17
+ );
18
+ };
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { SlidersHorizontal } from 'lucide-react';
5
+ import { Dropdown } from './Dropdown';
6
+ import { Button } from './Button';
7
+ import { cn } from '../helpers/cn';
8
+
9
+ export interface FilterPopoverProps {
10
+ /** Filter controls (typically Select components). */
11
+ children: React.ReactNode;
12
+ /** Show the active-filter indicator dot. */
13
+ active?: boolean;
14
+ label?: string;
15
+ className?: string;
16
+ }
17
+
18
+ /**
19
+ * Compact header filter: an icon button that opens a popover of filter
20
+ * controls, with a dot indicator when any filter is applied. Mirrors the
21
+ * jewelry-ui desktop filter affordance.
22
+ */
23
+ export function FilterPopover({ children, active = false, label = 'Filters', className }: FilterPopoverProps) {
24
+ return (
25
+ <div className={cn('relative', className)}>
26
+ <Dropdown
27
+ align="right"
28
+ contentClassName="w-[264px] p-3"
29
+ trigger={
30
+ <div className="relative">
31
+ <Button
32
+ variant="flat"
33
+ color="default"
34
+ size="small"
35
+ ariaLabel={label}
36
+ icon={<SlidersHorizontal size={18} />}
37
+ />
38
+ {active && (
39
+ <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-primary-500 rounded-full ring-2 ring-surface-1" />
40
+ )}
41
+ </div>
42
+ }
43
+ >
44
+ <div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-3">
45
+ {children}
46
+ </div>
47
+ </Dropdown>
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { cn } from '../helpers/cn';
5
+ import { Eye, EyeOff } from 'lucide-react';
6
+
7
+ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
8
+ label?: string;
9
+ error?: string;
10
+ icon?: React.ReactNode;
11
+ isRequired?: boolean;
12
+ }
13
+
14
+ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
15
+ ({ label, error, icon, isRequired, className, type, ...props }, ref) => {
16
+ const [showPassword, setShowPassword] = useState(false);
17
+ const isPassword = type === 'password';
18
+ const inputType = isPassword ? (showPassword ? 'text' : 'password') : type;
19
+
20
+ return (
21
+ <div className={cn("flex flex-col gap-1.5 w-full", className)}>
22
+ {label && (
23
+ <label className="text-[12px] font-normal text-foreground-subtle tracking-[0.3px] capitalize">
24
+ {label} {isRequired && <span className="text-danger-alt ml-0.5">*</span>}
25
+ </label>
26
+ )}
27
+ <div className="relative group">
28
+ {icon && (
29
+ <div className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-disabled group-focus-within:text-primary transition-colors">
30
+ {icon}
31
+ </div>
32
+ )}
33
+ <input
34
+ ref={ref}
35
+ type={inputType}
36
+ className={cn(
37
+ "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",
38
+ icon && "pl-10",
39
+ isPassword && "pr-10",
40
+ error && "border-danger-alt focus:border-danger-alt"
41
+ )}
42
+ {...props}
43
+ />
44
+ {isPassword && (
45
+ <button
46
+ type="button"
47
+ onClick={() => setShowPassword(!showPassword)}
48
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-disabled hover:text-primary transition-colors"
49
+ >
50
+ {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
51
+ </button>
52
+ )}
53
+ </div>
54
+ {error && <span className="text-[11px] text-danger-alt font-medium">{error}</span>}
55
+ </div>
56
+ );
57
+ }
58
+ );
59
+
60
+ Input.displayName = 'Input';
@@ -0,0 +1,107 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
+ import { X } from 'lucide-react';
7
+ import { cn } from '../helpers/cn';
8
+
9
+ export interface ModalProps {
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ title?: string | React.ReactNode;
13
+ children: React.ReactNode;
14
+ className?: string;
15
+ footer?: React.ReactNode;
16
+ backdrop?: 'transparent' | 'opaque' | 'blur';
17
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
18
+ hideHeader?: boolean;
19
+ closeOnBackdropClick?: boolean;
20
+ }
21
+
22
+ export const Modal = ({ isOpen, onClose, title, children, className, footer, backdrop = 'opaque', size = 'sm', hideHeader = false, closeOnBackdropClick = true }: ModalProps) => {
23
+ useEffect(() => {
24
+ if (isOpen) {
25
+ document.body.style.overflow = 'hidden';
26
+ } else {
27
+ document.body.style.overflow = 'unset';
28
+ }
29
+ return () => { document.body.style.overflow = 'unset'; };
30
+ }, [isOpen]);
31
+
32
+ const backdropClasses = cn(
33
+ "absolute inset-0 transition-all duration-300",
34
+ backdrop === 'transparent' && "bg-transparent",
35
+ backdrop === 'opaque' && "bg-foreground-0/60",
36
+ backdrop === 'blur' && "bg-foreground-0/40 backdrop-blur-sm"
37
+ );
38
+
39
+ const sizeClasses = {
40
+ xs: 'max-w-[400px]',
41
+ sm: 'max-w-[480px]',
42
+ md: 'max-w-[600px]',
43
+ lg: 'max-w-[800px]',
44
+ xl: 'max-w-[1140px]',
45
+ '2xl': 'max-w-[1400px]',
46
+ full: 'max-w-[100vw]',
47
+ };
48
+
49
+ if (typeof document === 'undefined') return null;
50
+
51
+ return createPortal(
52
+ <AnimatePresence>
53
+ {isOpen && (
54
+ <div className={cn("fixed inset-0 z-50 flex items-center justify-center", size === 'full' ? 'p-0' : 'p-2 sm:p-4')}>
55
+ <motion.div
56
+ initial={{ opacity: 0 }}
57
+ animate={{ opacity: 1 }}
58
+ exit={{ opacity: 0 }}
59
+ onClick={closeOnBackdropClick ? onClose : undefined}
60
+ className={backdropClasses}
61
+ />
62
+ <motion.div
63
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
64
+ animate={{ opacity: 1, scale: 1, y: 0 }}
65
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
66
+ onClick={(e) => e.stopPropagation()}
67
+ className={cn(
68
+ "relative w-full bg-surface-1 shadow-[0_8px_40px_rgba(0,0,0,0.2)] flex flex-col overflow-hidden",
69
+ size === 'full' ? "rounded-none h-screen max-h-screen" : "rounded-[16px] max-h-[90vh]",
70
+ sizeClasses[size],
71
+ className
72
+ )}
73
+ >
74
+ {/* Header */}
75
+ {!hideHeader && (
76
+ <div className="flex items-center justify-between px-5 py-4">
77
+ <h3 className="text-[16px] sm:text-[18px] font-bold text-foreground-0 tracking-tight">{title}</h3>
78
+ <button
79
+ onClick={onClose}
80
+ className="w-8 h-8 rounded-full flex items-center justify-center text-foreground-disabled hover:bg-surface-0 hover:text-foreground-0 transition-all"
81
+ >
82
+ <X size={20} />
83
+ </button>
84
+ </div>
85
+ )}
86
+
87
+ {/* Body */}
88
+ <div className={cn(
89
+ "flex-1",
90
+ size === 'full' ? "overflow-hidden p-0" : "overflow-y-auto px-5 py-5"
91
+ )}>
92
+ {children}
93
+ </div>
94
+
95
+ {/* Footer */}
96
+ {footer && (
97
+ <div className="px-5 py-6 bg-surface-1 flex flex-col-reverse sm:flex-row justify-end gap-2 sm:gap-3">
98
+ {footer}
99
+ </div>
100
+ )}
101
+ </motion.div>
102
+ </div>
103
+ )}
104
+ </AnimatePresence>,
105
+ document.body
106
+ );
107
+ };
@@ -0,0 +1,251 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef, useEffect } from 'react';
4
+ import { Modal } from './Modal';
5
+ import { Button } from './Button';
6
+ import { cn } from '../helpers/cn';
7
+ import { Mail, MessageCircle } from 'lucide-react';
8
+
9
+ export interface OtpVerificationModalProps {
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ target: string;
13
+ onVerify: (otp: string) => Promise<boolean | string>;
14
+ targetType?: 'email' | 'whatsapp' | 'phone';
15
+ className?: string;
16
+ backdrop?: 'transparent' | 'opaque' | 'blur';
17
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
18
+ /** Minutes the OTP stays valid for; drives the countdown shown below the input. */
19
+ expiryMinutes?: number;
20
+ /** Overrides the default "Check your email" / "Check your WhatsApp" heading. */
21
+ title?: string;
22
+ /** Overrides the default description paragraph below the heading. */
23
+ description?: React.ReactNode;
24
+ /** Called when the user clicks "resend" after the OTP has expired. */
25
+ onResend?: () => void;
26
+ /** Epoch ms when the current OTP was sent. Used so the countdown survives the modal being closed and reopened. Defaults to the moment the modal opens. */
27
+ sentAt?: number;
28
+ /** Brand color driving the digit boxes, resend link and verify button. Defaults to 'warning' (the hotel back-office theme). */
29
+ color?: 'warning' | 'primary';
30
+ /** Short status text shown above the OTP boxes, e.g. "An OTP was already sent. Please check your inbox." */
31
+ infoMessage?: React.ReactNode;
32
+ /** Externally-controlled error (e.g. a failed send/resend call) shown in the same slot as the verify error, below the OTP boxes. */
33
+ errorMessage?: string | null;
34
+ }
35
+
36
+ const COLOR_CLASSES = {
37
+ warning: {
38
+ inputBorder: 'border-warning-100 focus:border-warning',
39
+ resendText: 'text-warning-400',
40
+ verifyButton: '!bg-warning hover:!bg-warning !border-none !shadow-none font-semibold text-[15px]',
41
+ },
42
+ primary: {
43
+ inputBorder: 'border-primary-100 focus:border-primary',
44
+ resendText: 'text-primary-400',
45
+ verifyButton: '!bg-primary hover:!bg-primary-600 !border-none !shadow-none font-semibold text-[15px]',
46
+ },
47
+ } as const;
48
+
49
+ export const OtpVerificationModal = ({ isOpen, onClose, target, onVerify, targetType = 'email', className, backdrop = 'opaque', size = 'xs', expiryMinutes = 5, title, description, onResend, sentAt, color = 'warning', infoMessage, errorMessage }: OtpVerificationModalProps) => {
50
+ const [otp, setOtp] = useState<string[]>(Array(6).fill(''));
51
+ const [error, setError] = useState('');
52
+ const displayError = error || errorMessage || '';
53
+ const [isVerifying, setIsVerifying] = useState(false);
54
+ const [secondsLeft, setSecondsLeft] = useState(expiryMinutes * 60);
55
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
56
+
57
+ useEffect(() => {
58
+ if (!isOpen) return;
59
+
60
+ const computeSecondsLeft = () => {
61
+ const startedAt = sentAt ?? Date.now();
62
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
63
+ return Math.max(0, expiryMinutes * 60 - elapsed);
64
+ };
65
+
66
+ setSecondsLeft(computeSecondsLeft());
67
+ const interval = setInterval(() => {
68
+ setSecondsLeft(computeSecondsLeft());
69
+ }, 1000);
70
+
71
+ return () => clearInterval(interval);
72
+ }, [isOpen, expiryMinutes, sentAt]);
73
+
74
+ const isExpired = secondsLeft <= 0;
75
+ const countdownLabel = `${Math.floor(secondsLeft / 60)}:${String(secondsLeft % 60).padStart(2, '0')}`;
76
+
77
+ const handleOtpChange = (index: number, value: string) => {
78
+ if (value && !/^[0-9]+$/.test(value)) return;
79
+
80
+ if (value.length > 1) {
81
+ const pastedDigits = value.slice(0, 6).split('');
82
+ const newOtp = [...otp];
83
+ pastedDigits.forEach((digit, i) => {
84
+ if (index + i < 6) {
85
+ newOtp[index + i] = digit;
86
+ }
87
+ });
88
+ setOtp(newOtp);
89
+ setError('');
90
+
91
+ const nextIndex = Math.min(index + pastedDigits.length, 5);
92
+ inputRefs.current[nextIndex]?.focus();
93
+ return;
94
+ }
95
+
96
+ const newOtp = [...otp];
97
+ newOtp[index] = value;
98
+ setOtp(newOtp);
99
+ setError('');
100
+
101
+ if (value && index < 5) {
102
+ inputRefs.current[index + 1]?.focus();
103
+ }
104
+ };
105
+
106
+ const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
107
+ if (e.key === 'Backspace' && !otp[index] && index > 0) {
108
+ inputRefs.current[index - 1]?.focus();
109
+ }
110
+ };
111
+
112
+ const handleVerify = async () => {
113
+ const otpString = otp.join('');
114
+ if (otpString.length < 6) {
115
+ setError('Please enter a valid 6-digit OTP');
116
+ return;
117
+ }
118
+
119
+ setIsVerifying(true);
120
+ setError('');
121
+
122
+ try {
123
+ const result = await onVerify(otpString);
124
+ if (result === true) {
125
+ setOtp(Array(6).fill(''));
126
+ onClose();
127
+ } else if (typeof result === 'string') {
128
+ setError(result);
129
+ } else {
130
+ setError('Verification failed. Please try again.');
131
+ }
132
+ } catch (err) {
133
+ setError('An error occurred during verification.');
134
+ } finally {
135
+ setIsVerifying(false);
136
+ }
137
+ };
138
+
139
+ const handleClose = () => {
140
+ setOtp(Array(6).fill(''));
141
+ setError('');
142
+ onClose();
143
+ };
144
+
145
+ const isWhatsApp = targetType === 'whatsapp' || targetType === 'phone';
146
+ const titleText = title ?? (isWhatsApp ? 'Check your WhatsApp' : 'Check your email');
147
+ const verifyButtonText = isWhatsApp ? 'Verify' : 'Verify';
148
+ const IconComponent = isWhatsApp ? MessageCircle : Mail;
149
+
150
+ const handleResend = () => {
151
+ setSecondsLeft(expiryMinutes * 60);
152
+ onResend?.();
153
+ };
154
+
155
+ return (
156
+ <Modal
157
+ isOpen={isOpen}
158
+ onClose={handleClose}
159
+ size={size}
160
+ backdrop={backdrop}
161
+ className={className}
162
+ hideHeader={true}
163
+ closeOnBackdropClick={false}
164
+ >
165
+ <div className="flex flex-col items-center text-center pt-4 pb-0 px-2">
166
+ {/* <div className="w-14 h-14 rounded-full bg-primary-50 text-focus flex items-center justify-center mb-5">
167
+ <IconComponent size={28} strokeWidth={2.5} />
168
+ </div> */}
169
+
170
+ <h3 className="text-[22px] font-bold text-foreground-0 mb-2 tracking-tight">
171
+ {titleText}
172
+ </h3>
173
+
174
+ <p className="text-[14px] text-foreground-subtle leading-relaxed mb-6">
175
+ {description ?? (
176
+ <>
177
+ Enter the verification code sent to <br />
178
+ <strong className="text-foreground-0 font-semibold">{target}</strong>
179
+ </>
180
+ )}
181
+ </p>
182
+
183
+ {infoMessage && (
184
+ <p className="text-[12.5px] font-medium -mt-4 mb-5 text-warning-600">
185
+ {infoMessage}
186
+ </p>
187
+ )}
188
+
189
+ <div className="flex gap-2 justify-center mb-3 w-full">
190
+ {otp.map((digit, index) => (
191
+ <input
192
+ key={index}
193
+ ref={(el) => {
194
+ inputRefs.current[index] = el;
195
+ }}
196
+ type="text"
197
+ inputMode="numeric"
198
+ maxLength={6}
199
+ value={digit}
200
+ onChange={(e) => handleOtpChange(index, e.target.value)}
201
+ onKeyDown={(e) => handleKeyDown(index, e)}
202
+ className={cn(
203
+ "w-11 h-14 bg-surface-1 border-[1.5px] rounded-[10px] text-center text-[22px] font-semibold text-foreground-0 outline-none transition-all focus:bg-surface-1",
204
+ displayError ? "border-danger-alt focus:border-danger-alt" : COLOR_CLASSES[color].inputBorder
205
+ )}
206
+ placeholder={digit === '' ? '·' : ''}
207
+ />
208
+ ))}
209
+ </div>
210
+
211
+ {displayError && <span className="text-[12px] text-danger-alt font-medium mb-2">{displayError}</span>}
212
+
213
+ <div className="text-[13.5px] text-foreground-subtle font-medium mb-4">
214
+ Didn't get a code?{' '}
215
+ {isExpired ? (
216
+ <button type="button" onClick={handleResend} className={cn(COLOR_CLASSES[color].resendText, "hover:underline font-semibold transition-all")}>
217
+ resend
218
+ </button>
219
+ ) : (
220
+ <span className={cn(COLOR_CLASSES[color].resendText, "font-semibold opacity-70 cursor-not-allowed")}>
221
+ resend in {countdownLabel}
222
+ </span>
223
+ )}
224
+ </div>
225
+
226
+ <Button
227
+ color={color}
228
+ size="large"
229
+ width="100%"
230
+ isLoading={isVerifying}
231
+ isDisabled={isExpired || otp.join('').length < 6}
232
+ onClick={handleVerify}
233
+ className={COLOR_CLASSES[color].verifyButton + ' ' + 'rounded-xl'}
234
+ >
235
+ {verifyButtonText}
236
+ </Button>
237
+ <Button
238
+ variant="flat"
239
+ size="large"
240
+ type="button"
241
+ onClick={handleClose}
242
+ isDisabled={false}
243
+ color={color}
244
+ className="!bg-transparent hover:!bg-transparent hover:underline !border-none !shadow-none !py-0 !font-light !text-[13.5px]"
245
+ >
246
+ Cancel
247
+ </Button>
248
+ </div>
249
+ </Modal>
250
+ );
251
+ };