@airoom/nextmin-react 2.0.2 → 2.0.3

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 (54) hide show
  1. package/README.md +57 -0
  2. package/dist/components/AddressAutocomplete.d.ts +4 -1
  3. package/dist/components/AddressAutocomplete.js +2 -2
  4. package/dist/components/AdminApp.js +86 -1
  5. package/dist/components/FileUploader.d.ts +3 -0
  6. package/dist/components/FileUploader.js +84 -28
  7. package/dist/components/PasswordInput.d.ts +2 -0
  8. package/dist/components/PasswordInput.js +2 -2
  9. package/dist/components/PhoneInput.d.ts +2 -0
  10. package/dist/components/PhoneInput.js +2 -2
  11. package/dist/components/QRCodeModal.d.ts +10 -0
  12. package/dist/components/QRCodeModal.js +153 -0
  13. package/dist/components/RefSelect.d.ts +4 -1
  14. package/dist/components/RefSelect.js +163 -65
  15. package/dist/components/SchemaForm.d.ts +2 -1
  16. package/dist/components/SchemaForm.js +132 -55
  17. package/dist/components/Sidebar.js +96 -7
  18. package/dist/components/TableFilters.js +1 -1
  19. package/dist/components/editor/TiptapEditor.js +43 -12
  20. package/dist/components/editor/Toolbar.js +195 -70
  21. package/dist/components/editor/components/CustomFontSizeModal.d.ts +13 -0
  22. package/dist/components/editor/components/CustomFontSizeModal.js +81 -0
  23. package/dist/components/editor/components/DistrictGridModal.js +1 -1
  24. package/dist/components/editor/components/SchemaInsertionModal.js +1 -1
  25. package/dist/components/editor/editor.css +2 -0
  26. package/dist/components/editor/extensions/FontSize.d.ts +19 -0
  27. package/dist/components/editor/extensions/FontSize.js +41 -0
  28. package/dist/components/viewer/DynamicViewer.js +50 -15
  29. package/dist/components/viewer/useViewDrawer.js +42 -2
  30. package/dist/hooks/useAppNavigation.d.ts +3 -0
  31. package/dist/hooks/useAppNavigation.js +37 -0
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.js +1 -0
  34. package/dist/lib/QueryBuilder.d.ts +3 -1
  35. package/dist/lib/QueryBuilder.js +10 -0
  36. package/dist/lib/api.d.ts +6 -0
  37. package/dist/lib/api.js +45 -24
  38. package/dist/lib/schemaUtils.js +1 -1
  39. package/dist/lib/types.d.ts +6 -1
  40. package/dist/nextmin.css +1 -1
  41. package/dist/providers/NextMinProvider.d.ts +5 -1
  42. package/dist/providers/NextMinProvider.js +12 -2
  43. package/dist/providers/themeResolver.d.ts +25 -0
  44. package/dist/providers/themeResolver.js +174 -0
  45. package/dist/router/NextMinRouter.d.ts +0 -1
  46. package/dist/router/NextMinRouter.js +0 -1
  47. package/dist/views/CreateEditPage.js +20 -11
  48. package/dist/views/DashboardPage.js +1 -1
  49. package/dist/views/ListPage.js +37 -6
  50. package/dist/views/SettingsEdit.js +1 -1
  51. package/dist/views/list/DataTableHero.d.ts +5 -5
  52. package/dist/views/list/DataTableHero.js +154 -41
  53. package/dist/views/list/ListHeader.js +9 -1
  54. package/package.json +4 -2
package/README.md CHANGED
@@ -63,12 +63,14 @@ Wrap only the admin area with the provider and render the AdminApp. Mark these f
63
63
  // app/admin/layout.tsx
64
64
  'use client'
65
65
  import { NextMinProvider } from '@airoom/nextmin-react';
66
+ import theme from '../../nextmin-theme';
66
67
 
67
68
  export default function AdminLayout({ children }: { children: React.ReactNode }) {
68
69
  return (
69
70
  <NextMinProvider
70
71
  apiUrl={process.env.NEXT_PUBLIC_NEXTMIN_API_URL}
71
72
  apiKey={process.env.NEXT_PUBLIC_NEXTMIN_API_KEY}
73
+ theme={theme}
72
74
  >
73
75
  {children}
74
76
  </NextMinProvider>
@@ -128,6 +130,61 @@ export default Admin;
128
130
 
129
131
  Wrap with `<NextMinProvider>` at a top level where appropriate (e.g., `_app.tsx`) and ensure it runs on the client.
130
132
 
133
+ ## Dynamic Theme System
134
+
135
+ NextMin supports a dynamic runtime styling and layout configuration system. You can place a configuration file named `nextmin-theme.ts` or `nextmin-theme.js` in the root of your host project (where `package.json` is located).
136
+
137
+ The package will automatically detect and load the configuration at startup/SSR (Server-Side Rendering) and sync it with the client with zero build-time configuration or hydration mismatches.
138
+
139
+ ### Configuration Example (`nextmin-theme.ts` or `nextmin-theme.js`)
140
+
141
+ Create a file in your project root:
142
+
143
+ ```ts
144
+ import type { ThemeConfig } from '@airoom/nextmin-react';
145
+
146
+ export default {
147
+ // Main background color of the admin layout.
148
+ // Can be a Tailwind class (e.g. 'bg-slate-50', 'bg-[#123456]') or direct color (e.g. '#f3f4f6', 'rgba(0,0,0,0.05)')
149
+ mainAdminBgColor: '#e0f2fe',
150
+
151
+ // Sidebar container background color
152
+ sidebarColor: '#0f172a',
153
+
154
+ // Active menu link item background color
155
+ activeMenuBgColor: '#38bdf8',
156
+
157
+ // Top Nav container background color (if custom navbar is implemented)
158
+ topNavBgColor: '#ffffff',
159
+
160
+ // Brand header height (top nav equivalent height in sidebar)
161
+ // Can be a Tailwind class (e.g. 'h-16') or raw number/length (e.g. 70, '70px')
162
+ topNavHeight: '70px',
163
+
164
+ // Global form input controls height
165
+ // Can be a Tailwind class (e.g. 'h-10') or raw number/length (e.g. 48, '48px')
166
+ inputHeight: '48px',
167
+
168
+ // Global button/input/select control border radius
169
+ // Can be a Tailwind class (e.g. 'rounded-lg') or raw number/length (e.g. 12, '12px')
170
+ inputRadius: '12px',
171
+
172
+ // Sidebar column width
173
+ // Can be a Tailwind class (e.g. 'w-64') or raw number/length (e.g. 280, '280px')
174
+ sidebarWidth: '280px',
175
+
176
+ // Sidebar layout alignment position: 'left' or 'right'
177
+ sidebarPosition: 'right',
178
+
179
+ // HeroUI primary button and highlight colors (converts hex color to HSL automatically)
180
+ primaryColor: '#001f43',
181
+ } as ThemeConfig;
182
+ ```
183
+
184
+ > [!TIP]
185
+ > All sizes and dimensions defined as direct CSS units (like `px`, `rem`, etc.) will automatically have `max-height: 100%` and `max-width: 100%` constraints applied to ensure they conform to their respective parent container boundaries.
186
+
187
+
131
188
  ## Default admin credentials (after setup)
132
189
 
133
190
  After completing the backend setup, sign in with the default super user and change the password immediately:
@@ -1,3 +1,4 @@
1
+ import React from 'react';
1
2
  export type LatLng = {
2
3
  lat: number;
3
4
  lng: number;
@@ -17,5 +18,7 @@ export type AddressAutocompleteGoogleProps = {
17
18
  countryCodes?: string[];
18
19
  limit?: number;
19
20
  apiKey?: string;
21
+ isInvalid?: boolean;
22
+ errorMessage?: React.ReactNode;
20
23
  };
21
- export default function AddressAutocompleteGoogle({ name, label, description, value, onChange, disabled, required, className, classNames, countryCodes, limit, apiKey, }: AddressAutocompleteGoogleProps): import("react/jsx-runtime").JSX.Element;
24
+ export default function AddressAutocompleteGoogle({ name, label, description, value, onChange, disabled, required, className, classNames, countryCodes, limit, apiKey, isInvalid, errorMessage, }: AddressAutocompleteGoogleProps): import("react/jsx-runtime").JSX.Element;
@@ -12,7 +12,7 @@ function deriveStableKey(s) {
12
12
  const label = s?.placePrediction?.text?.toString?.();
13
13
  return pid ? String(pid) : label ? String(label) : null;
14
14
  }
15
- export default function AddressAutocompleteGoogle({ name, label, description, value, onChange, disabled, required, className, classNames, countryCodes, limit = 8, apiKey, }) {
15
+ export default function AddressAutocompleteGoogle({ name, label, description, value, onChange, disabled, required, className, classNames, countryCodes, limit = 8, apiKey, isInvalid, errorMessage, }) {
16
16
  // ---------------- Hooks (ALL before any return) ----------------
17
17
  const [q, setQ] = useState(value ?? '');
18
18
  useEffect(() => {
@@ -151,7 +151,7 @@ export default function AddressAutocompleteGoogle({ name, label, description, va
151
151
  return String(sel);
152
152
  }
153
153
  // ------- Render: keep subtree shape stable; disable when not ready -------
154
- return (_jsx("div", { className: className, children: _jsx(Autocomplete, { "aria-label": label ?? name, label: label, labelPlacement: "outside", placeholder: "Type address", name: name, isRequired: required, description: description, variant: "bordered", classNames: classNames, isDisabled: disabled || !ready, items: ready ? items : [], inputValue: q, isClearable: true, onClear: () => {
154
+ return (_jsx("div", { className: className, children: _jsx(Autocomplete, { isInvalid: isInvalid, errorMessage: errorMessage, "aria-label": label ?? name, label: label, labelPlacement: "outside", placeholder: "Type address", name: name, isRequired: required, description: description, variant: "bordered", classNames: classNames, isDisabled: disabled || !ready, items: ready ? items : [], inputValue: q, isClearable: true, onClear: () => {
155
155
  manualClearRef.current = true;
156
156
  setQ('');
157
157
  setItems([]);
@@ -13,6 +13,9 @@ import { api } from '../lib/api';
13
13
  import { AuthClient } from '../lib/AuthClient';
14
14
  import { RealtimeClient } from '../lib/RealtimeClient';
15
15
  import { systemLoading, systemLoaded, systemFailed, } from '../state/nextMinSlice';
16
+ import clsx from 'clsx';
17
+ import { isTailwindClass, resolveHeightValue, resolveRadiusValue, resolveSidebarWidth, hexToHslComponents, } from '../providers/themeResolver';
18
+ import { useNextMin } from '../providers/NextMinProvider';
16
19
  export function AdminApp() {
17
20
  const dispatch = useDispatch();
18
21
  const router = useRouter();
@@ -89,6 +92,86 @@ export function AdminApp() {
89
92
  router.replace('/admin/dashboard');
90
93
  }
91
94
  }, [hydrated, haveToken, onAuthRoute, router]);
95
+ const { theme } = useNextMin();
96
+ const themeStyles = useMemo(() => {
97
+ if (!theme)
98
+ return {};
99
+ const styles = {};
100
+ if (theme.sidebarWidth) {
101
+ const width = resolveSidebarWidth(theme.sidebarWidth);
102
+ if (width && !isTailwindClass(theme.sidebarWidth)) {
103
+ styles['--nm-sidebar-width'] = width;
104
+ }
105
+ }
106
+ if (theme.topNavHeight) {
107
+ const height = resolveHeightValue(theme.topNavHeight);
108
+ if (height && !isTailwindClass(theme.topNavHeight)) {
109
+ styles['--nm-top-nav-height'] = height;
110
+ }
111
+ }
112
+ if (theme.inputHeight) {
113
+ const height = resolveHeightValue(theme.inputHeight);
114
+ if (height && !isTailwindClass(theme.inputHeight)) {
115
+ styles['--nm-input-height'] = height;
116
+ }
117
+ }
118
+ if (theme.inputRadius) {
119
+ const radius = resolveRadiusValue(theme.inputRadius);
120
+ if (radius && !isTailwindClass(theme.inputRadius)) {
121
+ styles['--nm-input-radius'] = radius;
122
+ }
123
+ }
124
+ if (theme.mainAdminBgColor && !isTailwindClass(theme.mainAdminBgColor)) {
125
+ styles['--nm-admin-bg'] = theme.mainAdminBgColor;
126
+ }
127
+ if (theme.sidebarColor && !isTailwindClass(theme.sidebarColor)) {
128
+ styles['--nm-sidebar-bg'] = theme.sidebarColor;
129
+ }
130
+ if (theme.activeMenuBgColor && !isTailwindClass(theme.activeMenuBgColor)) {
131
+ styles['--nm-active-menu-bg'] = theme.activeMenuBgColor;
132
+ }
133
+ if (theme.activeMenuTextColor && !isTailwindClass(theme.activeMenuTextColor)) {
134
+ styles['--nm-active-menu-text'] = theme.activeMenuTextColor;
135
+ }
136
+ if (theme.menuTextColor && !isTailwindClass(theme.menuTextColor)) {
137
+ styles['--nm-menu-text'] = theme.menuTextColor;
138
+ }
139
+ if (theme.menuHoverBgColor && !isTailwindClass(theme.menuHoverBgColor)) {
140
+ styles['--nm-menu-hover-bg'] = theme.menuHoverBgColor;
141
+ }
142
+ if (theme.menuHoverTextColor && !isTailwindClass(theme.menuHoverTextColor)) {
143
+ styles['--nm-menu-hover-text'] = theme.menuHoverTextColor;
144
+ }
145
+ if (theme.sidebarTextColor && !isTailwindClass(theme.sidebarTextColor)) {
146
+ styles['--nm-sidebar-text'] = theme.sidebarTextColor;
147
+ }
148
+ if (theme.sidebarBorderColor && !isTailwindClass(theme.sidebarBorderColor)) {
149
+ styles['--nm-sidebar-border'] = theme.sidebarBorderColor;
150
+ }
151
+ if (theme.topNavBgColor && !isTailwindClass(theme.topNavBgColor)) {
152
+ styles['--nm-top-nav-bg'] = theme.topNavBgColor;
153
+ }
154
+ if (theme.primaryColor && !isTailwindClass(theme.primaryColor)) {
155
+ const hslVal = hexToHslComponents(theme.primaryColor);
156
+ if (hslVal) {
157
+ styles['--heroui-primary'] = hslVal;
158
+ styles['--heroui-primary-500'] = hslVal;
159
+ styles['--heroui-primary-400'] = hslVal;
160
+ styles['--heroui-primary-600'] = hslVal;
161
+ const parts = hslVal.split(' ');
162
+ if (parts.length === 3) {
163
+ const lVal = parseInt(parts[2]);
164
+ const fgHsl = lVal > 60 ? '0 0% 0%' : '0 0% 100%';
165
+ styles['--heroui-primary-foreground'] = fgHsl;
166
+ }
167
+ }
168
+ }
169
+ return styles;
170
+ }, [theme]);
171
+ const mainAdminBgClass = theme?.mainAdminBgColor && isTailwindClass(theme.mainAdminBgColor)
172
+ ? theme.mainAdminBgColor
173
+ : '';
174
+ const sidebarPosition = theme?.sidebarPosition || 'left';
92
175
  // 1) While hydrating: full screen loader
93
176
  if (!hydrated) {
94
177
  return _jsx(SectionLoader, { label: "Please wait..." });
@@ -107,5 +190,7 @@ export function AdminApp() {
107
190
  return _jsx(SectionLoader, { label: "Loading dashboard\u2026" });
108
191
  }
109
192
  // 4) Authed → Admin chrome + pages
110
- return (_jsxs(_Fragment, { children: [_jsx(AdminRouteNormalizer, {}), _jsxs("div", { className: "grid grid-cols-[240px_1fr] h-[100dvh]", children: [_jsx(Sidebar, {}), _jsx("div", { className: "p-4 overflow-auto relative", children: _jsx(NextMinRouter, {}) })] })] }));
193
+ return (_jsxs(_Fragment, { children: [_jsx(AdminRouteNormalizer, {}), _jsxs("div", { className: clsx("nextmin-theme-provider flex h-[100dvh] w-full", sidebarPosition === 'right' ? "flex-row-reverse" : "flex-row"), style: themeStyles, children: [_jsx(Sidebar, {}), _jsx("div", { className: clsx("flex-1 p-4 overflow-auto relative", mainAdminBgClass), style: {
194
+ backgroundColor: theme?.mainAdminBgColor && !isTailwindClass(theme.mainAdminBgColor) ? 'var(--nm-admin-bg)' : undefined
195
+ }, children: _jsx(NextMinRouter, {}) })] })] }));
111
196
  }
@@ -27,6 +27,9 @@ export type FileUploaderProps = {
27
27
  disabled?: boolean;
28
28
  required?: boolean;
29
29
  className?: string;
30
+ isImage?: boolean;
31
+ isInvalid?: boolean;
32
+ errorMessage?: string;
30
33
  };
31
34
  export declare const FileUploader: React.FC<FileUploaderProps>;
32
35
  export {};
@@ -69,16 +69,50 @@ const normalizeAccept = (input) => {
69
69
  });
70
70
  };
71
71
  const shallowEqArr = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
72
- // Treat only valid URLs/data/blobs as safe preview refs
72
+ // Accept any URL/blob/data reference as a valid file ref
73
73
  function isSafeFileRef(s) {
74
74
  const str = (s ?? '').trim();
75
75
  if (!str)
76
76
  return false;
77
- if (/^(https?:\/\/|data:image\/|blob:)/i.test(str))
77
+ if (/^(https?:\/\/|data:|blob:)/i.test(str))
78
78
  return true;
79
- const noQuery = str.replace(/[?#].*$/, '');
80
- return /\.(png|jpe?g|gif|webp|svg|bmp|ico|tiff?|avif)$/i.test(noQuery);
79
+ return /\.\w{2,6}$/i.test(str.replace(/[?#].*$/, ''));
81
80
  }
81
+ // Check if a URL or mime type is an image
82
+ function isImageRef(url, type, isImageMode) {
83
+ if (isImageMode)
84
+ return true;
85
+ if (type && type.startsWith('image/'))
86
+ return true;
87
+ const noQuery = (url ?? '').replace(/[?#].*$/, '');
88
+ return /\.(png|jpe?g|gif|webp|svg|bmp|ico|tiff?|avif)$/i.test(noQuery) ||
89
+ /^data:image\//i.test(url);
90
+ }
91
+ // Friendly label for file type
92
+ function fileTypeLabel(url, type) {
93
+ if (type) {
94
+ if (type === 'application/pdf')
95
+ return 'PDF';
96
+ if (type.startsWith('image/'))
97
+ return type.split('/')[1].toUpperCase();
98
+ if (type.startsWith('video/'))
99
+ return 'VIDEO';
100
+ if (type.startsWith('audio/'))
101
+ return 'AUDIO';
102
+ if (type.includes('word'))
103
+ return 'DOCX';
104
+ if (type.includes('excel') || type.includes('spreadsheet'))
105
+ return 'XLSX';
106
+ if (type.includes('powerpoint') || type.includes('presentation'))
107
+ return 'PPTX';
108
+ if (type.includes('zip'))
109
+ return 'ZIP';
110
+ }
111
+ const ext = (url ?? '').replace(/[?#].*$/, '').split('.').pop()?.toUpperCase() || 'FILE';
112
+ return ext.length > 5 ? 'FILE' : ext;
113
+ }
114
+ // Document icon SVG (used for non-image files)
115
+ const DocIcon = ({ label }) => (_jsxs("div", { className: "w-full h-full flex flex-col items-center justify-center gap-2 p-2", children: [_jsxs("svg", { fill: "none", viewBox: "0 0 48 48", className: "w-10 h-10 text-primary", "aria-hidden": true, children: [_jsx("rect", { x: "8", y: "4", width: "26", height: "36", rx: "3", fill: "currentColor", fillOpacity: "0.12", stroke: "currentColor", strokeWidth: "2" }), _jsx("path", { d: "M22 4v10h12", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }), _jsx("path", { d: "M14 20h20M14 26h16M14 32h12", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" })] }), _jsx("span", { className: "text-xs font-bold text-primary uppercase tracking-wider", children: label })] }));
82
116
  // Normalize incoming controlled value into an array of strings for comparison
83
117
  function normalizeToArray(v) {
84
118
  if (Array.isArray(v))
@@ -87,7 +121,7 @@ function normalizeToArray(v) {
87
121
  return [v];
88
122
  return [];
89
123
  }
90
- export const FileUploader = ({ name, label, endpoint = DEFAULT_ENDPOINT, getAuthHeaders = defaultAuthHeaders, accept, multiple = false, maxFilesCount, maxSizeBytes = DEFAULT_MAX, value, onChange, mapResult = (it) => it.url, description, disabled, required, className, }) => {
124
+ export const FileUploader = ({ name, label, endpoint = DEFAULT_ENDPOINT, getAuthHeaders = defaultAuthHeaders, accept, multiple = false, maxFilesCount, maxSizeBytes = DEFAULT_MAX, value, onChange, mapResult = (it) => it.url, description, disabled, required, className, isImage, isInvalid: isInvalidProp, errorMessage, }) => {
91
125
  const containerRef = useRef(null);
92
126
  const pickerRef = useRef(null);
93
127
  // **NO readOnly/disabled** -> participates in native validation
@@ -149,6 +183,13 @@ export const FileUploader = ({ name, label, endpoint = DEFAULT_ENDPOINT, getAuth
149
183
  const showPlusPlaceholder = !disabled && remainingCapacity > 0 && !uploadingOrIdle;
150
184
  const acceptTokens = useMemo(() => normalizeAccept(accept), [accept]);
151
185
  const acceptAttr = useMemo(() => acceptTokens?.join(',') || undefined, [acceptTokens]);
186
+ const isImageMode = useMemo(() => {
187
+ if (isImage)
188
+ return true;
189
+ if (!acceptTokens)
190
+ return false;
191
+ return acceptTokens.some((t) => t.startsWith('image/'));
192
+ }, [isImage, acceptTokens]);
152
193
  const oversize = (f) => f.size > (maxSizeBytes || DEFAULT_MAX);
153
194
  const typeAllowed = (f) => {
154
195
  if (!acceptTokens || !acceptTokens.length)
@@ -282,28 +323,38 @@ export const FileUploader = ({ name, label, endpoint = DEFAULT_ENDPOINT, getAuth
282
323
  : 'Upload limit reached.');
283
324
  return;
284
325
  }
285
- const f = files[0];
286
- if (oversize(f)) {
287
- setTouched(true);
288
- setError(`File too large. Max ${((maxSizeBytes || DEFAULT_MAX) / MB).toFixed(1)} MB`);
289
- return;
326
+ const filesToProcess = multiple ? files.slice(0, remainingCapacity) : [files[0]];
327
+ const stagedItems = [];
328
+ let hasError = false;
329
+ let errMessage = '';
330
+ for (const f of filesToProcess) {
331
+ if (oversize(f)) {
332
+ errMessage = `File too large. Max ${((maxSizeBytes || DEFAULT_MAX) / MB).toFixed(1)} MB`;
333
+ hasError = true;
334
+ continue;
335
+ }
336
+ if (!typeAllowed(f)) {
337
+ errMessage = `File type not allowed${acceptAttr ? ` (${acceptAttr})` : ''}`;
338
+ hasError = true;
339
+ continue;
340
+ }
341
+ stagedItems.push({
342
+ id: uid(),
343
+ file: f,
344
+ name: f.name,
345
+ size: f.size,
346
+ type: f.type,
347
+ progress: 0,
348
+ status: 'idle',
349
+ previewUrl: URL.createObjectURL(f),
350
+ });
290
351
  }
291
- if (!typeAllowed(f)) {
352
+ if (hasError && stagedItems.length === 0) {
292
353
  setTouched(true);
293
- setError(`File type not allowed${acceptAttr ? ` (${acceptAttr})` : ''}`);
354
+ setError(errMessage);
294
355
  return;
295
356
  }
296
- const staged = {
297
- id: uid(),
298
- file: f,
299
- name: f.name,
300
- size: f.size,
301
- type: f.type,
302
- progress: 0,
303
- status: 'idle',
304
- previewUrl: URL.createObjectURL(f),
305
- };
306
- setError(null);
357
+ setError(hasError ? errMessage : null);
307
358
  if (!multiple) {
308
359
  setItems((prev) => {
309
360
  prev.forEach((p) => p.xhr?.abort());
@@ -311,11 +362,11 @@ export const FileUploader = ({ name, label, endpoint = DEFAULT_ENDPOINT, getAuth
311
362
  if (p.previewUrl?.startsWith('blob:'))
312
363
  URL.revokeObjectURL(p.previewUrl);
313
364
  });
314
- return [staged];
365
+ return stagedItems.slice(0, 1);
315
366
  });
316
367
  }
317
368
  else {
318
- setItems((prev) => [...prev, staged]);
369
+ setItems((prev) => [...prev, ...stagedItems]);
319
370
  }
320
371
  };
321
372
  // Upload one
@@ -332,6 +383,10 @@ export const FileUploader = ({ name, label, endpoint = DEFAULT_ENDPOINT, getAuth
332
383
  const form = new FormData();
333
384
  // @ts-ignore backend expects 'file'
334
385
  form.append('file', item.file);
386
+ const originalName = item.file?.name || item.name || '';
387
+ if (originalName) {
388
+ form.append('originalName', originalName);
389
+ }
335
390
  xhr.upload.onprogress = (ev) => {
336
391
  if (!ev.lengthComputable)
337
392
  return;
@@ -437,7 +492,8 @@ export const FileUploader = ({ name, label, endpoint = DEFAULT_ENDPOINT, getAuth
437
492
  const files = Array.from(e.dataTransfer.files ?? []);
438
493
  stageAndQueue(files);
439
494
  };
440
- const isInvalid = !!error;
495
+ const isInvalid = isInvalidProp || !!error;
496
+ const displayError = errorMessage || error;
441
497
  return (_jsxs("div", { ref: containerRef, className: className, role: "group", "aria-labelledby": label ? `${name}-label` : undefined, "aria-invalid": isInvalid || undefined, "aria-describedby": isInvalid ? `${name}-error` : undefined, "data-invalid": isInvalid ? '' : undefined, children: [label ? (_jsxs("label", { id: `${name}-label`, className: `block text-sm mb-1 ${isInvalid ? 'text-danger' : 'text-small'}`, htmlFor: `uploader-${name}`, children: [label, " ", required ? _jsx("span", { className: "text-danger", children: "*" }) : null] })) : null, _jsx("input", { ref: validatorRef, type: "text", name: `${name}__value`, value: doneItems
442
498
  .map((d) => d.uploaded?.url || '')
443
499
  .filter(Boolean)
@@ -450,10 +506,10 @@ export const FileUploader = ({ name, label, endpoint = DEFAULT_ENDPOINT, getAuth
450
506
  setTouched(true);
451
507
  setError('Please wait for uploads to finish.');
452
508
  } }), _jsx("input", { ref: pickerRef, type: "file", accept: acceptTokens?.join(','), multiple: multiple, onChange: onInputChange, hidden: true, disabled: disabled, name: name, id: `uploader-${name}` }), _jsxs("div", { className: "grid gap-3 grid-cols-3", onDragOver: (e) => e.preventDefault(), onDrop: onDrop, children: [items.map((it) => (_jsxs("div", { className: 'relative aspect-square rounded-xl overflow-hidden border bg-default-50 ' +
453
- (isInvalid ? 'border-danger text-danger' : 'border-default-200'), children: [it.previewUrl ? (_jsx("img", { src: it.previewUrl, alt: it.name, className: "w-full h-full object-cover" })) : (_jsx("div", { className: "w-full h-full flex items-center justify-center text-xs text-default-600", children: it.name })), it.status === 'uploading' ? (_jsx("div", { className: "absolute inset-0 bg-black/30 flex items=end p-2", children: _jsx(Progress, { "aria-label": "upload", size: "sm", value: it.progress, className: "w-full" }) })) : null, it.status === 'deleting' ? (_jsx("div", { className: "absolute inset-0 bg-black/30 flex items-center justify-center text-white text-xs", children: "Deleting\u2026" })) : null, it.status === 'error' ? (_jsx("div", { className: "absolute bottom-0 left-0 right-0 bg-danger text-white text-xs px-2 py-1", children: it.error || 'Upload failed' })) : null, _jsx("div", { className: "absolute top-1 right-1 flex gap-1", children: it.status === 'uploading' ? (_jsx(Tooltip, { content: "Cancel", children: _jsx("button", { type: "button", onClick: () => itemsRef.current.find((x) => x.id === it.id)?.xhr?.abort(), className: "bg-white/80 hover:bg-white text-xs px-2 py-1 rounded", "aria-label": "Cancel upload", children: "\u2715" }) })) : it.status === 'error' ? (_jsx(Tooltip, { content: "Retry", children: _jsx("button", { type: "button", onClick: () => uploadOne(it.id), className: "bg-white/80 hover:bg-white text-xs px-2 py-1 rounded", "aria-label": "Retry upload", children: "Retry" }) })) : (_jsx(Tooltip, { content: "Delete", children: _jsx(Button, { variant: "ghost", color: "danger", isIconOnly: true, size: "sm", onPress: () => requestDelete(it.id), isDisabled: it.status === 'deleting', "aria-label": "Delete file", children: _jsx(TrashIcon, {}) }) })) })] }, it.id))), showPlusPlaceholder ? (_jsx("button", { type: "button", onClick: () => pickerRef.current?.click(), disabled: disabled, className: 'aspect-square rounded-xl border-2 border-dashed transition-colors flex items-center justify-center ' +
509
+ (isInvalid ? 'border-danger text-danger' : 'border-default-200'), children: [it.previewUrl && isImageRef(it.previewUrl, it.type, isImageMode) ? (_jsx("img", { src: it.previewUrl, alt: it.name, className: "w-full h-full object-cover" })) : it.previewUrl || it.name ? (_jsx(DocIcon, { label: fileTypeLabel(it.previewUrl ?? it.name, it.type) })) : (_jsx("div", { className: "w-full h-full flex items-center justify-center text-xs text-default-600", children: it.name })), it.status === 'uploading' ? (_jsx("div", { className: "absolute inset-0 bg-black/30 flex items=end p-2", children: _jsx(Progress, { "aria-label": "upload", size: "sm", value: it.progress, className: "w-full" }) })) : null, it.status === 'deleting' ? (_jsx("div", { className: "absolute inset-0 bg-black/30 flex items-center justify-center text-white text-xs", children: "Deleting\u2026" })) : null, it.status === 'error' ? (_jsx("div", { className: "absolute bottom-0 left-0 right-0 bg-danger text-white text-xs px-2 py-1", children: it.error || 'Upload failed' })) : null, _jsx("div", { className: "absolute top-1 right-1 flex gap-1", children: it.status === 'uploading' ? (_jsx(Tooltip, { content: "Cancel", children: _jsx("button", { type: "button", onClick: () => itemsRef.current.find((x) => x.id === it.id)?.xhr?.abort(), className: "bg-white/80 hover:bg-white text-xs px-2 py-1 rounded", "aria-label": "Cancel upload", children: "\u2715" }) })) : it.status === 'error' ? (_jsx(Tooltip, { content: "Retry", children: _jsx("button", { type: "button", onClick: () => uploadOne(it.id), className: "bg-white/80 hover:bg-white text-xs px-2 py-1 rounded", "aria-label": "Retry upload", children: "Retry" }) })) : (_jsx(Tooltip, { content: "Delete", children: _jsx(Button, { variant: "ghost", color: "danger", isIconOnly: true, size: "sm", onPress: () => requestDelete(it.id), isDisabled: it.status === 'deleting', "aria-label": "Delete file", children: _jsx(TrashIcon, {}) }) })) })] }, it.id))), showPlusPlaceholder ? (_jsx("button", { type: "button", onClick: () => pickerRef.current?.click(), disabled: disabled, className: 'aspect-square rounded-xl border-2 border-dashed transition-colors flex items-center justify-center ' +
454
510
  (isInvalid
455
511
  ? 'border-danger hover:border-danger text-danger'
456
- : 'border-default-300 hover:border-primary hover:bg-primary-50'), "aria-label": "Upload file", children: _jsx(PlusTile, { invalid: isInvalid }) })) : null] }), _jsx("div", { className: "mt-2 text-xs", "aria-live": "polite", children: error ? (_jsx("span", { id: `${name}-error`, className: "text-danger", children: error })) : (_jsx("span", { className: "text-default-500", children: description ??
512
+ : 'border-default-300 hover:border-primary hover:bg-primary-50'), "aria-label": "Upload file", children: _jsx(PlusTile, { invalid: isInvalid }) })) : null] }), _jsx("div", { className: "mt-2 text-xs", "aria-live": "polite", children: displayError ? (_jsx("span", { id: `${name}-error`, className: "text-danger", children: displayError })) : (_jsx("span", { className: "text-default-500", children: description ??
457
513
  `Max ${((maxSizeBytes || DEFAULT_MAX) / MB).toFixed(1)}MB` +
458
514
  (acceptAttr ? ` • ${acceptAttr}` : '') +
459
515
  (multiple && Number.isFinite(limitCount)
@@ -15,5 +15,7 @@ export type PasswordInputProps = {
15
15
  inputWrapper?: string;
16
16
  };
17
17
  autoComplete?: string;
18
+ isInvalid?: boolean;
19
+ errorMessage?: React.ReactNode;
18
20
  };
19
21
  export declare const PasswordInput: React.FC<PasswordInputProps>;
@@ -4,8 +4,8 @@ import * as React from 'react';
4
4
  import { Input } from '@heroui/react';
5
5
  export const EyeSlashFilledIcon = (props) => (_jsxs("svg", { "aria-hidden": "true", fill: "none", focusable: "false", height: "1em", role: "presentation", viewBox: "0 0 24 24", width: "1em", ...props, children: [_jsx("path", { d: "M21.2714 9.17834C20.9814 8.71834 20.6714 8.28834 20.3514 7.88834C19.9814 7.41834 19.2814 7.37834 18.8614 7.79834L15.8614 10.7983C16.0814 11.4583 16.1214 12.2183 15.9214 13.0083C15.5714 14.4183 14.4314 15.5583 13.0214 15.9083C12.2314 16.1083 11.4714 16.0683 10.8114 15.8483C10.8114 15.8483 9.38141 17.2783 8.35141 18.3083C7.85141 18.8083 8.01141 19.6883 8.68141 19.9483C9.75141 20.3583 10.8614 20.5683 12.0014 20.5683C13.7814 20.5683 15.5114 20.0483 17.0914 19.0783C18.7014 18.0783 20.1514 16.6083 21.3214 14.7383C22.2714 13.2283 22.2214 10.6883 21.2714 9.17834Z", fill: "currentColor" }), _jsx("path", { d: "M14.0206 9.98062L9.98062 14.0206C9.47062 13.5006 9.14062 12.7806 9.14062 12.0006C9.14062 10.4306 10.4206 9.14062 12.0006 9.14062C12.7806 9.14062 13.5006 9.47062 14.0206 9.98062Z", fill: "currentColor" }), _jsx("path", { d: "M18.25 5.74969L14.86 9.13969C14.13 8.39969 13.12 7.95969 12 7.95969C9.76 7.95969 7.96 9.76969 7.96 11.9997C7.96 13.1197 8.41 14.1297 9.14 14.8597L5.76 18.2497H5.75C4.64 17.3497 3.62 16.1997 2.75 14.8397C1.75 13.2697 1.75 10.7197 2.75 9.14969C3.91 7.32969 5.33 5.89969 6.91 4.91969C8.49 3.95969 10.22 3.42969 12 3.42969C14.23 3.42969 16.39 4.24969 18.25 5.74969Z", fill: "currentColor" }), _jsx("path", { d: "M14.8581 11.9981C14.8581 13.5681 13.5781 14.8581 11.9981 14.8581C11.9381 14.8581 11.8881 14.8581 11.8281 14.8381L14.8381 11.8281C14.8581 11.8881 14.8581 11.9381 14.8581 11.9981Z", fill: "currentColor" }), _jsx("path", { d: "M21.7689 2.22891C21.4689 1.92891 20.9789 1.92891 20.6789 2.22891L2.22891 20.6889C1.92891 20.9889 1.92891 21.4789 2.22891 21.7789C2.37891 21.9189 2.56891 21.9989 2.76891 21.9989C2.96891 21.9989 3.15891 21.9189 3.30891 21.7689L21.7689 3.30891C22.0789 3.00891 22.0789 2.52891 21.7689 2.22891Z", fill: "currentColor" })] }));
6
6
  export const EyeFilledIcon = (props) => (_jsxs("svg", { "aria-hidden": "true", fill: "none", focusable: "false", height: "1em", role: "presentation", viewBox: "0 0 24 24", width: "1em", ...props, children: [_jsx("path", { d: "M21.25 9.14969C18.94 5.51969 15.56 3.42969 12 3.42969C10.22 3.42969 8.49 3.94969 6.91 4.91969C5.33 5.89969 3.91 7.32969 2.75 9.14969C1.75 10.7197 1.75 13.2697 2.75 14.8397C5.06 18.4797 8.44 20.5597 12 20.5597C13.78 20.5597 15.51 20.0397 17.09 19.0697C18.67 18.0897 20.09 16.6597 21.25 14.8397C22.25 13.2797 22.25 10.7197 21.25 9.14969ZM12 16.0397C9.76 16.0397 7.96 14.2297 7.96 11.9997C7.96 9.76969 9.76 7.95969 12 7.95969C14.24 7.95969 16.04 9.76969 16.04 11.9997C16.04 14.2297 14.24 16.0397 12 16.0397Z", fill: "currentColor" }), _jsx("path", { d: "M11.9984 9.14062C10.4284 9.14062 9.14844 10.4206 9.14844 12.0006C9.14844 13.5706 10.4284 14.8506 11.9984 14.8506C13.5684 14.8506 14.8584 13.5706 14.8584 12.0006C14.8584 10.4306 13.5684 9.14062 11.9984 9.14062Z", fill: "currentColor" })] }));
7
- export const PasswordInput = ({ id, name = 'password', label = 'Password', value, onChange, disabled, required, description, className, classNames, autoComplete = 'new-password', }) => {
7
+ export const PasswordInput = ({ id, name = 'password', label = 'Password', value, onChange, disabled, required, description, className, classNames, autoComplete = 'new-password', isInvalid, errorMessage, }) => {
8
8
  const [isVisible, setIsVisible] = React.useState(false);
9
9
  const toggleVisibility = () => setIsVisible((v) => !v);
10
- return (_jsx(Input, { id: id, name: name, label: label, labelPlacement: "outside-top", type: isVisible ? 'text' : 'password', value: value, onChange: (e) => onChange(e.target.value), isDisabled: disabled, isRequired: required, description: description, variant: "bordered", className: className, classNames: classNames, autoComplete: autoComplete, endContent: _jsx("button", { type: "button", "aria-label": isVisible ? 'Hide password' : 'Show password', className: "focus:outline-solid outline-transparent", onClick: toggleVisibility, children: isVisible ? (_jsx(EyeSlashFilledIcon, { className: "text-2xl text-default-400 pointer-events-none" })) : (_jsx(EyeFilledIcon, { className: "text-2xl text-default-400 pointer-events-none" })) }) }));
10
+ return (_jsx(Input, { id: id, name: name, label: label, labelPlacement: "outside-top", type: isVisible ? 'text' : 'password', value: value, onChange: (e) => onChange(e.target.value), isDisabled: disabled, isRequired: required, description: description, variant: "bordered", className: className, classNames: classNames, autoComplete: autoComplete, isInvalid: isInvalid, errorMessage: errorMessage, endContent: _jsx("button", { type: "button", "aria-label": isVisible ? 'Hide password' : 'Show password', className: "focus:outline-solid outline-transparent", onClick: toggleVisibility, children: isVisible ? (_jsx(EyeSlashFilledIcon, { className: "text-2xl text-default-400 pointer-events-none" })) : (_jsx(EyeFilledIcon, { className: "text-2xl text-default-400 pointer-events-none" })) }) }));
11
11
  };
@@ -22,5 +22,7 @@ export interface PhoneInputProps {
22
22
  minLength?: number;
23
23
  maxLength?: number;
24
24
  regex?: string;
25
+ isInvalid?: boolean;
26
+ errorMessage?: React.ReactNode;
25
27
  }
26
28
  export declare const PhoneInput: React.FC<PhoneInputProps>;
@@ -59,7 +59,7 @@ const caretPosForDigitCount = (normMask, digitCount) => {
59
59
  return clamp(slots[k - 1] + 1, 0, normMask.length);
60
60
  };
61
61
  const digitsBeforeCaret = (maskedValue, caret, regex) => onlyDigits(maskedValue.slice(0, clamp(caret, 0, maskedValue.length)), regex).length;
62
- export const PhoneInput = ({ id, name, label, mask, value, onChange, disabled, required, description, className, classNames, minLength, maxLength, regex, }) => {
62
+ export const PhoneInput = ({ id, name, label, mask, value, onChange, disabled, required, description, className, classNames, minLength, maxLength, regex, isInvalid, errorMessage, }) => {
63
63
  const inputRef = useRef(null);
64
64
  // Normalize the mask to use lowercase 'x' as slots for internal logic
65
65
  const { normMask, maxDigits, placeholder, pattern } = useMemo(() => {
@@ -147,5 +147,5 @@ export const PhoneInput = ({ id, name, label, mask, value, onChange, disabled, r
147
147
  const nextCaret = caretPosForDigitCount(normMask, Math.min(pastedDigits.length, finalMax));
148
148
  setCaret(nextCaret);
149
149
  };
150
- return (_jsx(Input, { ref: inputRef, variant: "bordered", classNames: classNames, id: id, name: name, label: label, labelPlacement: "outside-top", value: masked, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, isDisabled: disabled, description: description ?? `Format: ${mask}`, className: className ?? 'w-full', isRequired: required, inputMode: "tel", pattern: pattern, minLength: minLength, maxLength: normMask.length, placeholder: placeholder }));
150
+ return (_jsx(Input, { ref: inputRef, variant: "bordered", classNames: classNames, id: id, name: name, label: label, labelPlacement: "outside-top", value: masked, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, isDisabled: disabled, description: description ?? `Format: ${mask}`, className: className ?? 'w-full', isRequired: required, inputMode: "tel", pattern: pattern, minLength: minLength, maxLength: normMask.length, placeholder: placeholder, isInvalid: isInvalid, errorMessage: errorMessage }));
151
151
  };
@@ -0,0 +1,10 @@
1
+ import { CustomAction } from '../lib/types';
2
+ interface QRCodeModalProps {
3
+ isOpen: boolean;
4
+ onClose: () => void;
5
+ item: any;
6
+ action?: CustomAction;
7
+ modelName?: string;
8
+ }
9
+ export declare function QRCodeModal({ isOpen, onClose, item: initialItem, action, modelName, }: QRCodeModalProps): import("react/jsx-runtime").JSX.Element;
10
+ export {};
@@ -0,0 +1,153 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useEffect, useState, useMemo } from 'react';
4
+ import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, Input, Chip, Spinner, Tooltip, } from '@heroui/react';
5
+ import { QrCode, Download, Copy, Check, ExternalLink, Stethoscope, } from 'lucide-react';
6
+ import QRCode from 'qrcode';
7
+ import { api } from '../lib/api';
8
+ export function QRCodeModal({ isOpen, onClose, item: initialItem, action, modelName = 'doctors', }) {
9
+ const [item, setItem] = useState(initialItem);
10
+ const [svgString, setSvgString] = useState('');
11
+ const [generating, setGenerating] = useState(false);
12
+ const [copiedUrl, setCopiedUrl] = useState(false);
13
+ useEffect(() => {
14
+ setItem(initialItem);
15
+ }, [initialItem]);
16
+ // If item is missing slug or other fields, fetch full doc
17
+ useEffect(() => {
18
+ if (!isOpen || !initialItem)
19
+ return;
20
+ const id = initialItem.id || initialItem._id;
21
+ if (id && initialItem.slug === undefined && modelName) {
22
+ api.get(modelName, String(id)).then((res) => {
23
+ if (res?.data) {
24
+ setItem(res.data);
25
+ }
26
+ }).catch((e) => {
27
+ console.error('Failed to load full doc for QR modal:', e);
28
+ });
29
+ }
30
+ }, [isOpen, initialItem, modelName]);
31
+ // Compute target URL dynamically using url or href template
32
+ const targetUrl = useMemo(() => {
33
+ if (!item)
34
+ return '';
35
+ const template = action?.url ||
36
+ action?.urlTemplate ||
37
+ (action?.href && action.href.startsWith('http') ? action.href : '') ||
38
+ 'https://doctors24.bd/doctor/{slug}';
39
+ let resolved = template;
40
+ // Replace all {fieldName} placeholders dynamically with item[fieldName]
41
+ resolved = resolved.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, fieldName) => {
42
+ if (item[fieldName] !== undefined && item[fieldName] !== null && item[fieldName] !== '') {
43
+ return String(item[fieldName]);
44
+ }
45
+ if (fieldName === 'slug') {
46
+ return item.slug || item.id || item._id || '';
47
+ }
48
+ if (fieldName === 'id') {
49
+ return String(item.id || item._id || '');
50
+ }
51
+ return match;
52
+ });
53
+ return resolved;
54
+ }, [item, action]);
55
+ // Generate SVG QR code when modal opens or targetUrl changes
56
+ useEffect(() => {
57
+ if (!isOpen || !targetUrl) {
58
+ setSvgString('');
59
+ return;
60
+ }
61
+ let cancelled = false;
62
+ async function generate() {
63
+ try {
64
+ setGenerating(true);
65
+ const svg = await QRCode.toString(targetUrl, {
66
+ type: 'svg',
67
+ margin: 2,
68
+ width: 280,
69
+ color: {
70
+ dark: '#0f172a',
71
+ light: '#ffffff',
72
+ },
73
+ });
74
+ if (!cancelled) {
75
+ setSvgString(svg);
76
+ }
77
+ }
78
+ catch (err) {
79
+ console.error('Failed to generate QR Code:', err);
80
+ }
81
+ finally {
82
+ if (!cancelled)
83
+ setGenerating(false);
84
+ }
85
+ }
86
+ generate();
87
+ return () => {
88
+ cancelled = true;
89
+ };
90
+ }, [isOpen, targetUrl]);
91
+ // Download SVG
92
+ const handleDownloadSvg = () => {
93
+ if (!svgString || !item)
94
+ return;
95
+ const fileSlug = item.slug || item.id || 'qr';
96
+ const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
97
+ const url = URL.createObjectURL(blob);
98
+ const link = document.createElement('a');
99
+ link.href = url;
100
+ link.download = `${fileSlug}-qr.svg`;
101
+ document.body.appendChild(link);
102
+ link.click();
103
+ document.body.removeChild(link);
104
+ URL.revokeObjectURL(url);
105
+ };
106
+ // Download PNG
107
+ const handleDownloadPng = async () => {
108
+ if (!targetUrl || !item)
109
+ return;
110
+ const fileSlug = item.slug || item.id || 'qr';
111
+ try {
112
+ const pngDataUrl = await QRCode.toDataURL(targetUrl, {
113
+ width: 1024,
114
+ margin: 2,
115
+ color: {
116
+ dark: '#0f172a',
117
+ light: '#ffffff',
118
+ },
119
+ });
120
+ const link = document.createElement('a');
121
+ link.href = pngDataUrl;
122
+ link.download = `${fileSlug}-qr.png`;
123
+ document.body.appendChild(link);
124
+ link.click();
125
+ document.body.removeChild(link);
126
+ }
127
+ catch (err) {
128
+ console.error('Failed to download PNG:', err);
129
+ }
130
+ };
131
+ // Copy Link
132
+ const handleCopyUrl = async () => {
133
+ if (!targetUrl)
134
+ return;
135
+ try {
136
+ await navigator.clipboard.writeText(targetUrl);
137
+ setCopiedUrl(true);
138
+ setTimeout(() => setCopiedUrl(false), 2000);
139
+ }
140
+ catch (err) {
141
+ console.error('Failed to copy URL:', err);
142
+ }
143
+ };
144
+ const doctorName = item?.fullName || item?.name || 'Doctor Profile';
145
+ const specialityText = typeof item?.speciality === 'object'
146
+ ? item?.speciality?.name
147
+ : item?.speciality || '';
148
+ return (_jsx(Modal, { isOpen: isOpen, onClose: onClose, size: "md", backdrop: "blur", placement: "center", classNames: {
149
+ base: 'bg-background dark:bg-content1 border border-default-200 shadow-xl rounded-2xl',
150
+ header: 'border-b border-default-100 pb-3',
151
+ footer: 'border-t border-default-100 pt-3',
152
+ }, children: _jsx(ModalContent, { children: () => (_jsxs(_Fragment, { children: [_jsx(ModalHeader, { className: "flex flex-col gap-1", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "p-2 rounded-lg bg-primary/10 text-primary", children: _jsx(QrCode, { size: 20 }) }), _jsxs("div", { children: [_jsx("h3", { className: "text-lg font-bold leading-tight text-foreground", children: doctorName }), specialityText && (_jsxs("p", { className: "text-xs text-default-500 flex items-center gap-1 mt-0.5", children: [_jsx(Stethoscope, { size: 12, className: "text-primary" }), specialityText] }))] })] }) }), _jsxs(ModalBody, { className: "py-4 flex flex-col items-center gap-4", children: [_jsx("div", { className: "w-full max-w-[240px] aspect-square rounded-xl bg-white p-3 shadow-sm border border-default-200 flex items-center justify-center relative overflow-hidden", children: generating ? (_jsxs("div", { className: "flex flex-col items-center gap-2", children: [_jsx(Spinner, { size: "sm", color: "primary" }), _jsx("span", { className: "text-xs text-default-500", children: "Generating SVG..." })] })) : svgString ? (_jsx("div", { className: "w-full h-full flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:max-w-full [&>svg]:max-h-full", dangerouslySetInnerHTML: { __html: svgString } })) : (_jsxs("div", { className: "text-center text-default-400 p-2", children: [_jsx(QrCode, { size: 36, className: "mx-auto mb-1 opacity-30" }), _jsx("p", { className: "text-xs", children: "No QR Code available" })] })) }), _jsxs("div", { className: "w-full space-y-1", children: [_jsxs("div", { className: "flex items-center justify-between text-xs text-default-500", children: [_jsx("span", { children: "Profile Link" }), item?.slug && (_jsxs(Chip, { size: "sm", variant: "flat", color: "primary", className: "h-5 text-[10px]", children: ["/", item.slug] }))] }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Input, { isReadOnly: true, size: "sm", value: targetUrl, variant: "flat", className: "font-mono text-xs" }), _jsx(Tooltip, { content: copiedUrl ? 'Copied!' : 'Copy URL', children: _jsx(Button, { isIconOnly: true, size: "sm", variant: "flat", color: copiedUrl ? 'success' : 'default', onPress: handleCopyUrl, children: copiedUrl ? _jsx(Check, { size: 15 }) : _jsx(Copy, { size: 15 }) }) }), _jsx(Tooltip, { content: "Open Link", children: _jsx(Button, { as: "a", href: targetUrl, target: "_blank", rel: "noopener noreferrer", isIconOnly: true, size: "sm", variant: "flat", children: _jsx(ExternalLink, { size: 15 }) }) })] })] })] }), _jsxs(ModalFooter, { className: "flex items-center justify-between gap-2", children: [_jsx(Button, { variant: "flat", color: "default", onPress: onClose, size: "sm", children: "Close" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "flat", color: "default", size: "sm", startContent: _jsx(Download, { size: 14 }), onPress: handleDownloadPng, children: "PNG" }), _jsx(Button, { color: "primary", size: "sm", className: "font-medium", startContent: _jsx(Download, { size: 14 }), onPress: handleDownloadSvg, children: "Download SVG" })] })] })] })) }) }));
153
+ }