Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
326
src/components/forms/form-wizard.tsx
Normal file
326
src/components/forms/form-wizard.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, ArrowRight, Check, Loader2 } from 'lucide-react'
|
||||
|
||||
export interface WizardStep {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
isOptional?: boolean
|
||||
}
|
||||
|
||||
interface FormWizardContextValue {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
steps: WizardStep[]
|
||||
goToStep: (step: number) => void
|
||||
nextStep: () => void
|
||||
prevStep: () => void
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
canGoNext: boolean
|
||||
setCanGoNext: (can: boolean) => void
|
||||
}
|
||||
|
||||
const FormWizardContext = React.createContext<FormWizardContextValue | null>(null)
|
||||
|
||||
export function useFormWizard() {
|
||||
const context = React.useContext(FormWizardContext)
|
||||
if (!context) {
|
||||
throw new Error('useFormWizard must be used within a FormWizard')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface FormWizardProps {
|
||||
steps: WizardStep[]
|
||||
children: React.ReactNode
|
||||
onComplete?: () => void | Promise<void>
|
||||
isSubmitting?: boolean
|
||||
submitLabel?: string
|
||||
className?: string
|
||||
showStepIndicator?: boolean
|
||||
allowStepNavigation?: boolean
|
||||
}
|
||||
|
||||
export function FormWizard({
|
||||
steps,
|
||||
children,
|
||||
onComplete,
|
||||
isSubmitting = false,
|
||||
submitLabel = 'Submit',
|
||||
className,
|
||||
showStepIndicator = true,
|
||||
allowStepNavigation = false,
|
||||
}: FormWizardProps) {
|
||||
const [currentStep, setCurrentStep] = React.useState(0)
|
||||
const [canGoNext, setCanGoNext] = React.useState(true)
|
||||
const [direction, setDirection] = React.useState(0) // -1 for back, 1 for forward
|
||||
|
||||
const totalSteps = steps.length
|
||||
const isFirstStep = currentStep === 0
|
||||
const isLastStep = currentStep === totalSteps - 1
|
||||
|
||||
const goToStep = React.useCallback((step: number) => {
|
||||
if (step >= 0 && step < totalSteps) {
|
||||
setDirection(step > currentStep ? 1 : -1)
|
||||
setCurrentStep(step)
|
||||
}
|
||||
}, [currentStep, totalSteps])
|
||||
|
||||
const nextStep = React.useCallback(() => {
|
||||
if (currentStep < totalSteps - 1) {
|
||||
setDirection(1)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}, [currentStep, totalSteps])
|
||||
|
||||
const prevStep = React.useCallback(() => {
|
||||
if (currentStep > 0) {
|
||||
setDirection(-1)
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isLastStep && onComplete) {
|
||||
await onComplete()
|
||||
} else {
|
||||
nextStep()
|
||||
}
|
||||
}
|
||||
|
||||
const contextValue: FormWizardContextValue = {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
steps,
|
||||
goToStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
canGoNext,
|
||||
setCanGoNext,
|
||||
}
|
||||
|
||||
const childrenArray = React.Children.toArray(children)
|
||||
const currentChild = childrenArray[currentStep]
|
||||
|
||||
const variants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 100 : -100,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
x: direction < 0 ? 100 : -100,
|
||||
opacity: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
return (
|
||||
<FormWizardContext.Provider value={contextValue}>
|
||||
<div className={cn('flex min-h-[600px] flex-col', className)}>
|
||||
{showStepIndicator && (
|
||||
<StepIndicator
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
allowNavigation={allowStepNavigation}
|
||||
onStepClick={allowStepNavigation ? goToStep : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<AnimatePresence initial={false} custom={direction} mode="wait">
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{
|
||||
x: { type: 'spring', stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 },
|
||||
}}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<div className="h-full px-1 py-6">
|
||||
{currentChild}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={prevStep}
|
||||
disabled={isFirstStep || isSubmitting}
|
||||
className={cn(isFirstStep && 'invisible')}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={!canGoNext || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : isLastStep ? (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{submitLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormWizardContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepIndicatorProps {
|
||||
steps: WizardStep[]
|
||||
currentStep: number
|
||||
allowNavigation?: boolean
|
||||
onStepClick?: (step: number) => void
|
||||
}
|
||||
|
||||
export function StepIndicator({
|
||||
steps,
|
||||
currentStep,
|
||||
allowNavigation = false,
|
||||
onStepClick,
|
||||
}: StepIndicatorProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{/* Progress bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>Step {currentStep + 1} of {steps.length}</span>
|
||||
<span>{Math.round(((currentStep + 1) / steps.length) * 100)}% complete</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-primary to-primary/80"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((currentStep + 1) / steps.length) * 100}%` }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStep
|
||||
const isCurrent = index === currentStep
|
||||
const isClickable = allowNavigation && (isCompleted || isCurrent)
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isClickable && onStepClick?.(index)}
|
||||
disabled={!isClickable}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-2',
|
||||
isClickable && 'cursor-pointer',
|
||||
!isClickable && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors',
|
||||
isCompleted && 'border-primary bg-primary text-primary-foreground',
|
||||
isCurrent && 'border-primary text-primary',
|
||||
!isCompleted && !isCurrent && 'border-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="h-5 w-5" />
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'hidden text-xs font-medium md:block',
|
||||
isCurrent && 'text-primary',
|
||||
!isCurrent && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 flex-1 mx-2',
|
||||
index < currentStep ? 'bg-primary' : 'bg-muted'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface WizardStepContentProps {
|
||||
children: React.ReactNode
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function WizardStepContent({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
}: WizardStepContentProps) {
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', className)}>
|
||||
{(title || description) && (
|
||||
<div className="mb-8 text-center">
|
||||
{title && (
|
||||
<h2 className="text-2xl font-semibold tracking-tight md:text-3xl">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user