@lucifer91299/create-portal-app 1.1.21 → 1.1.22
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/dist/cli/index.js +700 -5
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -32,6 +32,9 @@ var p = __toESM(require("@clack/prompts"));
|
|
|
32
32
|
function genPackageJson(o) {
|
|
33
33
|
const deps = {
|
|
34
34
|
"@lucifer91299/ui": o.localUiPath ? `file:${o.localUiPath}` : "^1.1.34",
|
|
35
|
+
"@dnd-kit/core": "^6.3.1",
|
|
36
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
37
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
35
38
|
"next": "^16.2.6",
|
|
36
39
|
"react": "^19.0.0",
|
|
37
40
|
"react-dom": "^19.0.0",
|
|
@@ -423,7 +426,7 @@ export default api
|
|
|
423
426
|
`;
|
|
424
427
|
}
|
|
425
428
|
function genNavConfig(o) {
|
|
426
|
-
return `import { LayoutDashboard, Settings, Users, Layers, ClipboardList } from 'lucide-react'
|
|
429
|
+
return `import { LayoutDashboard, Settings, Users, Layers, ClipboardList, FormInput } from 'lucide-react'
|
|
427
430
|
import type { NavGroup } from '@lucifer91299/ui'
|
|
428
431
|
|
|
429
432
|
export const navGroups: NavGroup[] = [
|
|
@@ -431,10 +434,11 @@ export const navGroups: NavGroup[] = [
|
|
|
431
434
|
heading: 'Main',
|
|
432
435
|
groupIcon: <LayoutDashboard className="h-3.5 w-3.5" />,
|
|
433
436
|
items: [
|
|
434
|
-
{ label: 'Dashboard',
|
|
435
|
-
{ label: 'Users',
|
|
436
|
-
{ label: '
|
|
437
|
-
{ label: '
|
|
437
|
+
{ label: 'Dashboard', href: '/dashboard', icon: <LayoutDashboard className="h-4 w-4" /> },
|
|
438
|
+
{ label: 'Users', href: '/dashboard/users', icon: <Users className="h-4 w-4" /> },
|
|
439
|
+
{ label: 'Form Builder', href: '/dashboard/form-builder', icon: <FormInput className="h-4 w-4" /> },
|
|
440
|
+
{ label: 'Components', href: '/dashboard/components', icon: <Layers className="h-4 w-4" /> },
|
|
441
|
+
{ label: 'Onboarding', href: '/dashboard/onboarding', icon: <ClipboardList className="h-4 w-4" /> },
|
|
438
442
|
],
|
|
439
443
|
},
|
|
440
444
|
{
|
|
@@ -1831,6 +1835,696 @@ export default function ComponentsPage() {
|
|
|
1831
1835
|
}
|
|
1832
1836
|
`;
|
|
1833
1837
|
}
|
|
1838
|
+
function genFormBuilderPage() {
|
|
1839
|
+
return `'use client'
|
|
1840
|
+
|
|
1841
|
+
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
1842
|
+
import {
|
|
1843
|
+
DndContext, closestCenter, KeyboardSensor, PointerSensor,
|
|
1844
|
+
useSensor, useSensors,
|
|
1845
|
+
} from '@dnd-kit/core'
|
|
1846
|
+
import type { DragEndEvent } from '@dnd-kit/core'
|
|
1847
|
+
import {
|
|
1848
|
+
arrayMove, SortableContext, sortableKeyboardCoordinates,
|
|
1849
|
+
verticalListSortingStrategy, useSortable,
|
|
1850
|
+
} from '@dnd-kit/sortable'
|
|
1851
|
+
import { CSS } from '@dnd-kit/utilities'
|
|
1852
|
+
import {
|
|
1853
|
+
GripVertical, Copy, Trash2, Plus, X,
|
|
1854
|
+
AlignLeft, AlignJustify, Calendar, Paperclip,
|
|
1855
|
+
SeparatorHorizontal, CircleDot, CheckSquare, ChevronDownSquare,
|
|
1856
|
+
ChevronDown, ChevronLeft, ChevronRight,
|
|
1857
|
+
BookOpen, Save, Send, Settings, ListChecks, ShieldCheck, MapPin,
|
|
1858
|
+
} from 'lucide-react'
|
|
1859
|
+
import { Card, CardContent, Button, Input, Select, Textarea } from '@lucifer91299/ui'
|
|
1860
|
+
|
|
1861
|
+
// \u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1862
|
+
|
|
1863
|
+
type QuestionType = 'short_answer' | 'paragraph' | 'multiple_choice' | 'checkboxes' | 'dropdown' | 'date' | 'file_upload' | 'section_header'
|
|
1864
|
+
type PresetType = 'name' | 'email' | 'contact_number' | 'aadhar_number' | 'residential_address' | 'date_of_birth' | 'state' | 'gender'
|
|
1865
|
+
|
|
1866
|
+
interface QuestionObject {
|
|
1867
|
+
id: string
|
|
1868
|
+
type: QuestionType
|
|
1869
|
+
label: string
|
|
1870
|
+
description?: string
|
|
1871
|
+
required: boolean
|
|
1872
|
+
options?: string[]
|
|
1873
|
+
order: number
|
|
1874
|
+
preset?: PresetType
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
interface PresetSelection {
|
|
1878
|
+
type: QuestionType
|
|
1879
|
+
preset?: PresetType
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// \u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1883
|
+
|
|
1884
|
+
const INDIAN_STATES = [
|
|
1885
|
+
'Andhra Pradesh', 'Arunachal Pradesh', 'Assam', 'Bihar', 'Chhattisgarh',
|
|
1886
|
+
'Goa', 'Gujarat', 'Haryana', 'Himachal Pradesh', 'Jharkhand', 'Karnataka',
|
|
1887
|
+
'Kerala', 'Madhya Pradesh', 'Maharashtra', 'Manipur', 'Meghalaya', 'Mizoram',
|
|
1888
|
+
'Nagaland', 'Odisha', 'Punjab', 'Rajasthan', 'Sikkim', 'Tamil Nadu',
|
|
1889
|
+
'Telangana', 'Tripura', 'Uttar Pradesh', 'Uttarakhand', 'West Bengal',
|
|
1890
|
+
'Delhi', 'Jammu & Kashmir', 'Ladakh',
|
|
1891
|
+
]
|
|
1892
|
+
|
|
1893
|
+
const GENDER_OPTIONS = ['male', 'female', 'other']
|
|
1894
|
+
|
|
1895
|
+
const PRESET_DEFAULT_LABELS: Record<string, string> = {
|
|
1896
|
+
name: 'Full Name', email: 'Email Address', contact_number: 'Contact Number',
|
|
1897
|
+
aadhar_number: 'Aadhar Card Number', residential_address: 'Residential Address',
|
|
1898
|
+
date_of_birth: 'Date of Birth', state: 'State', gender: 'Gender',
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
const PRESET_VALIDATION_INFO: Record<PresetType, string> = {
|
|
1902
|
+
name: 'Letters, spaces, hyphens, apostrophes, and periods only',
|
|
1903
|
+
email: 'Must be a valid email address',
|
|
1904
|
+
contact_number: '10-digit mobile number starting with 6, 7, 8, or 9',
|
|
1905
|
+
aadhar_number: '12-digit Aadhar number',
|
|
1906
|
+
residential_address: 'Free-text address',
|
|
1907
|
+
date_of_birth: 'Valid past date required',
|
|
1908
|
+
state: 'Must match an entry from the states list',
|
|
1909
|
+
gender: 'Auto-filled from the user profile',
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// \u2500\u2500 Type options & icons \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1913
|
+
|
|
1914
|
+
interface TypeOption { value: QuestionType; label: string; subItems?: { preset: PresetType; label: string }[] }
|
|
1915
|
+
|
|
1916
|
+
const TYPE_OPTIONS: TypeOption[] = [
|
|
1917
|
+
{ value: 'short_answer', label: 'Short Answer', subItems: [{ preset: 'name', label: 'Name' }, { preset: 'email', label: 'Email' }, { preset: 'contact_number', label: 'Contact Number' }, { preset: 'aadhar_number', label: 'Aadhar Number' }] },
|
|
1918
|
+
{ value: 'paragraph', label: 'Paragraph', subItems: [{ preset: 'residential_address', label: 'Residential Address' }] },
|
|
1919
|
+
{ value: 'dropdown', label: 'Dropdown', subItems: [{ preset: 'state', label: 'State' }] },
|
|
1920
|
+
{ value: 'multiple_choice', label: 'Multiple Choice', subItems: [{ preset: 'gender', label: 'Gender' }] },
|
|
1921
|
+
{ value: 'date', label: 'Date', subItems: [{ preset: 'date_of_birth', label: 'Date of Birth' }] },
|
|
1922
|
+
{ value: 'checkboxes', label: 'Checkboxes' },
|
|
1923
|
+
{ value: 'file_upload', label: 'File Upload' },
|
|
1924
|
+
{ value: 'section_header', label: 'Section Header' },
|
|
1925
|
+
]
|
|
1926
|
+
|
|
1927
|
+
const TYPE_ICONS: Record<QuestionType, React.ReactNode> = {
|
|
1928
|
+
short_answer: <AlignLeft className="w-3.5 h-3.5" />,
|
|
1929
|
+
paragraph: <AlignJustify className="w-3.5 h-3.5" />,
|
|
1930
|
+
multiple_choice: <CircleDot className="w-3.5 h-3.5" />,
|
|
1931
|
+
checkboxes: <CheckSquare className="w-3.5 h-3.5" />,
|
|
1932
|
+
dropdown: <ChevronDownSquare className="w-3.5 h-3.5" />,
|
|
1933
|
+
date: <Calendar className="w-3.5 h-3.5" />,
|
|
1934
|
+
file_upload: <Paperclip className="w-3.5 h-3.5" />,
|
|
1935
|
+
section_header: <SeparatorHorizontal className="w-3.5 h-3.5" />,
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// \u2500\u2500 PresetTypeSelector \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1939
|
+
|
|
1940
|
+
function PresetTypeSelector({ currentType, currentPreset, onSelect }: {
|
|
1941
|
+
currentType: QuestionType
|
|
1942
|
+
currentPreset?: PresetType
|
|
1943
|
+
onSelect: (s: PresetSelection) => void
|
|
1944
|
+
}) {
|
|
1945
|
+
const [open, setOpen] = useState(false)
|
|
1946
|
+
const [hoveredType, setHoveredType] = useState<QuestionType | null>(null)
|
|
1947
|
+
const [flyoutSide, setFlyoutSide] = useState<'left' | 'right'>('left')
|
|
1948
|
+
const [expandedType, setExpandedType] = useState<QuestionType | null>(null)
|
|
1949
|
+
const [isTouch, setIsTouch] = useState(false)
|
|
1950
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
1951
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
1952
|
+
|
|
1953
|
+
useEffect(() => {
|
|
1954
|
+
const mq = window.matchMedia('(hover: none) and (pointer: coarse)')
|
|
1955
|
+
setIsTouch(mq.matches)
|
|
1956
|
+
const h = (e: MediaQueryListEvent) => setIsTouch(e.matches)
|
|
1957
|
+
mq.addEventListener('change', h)
|
|
1958
|
+
return () => mq.removeEventListener('change', h)
|
|
1959
|
+
}, [])
|
|
1960
|
+
|
|
1961
|
+
useEffect(() => {
|
|
1962
|
+
if (!open) return
|
|
1963
|
+
const h = (e: PointerEvent) => {
|
|
1964
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
1965
|
+
setOpen(false); setHoveredType(null); setExpandedType(null)
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
document.addEventListener('pointerdown', h)
|
|
1969
|
+
return () => document.removeEventListener('pointerdown', h)
|
|
1970
|
+
}, [open])
|
|
1971
|
+
|
|
1972
|
+
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, [])
|
|
1973
|
+
|
|
1974
|
+
const subLabel = TYPE_OPTIONS.find(o => o.value === currentType)?.subItems?.find(s => s.preset === currentPreset)?.label
|
|
1975
|
+
const btnLabel = subLabel
|
|
1976
|
+
? \`\${TYPE_OPTIONS.find(o => o.value === currentType)?.label} \u203A \${subLabel}\`
|
|
1977
|
+
: (TYPE_OPTIONS.find(o => o.value === currentType)?.label ?? currentType)
|
|
1978
|
+
|
|
1979
|
+
return (
|
|
1980
|
+
<div ref={containerRef} className="relative">
|
|
1981
|
+
<button type="button"
|
|
1982
|
+
onClick={() => { setOpen(o => !o); setHoveredType(null); setExpandedType(null) }}
|
|
1983
|
+
className="flex items-center gap-2 pl-2.5 pr-2 py-1.5 text-sm font-medium border border-gray-200 rounded-lg bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50 transition-all min-w-[180px] focus:outline-none"
|
|
1984
|
+
>
|
|
1985
|
+
<span className="text-gray-400">{TYPE_ICONS[currentType]}</span>
|
|
1986
|
+
<span className="flex-1 text-left truncate">{btnLabel}</span>
|
|
1987
|
+
<ChevronDown className={\`w-3.5 h-3.5 text-gray-400 flex-shrink-0 transition-transform duration-200 \${open ? 'rotate-180' : ''}\`} />
|
|
1988
|
+
</button>
|
|
1989
|
+
|
|
1990
|
+
{open && (
|
|
1991
|
+
<div className="absolute right-0 top-full mt-1 z-50 w-52 bg-white border border-gray-200 rounded-xl shadow-lg py-1">
|
|
1992
|
+
{TYPE_OPTIONS.map((opt, idx) => {
|
|
1993
|
+
const isActive = currentType === opt.value && !currentPreset
|
|
1994
|
+
const hasSubs = !!(opt.subItems?.length)
|
|
1995
|
+
const showSep = idx > 0 && !!(TYPE_OPTIONS[idx - 1].subItems?.length) && !hasSubs
|
|
1996
|
+
const isExpanded = isTouch && expandedType === opt.value
|
|
1997
|
+
return (
|
|
1998
|
+
<div key={opt.value}>
|
|
1999
|
+
{showSep && <div className="my-1 border-t border-gray-100" />}
|
|
2000
|
+
<div
|
|
2001
|
+
className="relative"
|
|
2002
|
+
onMouseEnter={!isTouch ? (e) => {
|
|
2003
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
2004
|
+
if (hasSubs) {
|
|
2005
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
2006
|
+
setFlyoutSide(rect.left - 184 > 8 ? 'left' : 'right')
|
|
2007
|
+
timerRef.current = setTimeout(() => setHoveredType(opt.value), 80)
|
|
2008
|
+
} else setHoveredType(null)
|
|
2009
|
+
} : undefined}
|
|
2010
|
+
onMouseLeave={!isTouch ? () => { if (timerRef.current) clearTimeout(timerRef.current) } : undefined}
|
|
2011
|
+
>
|
|
2012
|
+
<button type="button"
|
|
2013
|
+
onClick={() => {
|
|
2014
|
+
onSelect({ type: opt.value, preset: undefined })
|
|
2015
|
+
if (isTouch && hasSubs) { setExpandedType(p => p === opt.value ? null : opt.value) }
|
|
2016
|
+
else { setOpen(false); setHoveredType(null); setExpandedType(null) }
|
|
2017
|
+
}}
|
|
2018
|
+
className={\`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors \${isActive ? 'bg-gray-100 text-gray-800 font-medium' : 'text-gray-600 hover:bg-gray-50'}\`}
|
|
2019
|
+
>
|
|
2020
|
+
<span className={isActive ? 'text-gray-600' : 'text-gray-400'}>{TYPE_ICONS[opt.value]}</span>
|
|
2021
|
+
<span className="flex-1 text-left">{opt.label}</span>
|
|
2022
|
+
{hasSubs && (isTouch
|
|
2023
|
+
? <ChevronRight className={\`w-3 h-3 text-gray-300 flex-shrink-0 transition-transform \${isExpanded ? 'rotate-90' : ''}\`} />
|
|
2024
|
+
: <ChevronLeft className="w-3 h-3 text-gray-300 flex-shrink-0" />
|
|
2025
|
+
)}
|
|
2026
|
+
</button>
|
|
2027
|
+
|
|
2028
|
+
{/* Desktop flyout */}
|
|
2029
|
+
{!isTouch && hasSubs && hoveredType === opt.value && (
|
|
2030
|
+
<div
|
|
2031
|
+
className={\`absolute top-0 w-44 bg-white border border-gray-200 rounded-xl shadow-lg py-1 \${flyoutSide === 'left' ? 'right-full mr-1' : 'left-full ml-1'}\`}
|
|
2032
|
+
onMouseEnter={() => { if (timerRef.current) clearTimeout(timerRef.current); setHoveredType(opt.value) }}
|
|
2033
|
+
onMouseLeave={() => setHoveredType(null)}
|
|
2034
|
+
>
|
|
2035
|
+
{opt.subItems!.map(sub => {
|
|
2036
|
+
const isSub = currentType === opt.value && currentPreset === sub.preset
|
|
2037
|
+
return (
|
|
2038
|
+
<button key={sub.preset} type="button"
|
|
2039
|
+
onClick={() => { onSelect({ type: opt.value, preset: sub.preset }); setOpen(false); setHoveredType(null) }}
|
|
2040
|
+
className={\`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors \${isSub ? 'bg-gray-100 text-gray-800 font-medium' : 'text-gray-600 hover:bg-gray-50'}\`}
|
|
2041
|
+
>
|
|
2042
|
+
<span className={isSub ? 'text-gray-600' : 'text-gray-400'}>{TYPE_ICONS[opt.value]}</span>
|
|
2043
|
+
{sub.label}
|
|
2044
|
+
</button>
|
|
2045
|
+
)
|
|
2046
|
+
})}
|
|
2047
|
+
</div>
|
|
2048
|
+
)}
|
|
2049
|
+
</div>
|
|
2050
|
+
|
|
2051
|
+
{/* Mobile accordion */}
|
|
2052
|
+
{isTouch && hasSubs && isExpanded && (
|
|
2053
|
+
<div className="border-t border-gray-100 bg-gray-50/60">
|
|
2054
|
+
{opt.subItems!.map(sub => {
|
|
2055
|
+
const isSub = currentType === opt.value && currentPreset === sub.preset
|
|
2056
|
+
return (
|
|
2057
|
+
<button key={sub.preset} type="button"
|
|
2058
|
+
onClick={() => { onSelect({ type: opt.value, preset: sub.preset }); setOpen(false); setExpandedType(null) }}
|
|
2059
|
+
className={\`w-full flex items-center gap-2.5 pl-8 pr-3 py-2 text-sm transition-colors \${isSub ? 'bg-gray-100 text-gray-800 font-medium' : 'text-gray-500 hover:bg-gray-100'}\`}
|
|
2060
|
+
>
|
|
2061
|
+
<span className={isSub ? 'text-gray-600' : 'text-gray-400'}>{TYPE_ICONS[opt.value]}</span>
|
|
2062
|
+
{sub.label}
|
|
2063
|
+
</button>
|
|
2064
|
+
)
|
|
2065
|
+
})}
|
|
2066
|
+
</div>
|
|
2067
|
+
)}
|
|
2068
|
+
</div>
|
|
2069
|
+
)
|
|
2070
|
+
})}
|
|
2071
|
+
</div>
|
|
2072
|
+
)}
|
|
2073
|
+
</div>
|
|
2074
|
+
)
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
// \u2500\u2500 SortableQuestionCard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2078
|
+
|
|
2079
|
+
function SortableQuestionCard({ question, onUpdate, onDuplicate, onDelete, hasError }: {
|
|
2080
|
+
question: QuestionObject
|
|
2081
|
+
onUpdate: (u: Partial<QuestionObject>) => void
|
|
2082
|
+
onDuplicate: () => void
|
|
2083
|
+
onDelete: () => void
|
|
2084
|
+
hasError?: boolean
|
|
2085
|
+
}) {
|
|
2086
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
|
2087
|
+
useSortable({ id: question.id })
|
|
2088
|
+
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }
|
|
2089
|
+
const isSectionHeader = question.type === 'section_header'
|
|
2090
|
+
|
|
2091
|
+
const addOption = () => onUpdate({ options: [...(question.options ?? []), \`Option \${(question.options?.length ?? 0) + 1}\`] })
|
|
2092
|
+
const updateOption = (i: number, v: string) => { const o = [...(question.options ?? [])]; o[i] = v; onUpdate({ options: o }) }
|
|
2093
|
+
const removeOption = (i: number) => onUpdate({ options: (question.options ?? []).filter((_, idx) => idx !== i) })
|
|
2094
|
+
|
|
2095
|
+
return (
|
|
2096
|
+
<div
|
|
2097
|
+
ref={setNodeRef}
|
|
2098
|
+
style={style}
|
|
2099
|
+
className={\`bg-white border rounded-xl shadow-sm transition-shadow \${isDragging ? 'shadow-lg' : 'hover:shadow-md'} \${hasError ? 'border-red-300' : 'border-gray-200'}\`}
|
|
2100
|
+
>
|
|
2101
|
+
{/* Top bar: drag handle + type selector */}
|
|
2102
|
+
<div className="flex items-center gap-2 px-3 py-2 border-b border-gray-100 bg-gray-50/60 rounded-t-xl">
|
|
2103
|
+
<button type="button" {...attributes} {...listeners}
|
|
2104
|
+
className="text-gray-300 hover:text-gray-500 cursor-grab active:cursor-grabbing touch-none p-0.5"
|
|
2105
|
+
title="Drag to reorder"
|
|
2106
|
+
>
|
|
2107
|
+
<GripVertical className="w-4 h-4" />
|
|
2108
|
+
</button>
|
|
2109
|
+
<span className="text-xs font-medium text-gray-400 select-none">
|
|
2110
|
+
{isSectionHeader ? 'Section Header' : 'Question'}
|
|
2111
|
+
</span>
|
|
2112
|
+
<div className="flex-1" />
|
|
2113
|
+
{!isSectionHeader && (
|
|
2114
|
+
<PresetTypeSelector
|
|
2115
|
+
currentType={question.type}
|
|
2116
|
+
currentPreset={question.preset}
|
|
2117
|
+
onSelect={({ type, preset }) => onUpdate({ type, preset })}
|
|
2118
|
+
/>
|
|
2119
|
+
)}
|
|
2120
|
+
</div>
|
|
2121
|
+
|
|
2122
|
+
{/* Label */}
|
|
2123
|
+
<div className="px-4 pt-4 pb-3">
|
|
2124
|
+
{isSectionHeader ? (
|
|
2125
|
+
<input type="text" placeholder="Section title" value={question.label}
|
|
2126
|
+
onChange={e => onUpdate({ label: e.target.value })}
|
|
2127
|
+
className="w-full text-lg font-bold border-0 border-b-2 border-gray-200 focus:border-blue-500 outline-none pb-1.5 bg-transparent placeholder-gray-300"
|
|
2128
|
+
/>
|
|
2129
|
+
) : (
|
|
2130
|
+
<>
|
|
2131
|
+
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-400 mb-1.5">
|
|
2132
|
+
Question Label
|
|
2133
|
+
</label>
|
|
2134
|
+
<input type="text" placeholder="e.g. What is your full name?" value={question.label}
|
|
2135
|
+
onChange={e => onUpdate({ label: e.target.value })}
|
|
2136
|
+
className={\`w-full text-sm font-medium rounded-lg px-3 py-2.5 outline-none transition-all border \${
|
|
2137
|
+
hasError && !question.label.trim()
|
|
2138
|
+
? 'bg-red-50 border-red-300 placeholder-red-300 focus:ring-2 focus:ring-red-100'
|
|
2139
|
+
: 'bg-gray-50 border-gray-200 placeholder-gray-300 focus:bg-white focus:border-blue-500 focus:ring-2 focus:ring-blue-500/10'
|
|
2140
|
+
}\`}
|
|
2141
|
+
/>
|
|
2142
|
+
{hasError && !question.label.trim() && (
|
|
2143
|
+
<p className="text-xs text-red-500 mt-1.5 px-0.5">Question label is required before publishing.</p>
|
|
2144
|
+
)}
|
|
2145
|
+
{question.preset && (
|
|
2146
|
+
<div className="flex items-center gap-1.5 mt-2 px-2.5 py-1.5 rounded-lg" style={{ background: 'var(--primary-soft, rgba(0,0,128,0.06))', border: '1px solid var(--primary-border, rgba(0,0,128,0.12))' }}>
|
|
2147
|
+
<ShieldCheck className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--primary, #000080)' }} />
|
|
2148
|
+
<span className="text-[11px] font-medium" style={{ color: 'var(--primary, #000080)' }}>Validation:</span>
|
|
2149
|
+
<span className="text-[11px]" style={{ color: 'var(--primary, #000080)', opacity: 0.7 }}>{PRESET_VALIDATION_INFO[question.preset]}</span>
|
|
2150
|
+
</div>
|
|
2151
|
+
)}
|
|
2152
|
+
</>
|
|
2153
|
+
)}
|
|
2154
|
+
</div>
|
|
2155
|
+
|
|
2156
|
+
{/* Body */}
|
|
2157
|
+
{!isSectionHeader && (
|
|
2158
|
+
<div className="px-4 pb-3 space-y-3">
|
|
2159
|
+
<div>
|
|
2160
|
+
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-400 mb-1.5">
|
|
2161
|
+
Description <span className="normal-case font-normal text-gray-300">(optional)</span>
|
|
2162
|
+
</label>
|
|
2163
|
+
<Textarea
|
|
2164
|
+
value={question.description ?? ''}
|
|
2165
|
+
onChange={e => onUpdate({ description: e.target.value || undefined })}
|
|
2166
|
+
placeholder="Add helper text visible to the applicant..."
|
|
2167
|
+
rows={2}
|
|
2168
|
+
/>
|
|
2169
|
+
</div>
|
|
2170
|
+
|
|
2171
|
+
{/* Answer preview */}
|
|
2172
|
+
<div className="pt-1">
|
|
2173
|
+
<div className="flex items-center gap-2 mb-2.5">
|
|
2174
|
+
<span className="text-[11px] font-semibold uppercase tracking-wider text-gray-400">Answer preview</span>
|
|
2175
|
+
<div className="flex-1 h-px bg-gray-100" />
|
|
2176
|
+
</div>
|
|
2177
|
+
|
|
2178
|
+
{question.type === 'short_answer' && (
|
|
2179
|
+
<div className="flex items-center gap-2.5 px-3.5 py-2.5 border border-gray-300 rounded-lg bg-white shadow-sm">
|
|
2180
|
+
<AlignLeft className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
|
2181
|
+
<span className="text-sm text-gray-400 italic">Short answer text</span>
|
|
2182
|
+
</div>
|
|
2183
|
+
)}
|
|
2184
|
+
|
|
2185
|
+
{question.type === 'paragraph' && (
|
|
2186
|
+
<div className="px-3.5 py-3 border border-gray-300 rounded-lg bg-white shadow-sm space-y-2 min-h-[70px]">
|
|
2187
|
+
{['w-full', 'w-5/6', 'w-3/4', 'w-2/5'].map((w, i) => (
|
|
2188
|
+
<div key={i} className={\`h-2 bg-gray-200 rounded-full \${w}\`} />
|
|
2189
|
+
))}
|
|
2190
|
+
</div>
|
|
2191
|
+
)}
|
|
2192
|
+
|
|
2193
|
+
{question.type === 'date' && (
|
|
2194
|
+
<div className="inline-flex items-center gap-2.5 px-3.5 py-2.5 border border-gray-300 rounded-lg bg-white shadow-sm text-sm text-gray-500">
|
|
2195
|
+
<Calendar className="w-4 h-4 text-gray-400" />
|
|
2196
|
+
<span>DD / MM / YYYY</span>
|
|
2197
|
+
</div>
|
|
2198
|
+
)}
|
|
2199
|
+
|
|
2200
|
+
{question.type === 'file_upload' && (
|
|
2201
|
+
<div className="py-5 px-4 border-2 border-dashed border-gray-300 rounded-lg bg-gray-50 text-center space-y-1.5">
|
|
2202
|
+
<Paperclip className="w-5 h-5 text-gray-400 mx-auto" />
|
|
2203
|
+
<p className="text-sm font-medium text-gray-500">Click to upload or drag & drop</p>
|
|
2204
|
+
<p className="text-xs text-gray-400">Any file type accepted</p>
|
|
2205
|
+
</div>
|
|
2206
|
+
)}
|
|
2207
|
+
|
|
2208
|
+
{(question.type === 'multiple_choice' || question.type === 'checkboxes') && (
|
|
2209
|
+
<div className="space-y-1.5">
|
|
2210
|
+
{(question.preset === 'gender' ? GENDER_OPTIONS : question.options ?? []).map((opt, i) => (
|
|
2211
|
+
<div key={i} className="flex items-center gap-2.5 bg-white border border-gray-200 rounded-lg px-3 py-2 hover:border-gray-300">
|
|
2212
|
+
{question.type === 'multiple_choice'
|
|
2213
|
+
? <span className="w-4 h-4 rounded-full border-2 border-gray-300 flex-shrink-0" />
|
|
2214
|
+
: <span className="w-4 h-4 rounded border-2 border-gray-300 flex-shrink-0" />
|
|
2215
|
+
}
|
|
2216
|
+
<input type="text" value={opt} placeholder={\`Option \${i + 1}\`}
|
|
2217
|
+
onChange={e => updateOption(i, e.target.value)}
|
|
2218
|
+
disabled={question.preset === 'gender'}
|
|
2219
|
+
className="flex-1 text-sm outline-none bg-transparent text-gray-700 placeholder-gray-300"
|
|
2220
|
+
/>
|
|
2221
|
+
{question.preset !== 'gender' && (
|
|
2222
|
+
<button type="button" onClick={() => removeOption(i)}
|
|
2223
|
+
disabled={(question.options?.length ?? 0) <= 1}
|
|
2224
|
+
className="w-6 h-6 flex items-center justify-center rounded-md text-gray-300 hover:text-red-400 hover:bg-red-50 disabled:opacity-0 transition-all flex-shrink-0"
|
|
2225
|
+
>
|
|
2226
|
+
<X className="w-3.5 h-3.5" />
|
|
2227
|
+
</button>
|
|
2228
|
+
)}
|
|
2229
|
+
</div>
|
|
2230
|
+
))}
|
|
2231
|
+
{question.preset === 'gender'
|
|
2232
|
+
? <p className="text-xs text-gray-400 px-1">Gender options are auto-populated.</p>
|
|
2233
|
+
: <button type="button" onClick={addOption}
|
|
2234
|
+
className="w-full flex items-center justify-center gap-1.5 py-2 border border-dashed border-gray-200 rounded-lg text-xs font-medium text-gray-400 hover:border-gray-400 hover:text-gray-600 hover:bg-gray-50 transition-all"
|
|
2235
|
+
><Plus className="w-3.5 h-3.5" /> Add option</button>
|
|
2236
|
+
}
|
|
2237
|
+
</div>
|
|
2238
|
+
)}
|
|
2239
|
+
|
|
2240
|
+
{question.type === 'dropdown' && (
|
|
2241
|
+
question.preset === 'state'
|
|
2242
|
+
? <div className="flex items-center gap-2 px-3 py-2.5 border border-gray-200 rounded-lg bg-gray-50 text-sm text-gray-500">
|
|
2243
|
+
<MapPin className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
|
2244
|
+
<span>States list ({INDIAN_STATES.length} options) \u2014 auto-populated</span>
|
|
2245
|
+
</div>
|
|
2246
|
+
: <div className="space-y-1.5">
|
|
2247
|
+
{(question.options ?? []).map((opt, i) => (
|
|
2248
|
+
<div key={i} className="flex items-center gap-2.5 bg-white border border-gray-200 rounded-lg px-3 py-2 hover:border-gray-300">
|
|
2249
|
+
<span className="w-5 h-5 flex items-center justify-center rounded bg-gray-100 text-[11px] font-semibold text-gray-400 flex-shrink-0">{i + 1}</span>
|
|
2250
|
+
<input type="text" value={opt} placeholder={\`Option \${i + 1}\`}
|
|
2251
|
+
onChange={e => updateOption(i, e.target.value)}
|
|
2252
|
+
className="flex-1 text-sm outline-none bg-transparent text-gray-700 placeholder-gray-300"
|
|
2253
|
+
/>
|
|
2254
|
+
<button type="button" onClick={() => removeOption(i)}
|
|
2255
|
+
disabled={(question.options?.length ?? 0) <= 1}
|
|
2256
|
+
className="w-6 h-6 flex items-center justify-center rounded-md text-gray-300 hover:text-red-400 hover:bg-red-50 disabled:opacity-0 transition-all flex-shrink-0"
|
|
2257
|
+
><X className="w-3.5 h-3.5" /></button>
|
|
2258
|
+
</div>
|
|
2259
|
+
))}
|
|
2260
|
+
<button type="button" onClick={addOption}
|
|
2261
|
+
className="w-full flex items-center justify-center gap-1.5 py-2 border border-dashed border-gray-200 rounded-lg text-xs font-medium text-gray-400 hover:border-gray-400 hover:text-gray-600 hover:bg-gray-50 transition-all"
|
|
2262
|
+
><Plus className="w-3.5 h-3.5" /> Add option</button>
|
|
2263
|
+
</div>
|
|
2264
|
+
)}
|
|
2265
|
+
</div>
|
|
2266
|
+
</div>
|
|
2267
|
+
)}
|
|
2268
|
+
|
|
2269
|
+
{/* Footer */}
|
|
2270
|
+
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-gray-100">
|
|
2271
|
+
{!isSectionHeader && (
|
|
2272
|
+
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer mr-2 select-none">
|
|
2273
|
+
<input type="checkbox" className="w-3.5 h-3.5"
|
|
2274
|
+
checked={question.required}
|
|
2275
|
+
onChange={e => onUpdate({ required: e.target.checked })}
|
|
2276
|
+
/>
|
|
2277
|
+
Required
|
|
2278
|
+
</label>
|
|
2279
|
+
)}
|
|
2280
|
+
<button type="button" onClick={onDuplicate}
|
|
2281
|
+
className="p-1.5 text-gray-400 hover:text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
|
|
2282
|
+
title="Duplicate"
|
|
2283
|
+
><Copy className="w-4 h-4" /></button>
|
|
2284
|
+
<button type="button" onClick={onDelete}
|
|
2285
|
+
className="p-1.5 text-gray-400 hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"
|
|
2286
|
+
title="Delete"
|
|
2287
|
+
><Trash2 className="w-4 h-4" /></button>
|
|
2288
|
+
</div>
|
|
2289
|
+
</div>
|
|
2290
|
+
)
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
// \u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2294
|
+
|
|
2295
|
+
function createQuestion(order: number): QuestionObject {
|
|
2296
|
+
return { id: crypto.randomUUID(), type: 'short_answer', label: '', required: false, order }
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// \u2500\u2500 FormBuilderPage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2300
|
+
|
|
2301
|
+
export default function FormBuilderPage() {
|
|
2302
|
+
const [activeTab, setActiveTab] = useState<'questions' | 'settings'>('questions')
|
|
2303
|
+
const [title, setTitle] = useState('')
|
|
2304
|
+
const [description, setDescription] = useState('')
|
|
2305
|
+
const [targetAudience, setTarget] = useState('all')
|
|
2306
|
+
const [paymentRequired, setPayment] = useState(false)
|
|
2307
|
+
const [price, setPrice] = useState('')
|
|
2308
|
+
const [questions, setQuestions] = useState<QuestionObject[]>([createQuestion(0)])
|
|
2309
|
+
const [questionErrors, setQErrors] = useState<Set<string>>(new Set())
|
|
2310
|
+
const [formErrors, setFormErrors] = useState<{ title?: string; price?: string; questions?: string }>({})
|
|
2311
|
+
const [saved, setSaved] = useState(false)
|
|
2312
|
+
|
|
2313
|
+
const sensors = useSensors(
|
|
2314
|
+
useSensor(PointerSensor),
|
|
2315
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
2316
|
+
)
|
|
2317
|
+
|
|
2318
|
+
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
|
2319
|
+
if (!over || active.id === over.id) return
|
|
2320
|
+
const from = questions.findIndex(q => q.id === active.id)
|
|
2321
|
+
const to = questions.findIndex(q => q.id === over.id)
|
|
2322
|
+
setQuestions(arrayMove(questions, from, to).map((q, i) => ({ ...q, order: i })))
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
const addQuestion = () => setQuestions(p => [...p, createQuestion(p.length)])
|
|
2326
|
+
|
|
2327
|
+
const updateQuestion = (id: string, updates: Partial<QuestionObject>) => {
|
|
2328
|
+
if (updates.label?.trim()) {
|
|
2329
|
+
setQErrors(p => { const n = new Set(p); n.delete(id); return n })
|
|
2330
|
+
if (formErrors.questions) setFormErrors(p => ({ ...p, questions: undefined }))
|
|
2331
|
+
}
|
|
2332
|
+
setQuestions(p => p.map(q => {
|
|
2333
|
+
if (q.id !== id) return q
|
|
2334
|
+
const u = { ...q, ...updates }
|
|
2335
|
+
if (updates.type && updates.type !== q.type && !('preset' in updates)) u.preset = undefined
|
|
2336
|
+
if (!['multiple_choice', 'checkboxes', 'dropdown'].includes(u.type)) u.options = undefined
|
|
2337
|
+
if (['multiple_choice', 'checkboxes', 'dropdown'].includes(u.type) && !u.options?.length && u.preset !== 'state') u.options = ['Option 1']
|
|
2338
|
+
if ('preset' in updates) {
|
|
2339
|
+
if (updates.preset === 'state') u.options = undefined
|
|
2340
|
+
if (updates.preset === 'gender') u.options = GENDER_OPTIONS
|
|
2341
|
+
if (!updates.preset && (q.preset === 'state' || q.preset === 'gender') && ['dropdown', 'multiple_choice'].includes(u.type)) u.options = ['Option 1']
|
|
2342
|
+
const isDefault = !q.label.trim() || Object.values(PRESET_DEFAULT_LABELS).includes(q.label.trim())
|
|
2343
|
+
if (updates.preset && isDefault) u.label = PRESET_DEFAULT_LABELS[updates.preset] ?? u.label
|
|
2344
|
+
else if (!updates.preset && isDefault) u.label = ''
|
|
2345
|
+
}
|
|
2346
|
+
return u
|
|
2347
|
+
}))
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
const duplicateQuestion = (id: string) => {
|
|
2351
|
+
const idx = questions.findIndex(q => q.id === id)
|
|
2352
|
+
const clone = { ...questions[idx], id: crypto.randomUUID(), order: idx + 1 }
|
|
2353
|
+
setQuestions([...questions.slice(0, idx + 1), clone, ...questions.slice(idx + 1)].map((q, i) => ({ ...q, order: i })))
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
const deleteQuestion = (id: string) =>
|
|
2357
|
+
setQuestions(p => p.filter(q => q.id !== id).map((q, i) => ({ ...q, order: i })))
|
|
2358
|
+
|
|
2359
|
+
const validate = (forPublish: boolean) => {
|
|
2360
|
+
const e: typeof formErrors = {}
|
|
2361
|
+
if (!title.trim()) e.title = 'Please enter a form title.'
|
|
2362
|
+
if (paymentRequired && (!price || Number(price) <= 0)) e.price = 'Please enter a valid fee greater than 0.'
|
|
2363
|
+
const emptyIds = forPublish
|
|
2364
|
+
? questions.filter(q => q.type !== 'section_header' && !q.label.trim()).map(q => q.id)
|
|
2365
|
+
: []
|
|
2366
|
+
if (emptyIds.length) e.questions = 'All questions must have a label before publishing.'
|
|
2367
|
+
return { errors: e, emptyIds }
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
const handleSave = () => {
|
|
2371
|
+
const { errors } = validate(false)
|
|
2372
|
+
if (Object.keys(errors).length) { setFormErrors(errors); return }
|
|
2373
|
+
setFormErrors({})
|
|
2374
|
+
setSaved(true)
|
|
2375
|
+
setTimeout(() => setSaved(false), 3500)
|
|
2376
|
+
// Replace with real API call:
|
|
2377
|
+
console.log('Draft payload:', { title, description, target_audience: targetAudience, payment_required: paymentRequired, price: paymentRequired ? Number(price) : undefined, questions })
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
const handlePublish = () => {
|
|
2381
|
+
const { errors, emptyIds } = validate(true)
|
|
2382
|
+
if (Object.keys(errors).length || emptyIds.length) {
|
|
2383
|
+
setFormErrors(errors); setQErrors(new Set(emptyIds))
|
|
2384
|
+
if (emptyIds.length && activeTab !== 'questions') setActiveTab('questions')
|
|
2385
|
+
return
|
|
2386
|
+
}
|
|
2387
|
+
setFormErrors({}); setQErrors(new Set())
|
|
2388
|
+
setSaved(true)
|
|
2389
|
+
setTimeout(() => setSaved(false), 3500)
|
|
2390
|
+
// Replace with real API call:
|
|
2391
|
+
console.log('Publish payload:', { title, description, target_audience: targetAudience, payment_required: paymentRequired, price: paymentRequired ? Number(price) : undefined, questions })
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
return (
|
|
2395
|
+
<div className="space-y-5 max-w-3xl mx-auto p-4 sm:p-6 pb-24">
|
|
2396
|
+
{/* Header */}
|
|
2397
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
2398
|
+
<div className="w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0"
|
|
2399
|
+
style={{ background: 'var(--primary-soft, rgba(0,0,128,0.07))' }}>
|
|
2400
|
+
<BookOpen className="w-4.5 h-4.5" style={{ color: 'var(--primary, #000080)' }} />
|
|
2401
|
+
</div>
|
|
2402
|
+
<div>
|
|
2403
|
+
<h1 className="text-xl font-bold text-label-primary leading-tight">Form Builder</h1>
|
|
2404
|
+
<p className="text-xs text-label-tertiary">Build dynamic forms with drag & drop questions</p>
|
|
2405
|
+
</div>
|
|
2406
|
+
</div>
|
|
2407
|
+
|
|
2408
|
+
{saved && (
|
|
2409
|
+
<div className="px-4 py-3 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700 font-medium">
|
|
2410
|
+
\u2713 Form saved \u2014 check the browser console for the payload. Wire up your API in handleSave / handlePublish.
|
|
2411
|
+
</div>
|
|
2412
|
+
)}
|
|
2413
|
+
|
|
2414
|
+
{/* Form title + description card */}
|
|
2415
|
+
<Card className="overflow-hidden">
|
|
2416
|
+
<div className="h-1" style={{ background: 'var(--primary, #000080)' }} />
|
|
2417
|
+
<CardContent className="pt-5 space-y-4">
|
|
2418
|
+
<div>
|
|
2419
|
+
<input type="text" placeholder="Form Title" value={title}
|
|
2420
|
+
onChange={e => { setTitle(e.target.value); if (formErrors.title) setFormErrors(p => ({ ...p, title: undefined })) }}
|
|
2421
|
+
className={\`w-full text-2xl font-bold border-0 border-b-2 focus:outline-none pb-2 bg-transparent placeholder-gray-300 \${formErrors.title ? 'border-red-400' : 'border-gray-200'}\`}
|
|
2422
|
+
/>
|
|
2423
|
+
{formErrors.title && <p className="text-red-500 text-sm mt-1">{formErrors.title}</p>}
|
|
2424
|
+
</div>
|
|
2425
|
+
<Textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="Form description (optional)" rows={2} />
|
|
2426
|
+
</CardContent>
|
|
2427
|
+
</Card>
|
|
2428
|
+
|
|
2429
|
+
{/* Tab bar */}
|
|
2430
|
+
<div className="flex border-b border-gray-200">
|
|
2431
|
+
{([
|
|
2432
|
+
{ id: 'questions' as const, Icon: ListChecks, label: 'Questions' },
|
|
2433
|
+
{ id: 'settings' as const, Icon: Settings, label: 'Settings' },
|
|
2434
|
+
]).map(({ id, Icon, label }) => (
|
|
2435
|
+
<button key={id} type="button" onClick={() => setActiveTab(id)}
|
|
2436
|
+
className={\`flex items-center gap-2 px-5 py-2.5 text-sm font-medium border-b-2 transition-colors \${activeTab === id ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}\`}
|
|
2437
|
+
style={activeTab === id ? { borderColor: 'var(--primary, #000080)', color: 'var(--primary, #000080)' } : undefined}
|
|
2438
|
+
>
|
|
2439
|
+
<Icon className="w-4 h-4" />{label}
|
|
2440
|
+
</button>
|
|
2441
|
+
))}
|
|
2442
|
+
</div>
|
|
2443
|
+
|
|
2444
|
+
{/* Questions tab */}
|
|
2445
|
+
{activeTab === 'questions' && (
|
|
2446
|
+
<div className="space-y-5">
|
|
2447
|
+
{formErrors.questions && <p className="text-red-500 text-sm px-1">{formErrors.questions}</p>}
|
|
2448
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
2449
|
+
<SortableContext items={questions.map(q => q.id)} strategy={verticalListSortingStrategy}>
|
|
2450
|
+
{questions.map(q => (
|
|
2451
|
+
<SortableQuestionCard
|
|
2452
|
+
key={q.id}
|
|
2453
|
+
question={q}
|
|
2454
|
+
onUpdate={u => updateQuestion(q.id, u)}
|
|
2455
|
+
onDuplicate={() => duplicateQuestion(q.id)}
|
|
2456
|
+
onDelete={() => deleteQuestion(q.id)}
|
|
2457
|
+
hasError={questionErrors.has(q.id)}
|
|
2458
|
+
/>
|
|
2459
|
+
))}
|
|
2460
|
+
</SortableContext>
|
|
2461
|
+
</DndContext>
|
|
2462
|
+
|
|
2463
|
+
<button type="button" onClick={addQuestion}
|
|
2464
|
+
className="w-full py-3 border-2 border-dashed border-gray-200 rounded-xl text-gray-500 hover:text-blue-600 transition-colors flex items-center justify-center gap-2 text-sm font-medium"
|
|
2465
|
+
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--primary, #000080)')}
|
|
2466
|
+
onMouseLeave={e => (e.currentTarget.style.borderColor = '')}
|
|
2467
|
+
>
|
|
2468
|
+
<Plus className="w-4 h-4" /> Add Question
|
|
2469
|
+
</button>
|
|
2470
|
+
</div>
|
|
2471
|
+
)}
|
|
2472
|
+
|
|
2473
|
+
{/* Settings tab */}
|
|
2474
|
+
{activeTab === 'settings' && (
|
|
2475
|
+
<Card>
|
|
2476
|
+
<CardContent className="pt-5 space-y-5">
|
|
2477
|
+
<Select
|
|
2478
|
+
label="Target Audience"
|
|
2479
|
+
value={targetAudience}
|
|
2480
|
+
onChange={setTarget}
|
|
2481
|
+
options={[
|
|
2482
|
+
{ value: 'all', label: 'All Users' },
|
|
2483
|
+
{ value: 'admin', label: 'Admins Only' },
|
|
2484
|
+
{ value: 'member', label: 'Members Only' },
|
|
2485
|
+
]}
|
|
2486
|
+
/>
|
|
2487
|
+
|
|
2488
|
+
<div className="flex items-center justify-between p-3.5 bg-gray-50 rounded-xl">
|
|
2489
|
+
<div>
|
|
2490
|
+
<p className="text-sm font-medium text-gray-700">Payment Required</p>
|
|
2491
|
+
<p className="text-xs text-gray-400">Users must pay to submit this form</p>
|
|
2492
|
+
</div>
|
|
2493
|
+
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
|
|
2494
|
+
<input type="checkbox" className="sr-only peer" checked={paymentRequired} onChange={e => setPayment(e.target.checked)} />
|
|
2495
|
+
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-blue-600 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full" />
|
|
2496
|
+
</label>
|
|
2497
|
+
</div>
|
|
2498
|
+
|
|
2499
|
+
{paymentRequired && (
|
|
2500
|
+
<Input
|
|
2501
|
+
label="Fee Amount"
|
|
2502
|
+
type="number"
|
|
2503
|
+
min="0"
|
|
2504
|
+
placeholder="e.g. 500"
|
|
2505
|
+
value={price}
|
|
2506
|
+
onChange={e => { setPrice(e.target.value); if (formErrors.price) setFormErrors(p => ({ ...p, price: undefined })) }}
|
|
2507
|
+
error={formErrors.price}
|
|
2508
|
+
/>
|
|
2509
|
+
)}
|
|
2510
|
+
</CardContent>
|
|
2511
|
+
</Card>
|
|
2512
|
+
)}
|
|
2513
|
+
|
|
2514
|
+
{/* Sticky action footer */}
|
|
2515
|
+
<div className="sticky bottom-0 z-10 bg-white border-t border-gray-200 py-3 flex justify-end gap-3">
|
|
2516
|
+
<Button variant="outline" onClick={handleSave} className="flex items-center gap-2">
|
|
2517
|
+
<Save className="w-4 h-4" /> Save as Draft
|
|
2518
|
+
</Button>
|
|
2519
|
+
<Button variant="primary" onClick={handlePublish} className="flex items-center gap-2">
|
|
2520
|
+
<Send className="w-4 h-4" /> Publish Now
|
|
2521
|
+
</Button>
|
|
2522
|
+
</div>
|
|
2523
|
+
</div>
|
|
2524
|
+
)
|
|
2525
|
+
}
|
|
2526
|
+
`;
|
|
2527
|
+
}
|
|
1834
2528
|
function genOnboardingPage() {
|
|
1835
2529
|
return `'use client'
|
|
1836
2530
|
|
|
@@ -2355,6 +3049,7 @@ function scaffold(opts) {
|
|
|
2355
3049
|
w(f("src/app/dashboard/users/page.tsx"), genUsersPage(opts));
|
|
2356
3050
|
w(f("src/app/dashboard/settings/page.tsx"), genSettingsPage(opts));
|
|
2357
3051
|
w(f("src/app/dashboard/components/page.tsx"), genComponentsShowcasePage());
|
|
3052
|
+
w(f("src/app/dashboard/form-builder/page.tsx"), genFormBuilderPage());
|
|
2358
3053
|
w(f("src/app/dashboard/onboarding/page.tsx"), genOnboardingPage());
|
|
2359
3054
|
w(f("src/app/api/auth/login/route.ts"), genLoginRoute(opts));
|
|
2360
3055
|
w(f("src/app/api/auth/user/route.ts"), genUserRoute(opts));
|
package/package.json
CHANGED