@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.
Files changed (2) hide show
  1. package/dist/cli/index.js +700 -5
  2. 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', href: '/dashboard', icon: <LayoutDashboard className="h-4 w-4" /> },
435
- { label: 'Users', href: '/dashboard/users', icon: <Users className="h-4 w-4" /> },
436
- { label: 'Components', href: '/dashboard/components', icon: <Layers className="h-4 w-4" /> },
437
- { label: 'Onboarding', href: '/dashboard/onboarding', icon: <ClipboardList className="h-4 w-4" /> },
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 &amp; 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 &amp; 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucifer91299/create-portal-app",
3
- "version": "1.1.21",
3
+ "version": "1.1.22",
4
4
  "description": "Scaffold a Next.js authenticated portal with full design system in one command",
5
5
  "license": "MIT",
6
6
  "author": "Aakash Kanojiya",