Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
This commit is contained in:
@@ -1,118 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { CheckIcon, ChevronsUpDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { COUNTRIES } from '@/lib/countries'
|
||||
|
||||
// Build sorted country list from the canonical COUNTRIES source
|
||||
const countryList = Object.entries(COUNTRIES)
|
||||
.map(([code, info]) => ({ code, name: info.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
/** Renders a country flag image from flagcdn.com CDN */
|
||||
function CountryFlagImg({ code, size = 20, className }: { code: string; size?: number; className?: string }) {
|
||||
return (
|
||||
<img
|
||||
src={`https://flagcdn.com/w${size}/${code.toLowerCase()}.png`}
|
||||
srcSet={`https://flagcdn.com/w${size * 2}/${code.toLowerCase()}.png 2x`}
|
||||
width={size}
|
||||
height={Math.round(size * 0.75)}
|
||||
alt={code}
|
||||
className={cn('inline-block rounded-[2px]', className)}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type Country = { code: string; name: string }
|
||||
|
||||
interface CountrySelectProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const CountrySelect = React.forwardRef<HTMLButtonElement, CountrySelectProps>(
|
||||
({ value, onChange, placeholder = 'Select country...', disabled, className }, ref) => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const selectedCountry = countryList.find((c) => c.code === value)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn('w-full justify-between font-normal', className)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{selectedCountry ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<CountryFlagImg code={selectedCountry.code} size={20} />
|
||||
<span>{selectedCountry.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search country..." />
|
||||
<CommandList>
|
||||
<ScrollArea className="h-72">
|
||||
<CommandEmpty>No country found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{countryList.map((country) => (
|
||||
<CommandItem
|
||||
key={country.code}
|
||||
value={country.name}
|
||||
onSelect={() => {
|
||||
onChange?.(country.code)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<CountryFlagImg code={country.code} size={20} />
|
||||
<span className="flex-1">{country.name}</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'ml-auto h-4 w-4',
|
||||
value === country.code ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
)
|
||||
CountrySelect.displayName = 'CountrySelect'
|
||||
|
||||
export { CountrySelect, countryList as countries, CountryFlagImg }
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { CheckIcon, ChevronsUpDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { COUNTRIES } from '@/lib/countries'
|
||||
|
||||
// Build sorted country list from the canonical COUNTRIES source
|
||||
const countryList = Object.entries(COUNTRIES)
|
||||
.map(([code, info]) => ({ code, name: info.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
/** Renders a country flag image from flagcdn.com CDN */
|
||||
function CountryFlagImg({ code, size = 20, className }: { code: string; size?: number; className?: string }) {
|
||||
return (
|
||||
<img
|
||||
src={`https://flagcdn.com/w${size}/${code.toLowerCase()}.png`}
|
||||
srcSet={`https://flagcdn.com/w${size * 2}/${code.toLowerCase()}.png 2x`}
|
||||
width={size}
|
||||
height={Math.round(size * 0.75)}
|
||||
alt={code}
|
||||
className={cn('inline-block rounded-[2px]', className)}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type Country = { code: string; name: string }
|
||||
|
||||
interface CountrySelectProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const CountrySelect = React.forwardRef<HTMLButtonElement, CountrySelectProps>(
|
||||
({ value, onChange, placeholder = 'Select country...', disabled, className }, ref) => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const selectedCountry = countryList.find((c) => c.code === value)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn('w-full justify-between font-normal', className)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{selectedCountry ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<CountryFlagImg code={selectedCountry.code} size={20} />
|
||||
<span>{selectedCountry.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search country..." />
|
||||
<CommandList>
|
||||
<ScrollArea className="h-72">
|
||||
<CommandEmpty>No country found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{countryList.map((country) => (
|
||||
<CommandItem
|
||||
key={country.code}
|
||||
value={country.name}
|
||||
onSelect={() => {
|
||||
onChange?.(country.code)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<CountryFlagImg code={country.code} size={20} />
|
||||
<span className="flex-1">{country.name}</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'ml-auto h-4 w-4',
|
||||
value === country.code ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
)
|
||||
CountrySelect.displayName = 'CountrySelect'
|
||||
|
||||
export { CountrySelect, countryList as countries, CountryFlagImg }
|
||||
|
||||
@@ -1,121 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Pencil, X, Loader2 } from 'lucide-react'
|
||||
|
||||
type EditableCardProps = {
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
summary: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onSave?: () => void | Promise<void>
|
||||
isSaving?: boolean
|
||||
alwaysShowEdit?: boolean
|
||||
defaultEditing?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EditableCard({
|
||||
title,
|
||||
icon,
|
||||
summary,
|
||||
children,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
alwaysShowEdit = false,
|
||||
defaultEditing = false,
|
||||
className,
|
||||
}: EditableCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(defaultEditing)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (onSave) {
|
||||
await onSave()
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('group relative overflow-hidden', className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && (
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
)}
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={cn(
|
||||
'h-7 gap-1.5 text-xs',
|
||||
!alwaysShowEdit && 'sm:opacity-0 sm:group-hover:opacity-100 transition-opacity'
|
||||
)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(false)}
|
||||
disabled={isSaving}
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isEditing ? (
|
||||
<motion.div
|
||||
key="edit"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{children}
|
||||
{onSave && (
|
||||
<div className="flex justify-end pt-2 border-t">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="view"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{summary}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Pencil, X, Loader2 } from 'lucide-react'
|
||||
|
||||
type EditableCardProps = {
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
summary: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onSave?: () => void | Promise<void>
|
||||
isSaving?: boolean
|
||||
alwaysShowEdit?: boolean
|
||||
defaultEditing?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EditableCard({
|
||||
title,
|
||||
icon,
|
||||
summary,
|
||||
children,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
alwaysShowEdit = false,
|
||||
defaultEditing = false,
|
||||
className,
|
||||
}: EditableCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(defaultEditing)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (onSave) {
|
||||
await onSave()
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('group relative overflow-hidden', className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && (
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
)}
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={cn(
|
||||
'h-7 gap-1.5 text-xs',
|
||||
!alwaysShowEdit && 'sm:opacity-0 sm:group-hover:opacity-100 transition-opacity'
|
||||
)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(false)}
|
||||
disabled={isSaving}
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isEditing ? (
|
||||
<motion.div
|
||||
key="edit"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{children}
|
||||
{onSave && (
|
||||
<div className="flex justify-end pt-2 border-t">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="view"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{summary}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { Info } from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
type InfoTooltipProps = {
|
||||
content: string
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}
|
||||
|
||||
export function InfoTooltip({ content, side = 'top' }: InfoTooltipProps) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className="inline-flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="sr-only">More info</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className="max-w-xs text-sm">
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { Info } from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
type InfoTooltipProps = {
|
||||
content: string
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}
|
||||
|
||||
export function InfoTooltip({ content, side = 'top' }: InfoTooltipProps) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className="inline-flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="sr-only">More info</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className="max-w-xs text-sm">
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,179 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Pencil, Check, X } from 'lucide-react'
|
||||
|
||||
type InlineEditableTextVariant = 'h1' | 'h2' | 'body' | 'mono'
|
||||
|
||||
type InlineEditableTextProps = {
|
||||
value: string
|
||||
onSave: (newValue: string) => void | Promise<void>
|
||||
variant?: InlineEditableTextVariant
|
||||
placeholder?: string
|
||||
multiline?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantStyles: Record<InlineEditableTextVariant, string> = {
|
||||
h1: 'text-xl font-bold',
|
||||
h2: 'text-base font-semibold',
|
||||
body: 'text-sm',
|
||||
mono: 'text-sm font-mono text-muted-foreground',
|
||||
}
|
||||
|
||||
export function InlineEditableText({
|
||||
value,
|
||||
onSave,
|
||||
variant = 'body',
|
||||
placeholder = 'Click to edit...',
|
||||
multiline = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: InlineEditableTextProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(value)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
useEffect(() => {
|
||||
setEditValue(value)
|
||||
}, [value])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmed = editValue.trim()
|
||||
if (trimmed === value) {
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
if (!trimmed) {
|
||||
setEditValue(value)
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onSave(trimmed)
|
||||
setIsEditing(false)
|
||||
} catch {
|
||||
setEditValue(value)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [editValue, value, onSave])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditValue(value)
|
||||
setIsEditing(false)
|
||||
}, [value])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel()
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (multiline && !e.ctrlKey) return
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
},
|
||||
[handleCancel, handleSave, multiline]
|
||||
)
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={cn(variantStyles[variant], className)}>
|
||||
{value || <span className="text-muted-foreground italic">{placeholder}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isEditing ? (
|
||||
<motion.div
|
||||
key="editing"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={cn('flex items-center gap-1.5', className)}
|
||||
>
|
||||
{multiline ? (
|
||||
<Textarea
|
||||
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
disabled={isSaving}
|
||||
placeholder={placeholder}
|
||||
className={cn(variantStyles[variant], 'min-h-[60px] resize-y')}
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
disabled={isSaving}
|
||||
placeholder={placeholder}
|
||||
className={cn(variantStyles[variant], 'h-auto py-1')}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="shrink-0 rounded p-1 text-emerald-600 hover:bg-emerald-50 transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.button
|
||||
key="viewing"
|
||||
type="button"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={cn(
|
||||
'group inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 -mx-1.5 -my-0.5',
|
||||
'hover:bg-muted/60 transition-colors text-left cursor-text',
|
||||
variantStyles[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn(!value && 'text-muted-foreground italic')}>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
<Pencil className="h-3 w-3 shrink-0 opacity-30 sm:opacity-0 sm:group-hover:opacity-50 transition-opacity" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Pencil, Check, X } from 'lucide-react'
|
||||
|
||||
type InlineEditableTextVariant = 'h1' | 'h2' | 'body' | 'mono'
|
||||
|
||||
type InlineEditableTextProps = {
|
||||
value: string
|
||||
onSave: (newValue: string) => void | Promise<void>
|
||||
variant?: InlineEditableTextVariant
|
||||
placeholder?: string
|
||||
multiline?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantStyles: Record<InlineEditableTextVariant, string> = {
|
||||
h1: 'text-xl font-bold',
|
||||
h2: 'text-base font-semibold',
|
||||
body: 'text-sm',
|
||||
mono: 'text-sm font-mono text-muted-foreground',
|
||||
}
|
||||
|
||||
export function InlineEditableText({
|
||||
value,
|
||||
onSave,
|
||||
variant = 'body',
|
||||
placeholder = 'Click to edit...',
|
||||
multiline = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: InlineEditableTextProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(value)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
useEffect(() => {
|
||||
setEditValue(value)
|
||||
}, [value])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmed = editValue.trim()
|
||||
if (trimmed === value) {
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
if (!trimmed) {
|
||||
setEditValue(value)
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onSave(trimmed)
|
||||
setIsEditing(false)
|
||||
} catch {
|
||||
setEditValue(value)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [editValue, value, onSave])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditValue(value)
|
||||
setIsEditing(false)
|
||||
}, [value])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel()
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (multiline && !e.ctrlKey) return
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
},
|
||||
[handleCancel, handleSave, multiline]
|
||||
)
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={cn(variantStyles[variant], className)}>
|
||||
{value || <span className="text-muted-foreground italic">{placeholder}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isEditing ? (
|
||||
<motion.div
|
||||
key="editing"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={cn('flex items-center gap-1.5', className)}
|
||||
>
|
||||
{multiline ? (
|
||||
<Textarea
|
||||
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
disabled={isSaving}
|
||||
placeholder={placeholder}
|
||||
className={cn(variantStyles[variant], 'min-h-[60px] resize-y')}
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
disabled={isSaving}
|
||||
placeholder={placeholder}
|
||||
className={cn(variantStyles[variant], 'h-auto py-1')}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="shrink-0 rounded p-1 text-emerald-600 hover:bg-emerald-50 transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.button
|
||||
key="viewing"
|
||||
type="button"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={cn(
|
||||
'group inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 -mx-1.5 -my-0.5',
|
||||
'hover:bg-muted/60 transition-colors text-left cursor-text',
|
||||
variantStyles[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn(!value && 'text-muted-foreground italic')}>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
<Pencil className="h-3 w-3 shrink-0 opacity-30 sm:opacity-0 sm:group-hover:opacity-50 transition-opacity" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
|
||||
gradient?: boolean
|
||||
}
|
||||
>(({ className, value, gradient, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-primary/20',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn(
|
||||
'h-full w-full flex-1 transition-all',
|
||||
gradient
|
||||
? 'bg-gradient-to-r from-brand-teal to-brand-blue'
|
||||
: 'bg-primary'
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
|
||||
gradient?: boolean
|
||||
}
|
||||
>(({ className, value, gradient, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-primary/20',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn(
|
||||
'h-full w-full flex-1 transition-all',
|
||||
gradient
|
||||
? 'bg-gradient-to-r from-brand-teal to-brand-blue'
|
||||
: 'bg-primary'
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
|
||||
@@ -1,262 +1,262 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle2, AlertCircle, Loader2, Save, Rocket } from 'lucide-react'
|
||||
|
||||
export type StepConfig = {
|
||||
title: string
|
||||
description?: string
|
||||
isValid?: boolean
|
||||
hasErrors?: boolean
|
||||
}
|
||||
|
||||
type SidebarStepperProps = {
|
||||
steps: StepConfig[]
|
||||
currentStep: number
|
||||
onStepChange: (index: number) => void
|
||||
onSave?: () => void
|
||||
onSubmit?: () => void
|
||||
isSaving?: boolean
|
||||
isSubmitting?: boolean
|
||||
saveLabel?: string
|
||||
submitLabel?: string
|
||||
canSubmit?: boolean
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SidebarStepper({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepChange,
|
||||
onSave,
|
||||
onSubmit,
|
||||
isSaving = false,
|
||||
isSubmitting = false,
|
||||
saveLabel = 'Save Draft',
|
||||
submitLabel = 'Create Pipeline',
|
||||
canSubmit = true,
|
||||
children,
|
||||
className,
|
||||
}: SidebarStepperProps) {
|
||||
const direction = (prev: number, next: number) => (next > prev ? 1 : -1)
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-6 min-h-[600px]', className)}>
|
||||
{/* Sidebar - hidden on mobile */}
|
||||
<div className="hidden lg:flex lg:flex-col lg:w-[260px] lg:shrink-0">
|
||||
<nav className="flex-1 space-y-1 py-2">
|
||||
{steps.map((step, index) => {
|
||||
const isCurrent = index === currentStep
|
||||
const isComplete = step.isValid === true
|
||||
const hasErrors = step.hasErrors === true
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => onStepChange(index)}
|
||||
className={cn(
|
||||
'w-full flex items-start gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
|
||||
isCurrent
|
||||
? 'bg-primary/5 border border-primary/20'
|
||||
: 'hover:bg-muted/50 border border-transparent'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold transition-colors',
|
||||
isCurrent && 'bg-primary text-primary-foreground',
|
||||
!isCurrent && isComplete && 'bg-emerald-500 text-white',
|
||||
!isCurrent && hasErrors && 'bg-destructive/10 text-destructive border border-destructive/30',
|
||||
!isCurrent && !isComplete && !hasErrors && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isComplete && !isCurrent ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
) : hasErrors && !isCurrent ? (
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm font-medium truncate',
|
||||
isCurrent && 'text-primary',
|
||||
!isCurrent && 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
{step.description && (
|
||||
<p className="text-[11px] text-muted-foreground truncate mt-0.5">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="border-t pt-4 space-y-2 mt-auto">
|
||||
{onSave && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
disabled={isSaving || isSubmitting}
|
||||
onClick={onSave}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{saveLabel}
|
||||
</Button>
|
||||
)}
|
||||
{onSubmit && (
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
disabled={isSubmitting || isSaving || !canSubmit}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Rocket className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile step indicator */}
|
||||
<div className="lg:hidden flex flex-col w-full">
|
||||
<MobileStepIndicator
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepChange={onStepChange}
|
||||
/>
|
||||
<div className="flex-1 mt-4">
|
||||
<StepContent currentStep={currentStep} direction={direction}>
|
||||
{children}
|
||||
</StepContent>
|
||||
</div>
|
||||
{/* Mobile actions */}
|
||||
<div className="flex gap-2 pt-4 border-t mt-4">
|
||||
{onSave && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={isSaving || isSubmitting}
|
||||
onClick={onSave}
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
|
||||
{saveLabel}
|
||||
</Button>
|
||||
)}
|
||||
{onSubmit && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={isSubmitting || isSaving || !canSubmit}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Rocket className="h-4 w-4 mr-1" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop content */}
|
||||
<div className="hidden lg:block flex-1 min-w-0">
|
||||
<StepContent currentStep={currentStep} direction={direction}>
|
||||
{children}
|
||||
</StepContent>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileStepIndicator({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepChange,
|
||||
}: {
|
||||
steps: StepConfig[]
|
||||
currentStep: number
|
||||
onStepChange: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex items-center gap-1 pb-2 min-w-max">
|
||||
{steps.map((step, index) => {
|
||||
const isCurrent = index === currentStep
|
||||
const isComplete = step.isValid === true
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => onStepChange(index)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-colors shrink-0',
|
||||
isCurrent && 'bg-primary text-primary-foreground',
|
||||
!isCurrent && isComplete && 'bg-emerald-100 text-emerald-700',
|
||||
!isCurrent && !isComplete && 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
)}
|
||||
>
|
||||
{isComplete && !isCurrent ? (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
<span className="hidden sm:inline">{step.title}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepContent({
|
||||
currentStep,
|
||||
direction,
|
||||
children,
|
||||
}: {
|
||||
currentStep: number
|
||||
direction: (prev: number, next: number) => number
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const childArray = Array.isArray(children) ? children : [children]
|
||||
const currentChild = childArray[currentStep]
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
{currentChild}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle2, AlertCircle, Loader2, Save, Rocket } from 'lucide-react'
|
||||
|
||||
export type StepConfig = {
|
||||
title: string
|
||||
description?: string
|
||||
isValid?: boolean
|
||||
hasErrors?: boolean
|
||||
}
|
||||
|
||||
type SidebarStepperProps = {
|
||||
steps: StepConfig[]
|
||||
currentStep: number
|
||||
onStepChange: (index: number) => void
|
||||
onSave?: () => void
|
||||
onSubmit?: () => void
|
||||
isSaving?: boolean
|
||||
isSubmitting?: boolean
|
||||
saveLabel?: string
|
||||
submitLabel?: string
|
||||
canSubmit?: boolean
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SidebarStepper({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepChange,
|
||||
onSave,
|
||||
onSubmit,
|
||||
isSaving = false,
|
||||
isSubmitting = false,
|
||||
saveLabel = 'Save Draft',
|
||||
submitLabel = 'Create Pipeline',
|
||||
canSubmit = true,
|
||||
children,
|
||||
className,
|
||||
}: SidebarStepperProps) {
|
||||
const direction = (prev: number, next: number) => (next > prev ? 1 : -1)
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-6 min-h-[600px]', className)}>
|
||||
{/* Sidebar - hidden on mobile */}
|
||||
<div className="hidden lg:flex lg:flex-col lg:w-[260px] lg:shrink-0">
|
||||
<nav className="flex-1 space-y-1 py-2">
|
||||
{steps.map((step, index) => {
|
||||
const isCurrent = index === currentStep
|
||||
const isComplete = step.isValid === true
|
||||
const hasErrors = step.hasErrors === true
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => onStepChange(index)}
|
||||
className={cn(
|
||||
'w-full flex items-start gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
|
||||
isCurrent
|
||||
? 'bg-primary/5 border border-primary/20'
|
||||
: 'hover:bg-muted/50 border border-transparent'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold transition-colors',
|
||||
isCurrent && 'bg-primary text-primary-foreground',
|
||||
!isCurrent && isComplete && 'bg-emerald-500 text-white',
|
||||
!isCurrent && hasErrors && 'bg-destructive/10 text-destructive border border-destructive/30',
|
||||
!isCurrent && !isComplete && !hasErrors && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isComplete && !isCurrent ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
) : hasErrors && !isCurrent ? (
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm font-medium truncate',
|
||||
isCurrent && 'text-primary',
|
||||
!isCurrent && 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
{step.description && (
|
||||
<p className="text-[11px] text-muted-foreground truncate mt-0.5">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="border-t pt-4 space-y-2 mt-auto">
|
||||
{onSave && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
disabled={isSaving || isSubmitting}
|
||||
onClick={onSave}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{saveLabel}
|
||||
</Button>
|
||||
)}
|
||||
{onSubmit && (
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
disabled={isSubmitting || isSaving || !canSubmit}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Rocket className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile step indicator */}
|
||||
<div className="lg:hidden flex flex-col w-full">
|
||||
<MobileStepIndicator
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepChange={onStepChange}
|
||||
/>
|
||||
<div className="flex-1 mt-4">
|
||||
<StepContent currentStep={currentStep} direction={direction}>
|
||||
{children}
|
||||
</StepContent>
|
||||
</div>
|
||||
{/* Mobile actions */}
|
||||
<div className="flex gap-2 pt-4 border-t mt-4">
|
||||
{onSave && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={isSaving || isSubmitting}
|
||||
onClick={onSave}
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
|
||||
{saveLabel}
|
||||
</Button>
|
||||
)}
|
||||
{onSubmit && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={isSubmitting || isSaving || !canSubmit}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Rocket className="h-4 w-4 mr-1" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop content */}
|
||||
<div className="hidden lg:block flex-1 min-w-0">
|
||||
<StepContent currentStep={currentStep} direction={direction}>
|
||||
{children}
|
||||
</StepContent>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileStepIndicator({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepChange,
|
||||
}: {
|
||||
steps: StepConfig[]
|
||||
currentStep: number
|
||||
onStepChange: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex items-center gap-1 pb-2 min-w-max">
|
||||
{steps.map((step, index) => {
|
||||
const isCurrent = index === currentStep
|
||||
const isComplete = step.isValid === true
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => onStepChange(index)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-colors shrink-0',
|
||||
isCurrent && 'bg-primary text-primary-foreground',
|
||||
!isCurrent && isComplete && 'bg-emerald-100 text-emerald-700',
|
||||
!isCurrent && !isComplete && 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
)}
|
||||
>
|
||||
{isComplete && !isCurrent ? (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
<span className="hidden sm:inline">{step.title}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepContent({
|
||||
currentStep,
|
||||
direction,
|
||||
children,
|
||||
}: {
|
||||
currentStep: number
|
||||
direction: (prev: number, next: number) => number
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const childArray = Array.isArray(children) ? children : [children]
|
||||
const currentChild = childArray[currentStep]
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
{currentChild}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user