deepspace 0.3.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/cli.js +331 -115
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +199 -16
  4. package/dist/index.js +354 -166
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +242 -73
  7. package/dist/server.js +985 -374
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +322 -73
  10. package/dist/worker.js +961 -343
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Landing Page Primitives
3
+ *
4
+ * Shared animation and UI primitives used by the landing page shell
5
+ * and all landing section components.
6
+ */
7
+
8
+ import { useEffect, useState, useRef, type ReactNode } from 'react'
9
+ import { motion, AnimatePresence, useInView } from 'framer-motion'
10
+ import { ImageIcon, ChevronDown } from 'lucide-react'
11
+ import { cn } from 'deepspace'
12
+
13
+ // ============================================================================
14
+ // Smooth Typewriter
15
+ // ============================================================================
16
+
17
+ function useTypewriter(
18
+ text: string,
19
+ options: { baseSpeed?: number; variance?: number; startDelay?: number } = {},
20
+ ) {
21
+ const { baseSpeed = 65, variance = 0.4, startDelay = 300 } = options
22
+ const [displayedCount, setDisplayedCount] = useState(0)
23
+ const [isComplete, setIsComplete] = useState(false)
24
+ const [hasStarted, setHasStarted] = useState(false)
25
+
26
+ useEffect(() => {
27
+ setDisplayedCount(0)
28
+ setIsComplete(false)
29
+ setHasStarted(false)
30
+ const startTimer = setTimeout(() => setHasStarted(true), startDelay)
31
+ return () => clearTimeout(startTimer)
32
+ }, [text, startDelay])
33
+
34
+ useEffect(() => {
35
+ if (!hasStarted || displayedCount >= text.length) {
36
+ if (hasStarted && displayedCount >= text.length) setIsComplete(true)
37
+ return
38
+ }
39
+ const char = text[displayedCount]
40
+ const nextChar = text[displayedCount + 1]
41
+ let delay = baseSpeed
42
+ if (char === ' ') delay = baseSpeed * 0.3
43
+ else if ('.!?,;:'.includes(char)) delay = baseSpeed * 2.5
44
+ else if (nextChar === ' ' || displayedCount === text.length - 1) delay = baseSpeed * 1.3
45
+ const varianceFactor = 1 + (Math.random() - 0.5) * 2 * variance
46
+ delay *= varianceFactor
47
+ const timer = setTimeout(() => setDisplayedCount(prev => prev + 1), delay)
48
+ return () => clearTimeout(timer)
49
+ }, [hasStarted, displayedCount, text, baseSpeed, variance])
50
+
51
+ return { displayedText: text.slice(0, displayedCount), isComplete }
52
+ }
53
+
54
+ export function Typewriter({
55
+ text,
56
+ className,
57
+ cursorClassName,
58
+ baseSpeed,
59
+ variance,
60
+ startDelay,
61
+ onComplete,
62
+ }: {
63
+ text: string
64
+ className?: string
65
+ cursorClassName?: string
66
+ baseSpeed?: number
67
+ variance?: number
68
+ startDelay?: number
69
+ onComplete?: () => void
70
+ }) {
71
+ const { displayedText, isComplete } = useTypewriter(text, { baseSpeed, variance, startDelay })
72
+
73
+ useEffect(() => {
74
+ if (isComplete && onComplete) onComplete()
75
+ }, [isComplete, onComplete])
76
+
77
+ return (
78
+ <span className={className} aria-label={text}>
79
+ <span>{displayedText}</span>
80
+ <motion.span
81
+ className={cn(
82
+ 'inline-block w-[3px] h-[0.9em] ml-[2px] align-middle rounded-full',
83
+ cursorClassName ?? 'bg-foreground',
84
+ )}
85
+ animate={{ opacity: isComplete ? [1, 0] : [1, 1] }}
86
+ transition={
87
+ isComplete
88
+ ? { duration: 0.8, repeat: Infinity, repeatType: 'reverse', ease: 'easeInOut' }
89
+ : { duration: 0 }
90
+ }
91
+ />
92
+ </span>
93
+ )
94
+ }
95
+
96
+ // ============================================================================
97
+ // Scroll-triggered animation primitives
98
+ // ============================================================================
99
+
100
+ export function ScrollReveal({
101
+ children,
102
+ className,
103
+ delay = 0,
104
+ direction = 'up',
105
+ }: {
106
+ children: ReactNode
107
+ className?: string
108
+ delay?: number
109
+ direction?: 'up' | 'down' | 'left' | 'right'
110
+ }) {
111
+ const ref = useRef<HTMLDivElement>(null)
112
+ const isInView = useInView(ref, { once: true, margin: '-80px 0px' })
113
+ const directionMap = {
114
+ up: { y: 40, x: 0 },
115
+ down: { y: -40, x: 0 },
116
+ left: { x: 40, y: 0 },
117
+ right: { x: -40, y: 0 },
118
+ }
119
+ const offset = directionMap[direction]
120
+
121
+ return (
122
+ <motion.div
123
+ ref={ref}
124
+ className={className}
125
+ initial={{ opacity: 0, x: offset.x, y: offset.y }}
126
+ animate={isInView ? { opacity: 1, x: 0, y: 0 } : { opacity: 0, x: offset.x, y: offset.y }}
127
+ transition={{ duration: 0.7, delay, ease: [0.25, 0.4, 0.25, 1] }}
128
+ >
129
+ {children}
130
+ </motion.div>
131
+ )
132
+ }
133
+
134
+ export function StaggerContainer({
135
+ children,
136
+ className,
137
+ staggerDelay = 0.1,
138
+ }: {
139
+ children: ReactNode
140
+ className?: string
141
+ staggerDelay?: number
142
+ }) {
143
+ const ref = useRef<HTMLDivElement>(null)
144
+ const isInView = useInView(ref, { once: true, margin: '-60px 0px' })
145
+
146
+ return (
147
+ <motion.div
148
+ ref={ref}
149
+ className={className}
150
+ initial="hidden"
151
+ animate={isInView ? 'visible' : 'hidden'}
152
+ variants={{
153
+ hidden: {},
154
+ visible: { transition: { staggerChildren: staggerDelay } },
155
+ }}
156
+ >
157
+ {children}
158
+ </motion.div>
159
+ )
160
+ }
161
+
162
+ export const staggerChild = {
163
+ hidden: { opacity: 0, y: 30, scale: 0.97 },
164
+ visible: {
165
+ opacity: 1,
166
+ y: 0,
167
+ scale: 1,
168
+ transition: { duration: 0.5, ease: 'easeOut' as const },
169
+ },
170
+ }
171
+
172
+ // ============================================================================
173
+ // Animated counter
174
+ // ============================================================================
175
+
176
+ function parseStatValue(value: string): { prefix: string; number: number; suffix: string } {
177
+ const match = value.match(/^([<>]?)(\d+(?:\.\d+)?)(.*)$/)
178
+ if (!match) return { prefix: '', number: 0, suffix: value }
179
+ return { prefix: match[1], number: parseFloat(match[2]), suffix: match[3] }
180
+ }
181
+
182
+ function useCountUp(target: number, duration: number = 1800, shouldStart: boolean = false): number {
183
+ const [current, setCurrent] = useState(0)
184
+ const startTimeRef = useRef<number | null>(null)
185
+ const rafRef = useRef<number>(0)
186
+
187
+ useEffect(() => {
188
+ if (!shouldStart) return
189
+ startTimeRef.current = null
190
+
191
+ function tick(timestamp: number) {
192
+ if (startTimeRef.current === null) startTimeRef.current = timestamp
193
+ const elapsed = timestamp - startTimeRef.current
194
+ const progress = Math.min(elapsed / duration, 1)
195
+ const eased = 1 - Math.pow(1 - progress, 3)
196
+ setCurrent(eased * target)
197
+ if (progress < 1) rafRef.current = requestAnimationFrame(tick)
198
+ }
199
+
200
+ rafRef.current = requestAnimationFrame(tick)
201
+ return () => cancelAnimationFrame(rafRef.current)
202
+ }, [target, duration, shouldStart])
203
+
204
+ return current
205
+ }
206
+
207
+ export function AnimatedStat({ value, label }: { value: string; label: string }) {
208
+ const ref = useRef<HTMLDivElement>(null)
209
+ const isInView = useInView(ref, { once: true, margin: '-40px 0px' })
210
+ const { prefix, number, suffix } = parseStatValue(value)
211
+ const animated = useCountUp(number, 1800, isInView)
212
+
213
+ const hasDecimal = number % 1 !== 0
214
+ const displayed = hasDecimal ? animated.toFixed(1) : Math.round(animated).toString()
215
+
216
+ return (
217
+ <motion.div ref={ref} variants={staggerChild} className="text-center">
218
+ <div className="text-3xl md:text-4xl font-bold text-foreground tracking-tight">
219
+ {prefix}{displayed}{suffix}
220
+ </div>
221
+ <div className="text-muted-foreground/60 text-sm font-medium mt-1">{label}</div>
222
+ </motion.div>
223
+ )
224
+ }
225
+
226
+ // ============================================================================
227
+ // Glassmorphic Card
228
+ // ============================================================================
229
+
230
+ export function GlassCard({
231
+ children,
232
+ className,
233
+ hoverEffect = true,
234
+ }: {
235
+ children: ReactNode
236
+ className?: string
237
+ hoverEffect?: boolean
238
+ }) {
239
+ return (
240
+ <motion.div
241
+ className={cn(
242
+ 'relative rounded-2xl overflow-hidden',
243
+ 'bg-background/70',
244
+ 'border border-foreground/[0.08]',
245
+ 'shadow-[0_8px_32px_rgba(0,0,0,0.3)]',
246
+ hoverEffect && 'group transition-all duration-500',
247
+ className,
248
+ )}
249
+ whileHover={hoverEffect ? { y: -4, transition: { duration: 0.3, ease: 'easeOut' } } : undefined}
250
+ >
251
+ <div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-foreground/20 to-transparent" />
252
+ {children}
253
+ </motion.div>
254
+ )
255
+ }
256
+
257
+ // ============================================================================
258
+ // Placeholder Image
259
+ // ============================================================================
260
+
261
+ export function PlaceholderImage({
262
+ className,
263
+ label,
264
+ src,
265
+ aspectRatio = 'aspect-video',
266
+ }: {
267
+ className?: string
268
+ label?: string
269
+ src?: string
270
+ aspectRatio?: string
271
+ }) {
272
+ if (src) {
273
+ return (
274
+ <div className={cn('relative rounded-xl overflow-hidden', aspectRatio, className)}>
275
+ <img src={src} alt={label ?? ''} className="absolute inset-0 w-full h-full object-cover" />
276
+ </div>
277
+ )
278
+ }
279
+
280
+ return (
281
+ <div
282
+ className={cn(
283
+ 'relative rounded-xl overflow-hidden',
284
+ 'bg-foreground/[0.03] border-2 border-dashed border-foreground/[0.12]',
285
+ aspectRatio,
286
+ className,
287
+ )}
288
+ >
289
+ <div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
290
+ <div className="w-12 h-12 rounded-xl bg-foreground/[0.06] border border-foreground/[0.1] flex items-center justify-center">
291
+ <ImageIcon className="w-6 h-6 text-muted-foreground/50" />
292
+ </div>
293
+ {label && (
294
+ <span className="text-[11px] font-medium tracking-wider uppercase text-muted-foreground/50">
295
+ {label}
296
+ </span>
297
+ )}
298
+ </div>
299
+ <svg className="absolute inset-0 w-full h-full opacity-[0.04]" preserveAspectRatio="none">
300
+ <pattern id="diag" width="20" height="20" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
301
+ <line x1="0" y1="0" x2="0" y2="20" stroke="currentColor" strokeWidth="1" />
302
+ </pattern>
303
+ <rect width="100%" height="100%" fill="url(#diag)" />
304
+ </svg>
305
+ </div>
306
+ )
307
+ }
308
+
309
+ // ============================================================================
310
+ // Browser Mockup
311
+ // ============================================================================
312
+
313
+ export function BrowserMockup({
314
+ className,
315
+ label,
316
+ src,
317
+ glowBorder = false,
318
+ }: {
319
+ className?: string
320
+ label?: string
321
+ src?: string
322
+ glowBorder?: boolean
323
+ }) {
324
+ return (
325
+ <div
326
+ className={cn(
327
+ 'rounded-xl overflow-hidden border border-foreground/[0.1] shadow-2xl',
328
+ glowBorder && 'landing-gradient-border',
329
+ className,
330
+ )}
331
+ >
332
+ <div className="flex items-center gap-2 px-4 py-3 bg-foreground/[0.04] border-b border-foreground/[0.08]">
333
+ <div className="flex gap-1.5">
334
+ <div className="w-3 h-3 rounded-full bg-foreground/[0.1]" />
335
+ <div className="w-3 h-3 rounded-full bg-foreground/[0.1]" />
336
+ <div className="w-3 h-3 rounded-full bg-foreground/[0.1]" />
337
+ </div>
338
+ <div className="flex-1 mx-4">
339
+ <div className="h-6 rounded-md bg-foreground/[0.05] border border-foreground/[0.08] max-w-xs mx-auto flex items-center justify-center">
340
+ <span className="text-[10px] text-muted-foreground/50 font-medium">myapp.com</span>
341
+ </div>
342
+ </div>
343
+ </div>
344
+ <PlaceholderImage src={src} label={label} aspectRatio="aspect-[16/10]" className="rounded-none border-0 border-dashed-0" />
345
+ </div>
346
+ )
347
+ }
348
+
349
+ // ============================================================================
350
+ // Section Heading
351
+ // ============================================================================
352
+
353
+ export function SectionHeading({
354
+ tag,
355
+ title,
356
+ titleHighlight,
357
+ subtitle,
358
+ }: {
359
+ tag: string
360
+ title: string
361
+ titleHighlight?: string
362
+ subtitle?: string
363
+ }) {
364
+ return (
365
+ <ScrollReveal className="text-center mb-16">
366
+ <span className="inline-block text-xs font-semibold tracking-[0.2em] uppercase text-primary/80 mb-4">
367
+ {tag}
368
+ </span>
369
+ <h2 className="text-3xl sm:text-4xl md:text-5xl font-bold text-foreground leading-tight tracking-[-0.02em]">
370
+ {title}
371
+ {titleHighlight && (
372
+ <>
373
+ <br />
374
+ <span className="bg-gradient-to-r from-primary via-violet-400 to-purple-300 bg-clip-text text-transparent">
375
+ {titleHighlight}
376
+ </span>
377
+ </>
378
+ )}
379
+ </h2>
380
+ {subtitle && (
381
+ <p className="mt-5 text-muted-foreground max-w-xl mx-auto text-lg leading-relaxed">
382
+ {subtitle}
383
+ </p>
384
+ )}
385
+ </ScrollReveal>
386
+ )
387
+ }
388
+
389
+ export { cn, AnimatePresence, motion, useInView, ChevronDown }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Landing Section: FAQ
3
+ *
4
+ * Accordion FAQ inside a glassmorphic card.
5
+ */
6
+
7
+ import { useState, useCallback } from 'react'
8
+ import { Plus, Minus } from 'lucide-react'
9
+ import { ScrollReveal, GlassCard, SectionHeading, cn, motion, AnimatePresence } from '../primitives'
10
+
11
+ const FAQ_ITEMS = [
12
+ {
13
+ question: 'How do I get started?',
14
+ answer: 'Just launch the app and follow the guided setup. You\'ll be up and running in under a minute -- no complicated configuration needed.',
15
+ },
16
+ {
17
+ question: 'Is it free to use?',
18
+ answer: 'Yes, the core features are completely free. Premium features are available for teams and power users who need more.',
19
+ },
20
+ {
21
+ question: 'Can I customize it?',
22
+ answer: 'Absolutely. The app is designed to be flexible -- adjust settings, layouts, and workflows to match exactly how you like to work.',
23
+ },
24
+ {
25
+ question: 'How does collaboration work?',
26
+ answer: 'Invite your team and work together in real time. Everyone sees changes instantly, and permissions keep things organized.',
27
+ },
28
+ {
29
+ question: 'Where can I get help?',
30
+ answer: 'Check out the built-in help section, browse the documentation, or reach out to the community. Support is always available.',
31
+ },
32
+ ]
33
+
34
+ function FAQItem({ item, isOpen, onToggle }: {
35
+ item: typeof FAQ_ITEMS[number]
36
+ isOpen: boolean
37
+ onToggle: () => void
38
+ }) {
39
+ return (
40
+ <div className="border-b border-foreground/[0.06] last:border-b-0">
41
+ <button
42
+ onClick={onToggle}
43
+ className="w-full flex items-center justify-between gap-4 py-5 text-left group"
44
+ >
45
+ <span className={cn(
46
+ 'text-base font-medium transition-colors duration-200',
47
+ isOpen ? 'text-foreground' : 'text-muted-foreground group-hover:text-foreground',
48
+ )}>
49
+ {item.question}
50
+ </span>
51
+ <div className={cn(
52
+ 'shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-all duration-300',
53
+ isOpen
54
+ ? 'bg-primary/20 text-primary rotate-0'
55
+ : 'bg-foreground/[0.06] text-muted-foreground group-hover:bg-foreground/[0.1]',
56
+ )}>
57
+ {isOpen ? <Minus className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
58
+ </div>
59
+ </button>
60
+ <AnimatePresence initial={false}>
61
+ {isOpen && (
62
+ <motion.div
63
+ initial={{ height: 0, opacity: 0 }}
64
+ animate={{ height: 'auto', opacity: 1 }}
65
+ exit={{ height: 0, opacity: 0 }}
66
+ transition={{ duration: 0.3, ease: [0.25, 0.4, 0.25, 1] }}
67
+ className="overflow-hidden"
68
+ >
69
+ <p className="pb-5 text-sm text-muted-foreground leading-relaxed max-w-2xl">
70
+ {item.answer}
71
+ </p>
72
+ </motion.div>
73
+ )}
74
+ </AnimatePresence>
75
+ </div>
76
+ )
77
+ }
78
+
79
+ export default function FAQSection() {
80
+ const [openIndex, setOpenIndex] = useState<number | null>(null)
81
+
82
+ const handleToggle = useCallback((index: number) => {
83
+ setOpenIndex(prev => prev === index ? null : index)
84
+ }, [])
85
+
86
+ return (
87
+ <section id="faq" className="relative py-28 md:py-36">
88
+ <div className="max-w-3xl mx-auto px-6">
89
+ <SectionHeading
90
+ tag="FAQ"
91
+ title="Common questions"
92
+ subtitle="Everything you need to know to get started."
93
+ />
94
+
95
+ <ScrollReveal>
96
+ <GlassCard className="px-7 md:px-10 py-2" hoverEffect={false}>
97
+ {FAQ_ITEMS.map((item, idx) => (
98
+ <FAQItem
99
+ key={item.question}
100
+ item={item}
101
+ isOpen={openIndex === idx}
102
+ onToggle={() => handleToggle(idx)}
103
+ />
104
+ ))}
105
+ </GlassCard>
106
+ </ScrollReveal>
107
+ </div>
108
+ </section>
109
+ )
110
+ }