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,142 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { Calendar, GraduationCap, Heart } from 'lucide-react'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import type { WizardConfig } from '@/types/wizard-config'
|
||||
|
||||
interface StepAdditionalProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
isBusinessConcept: boolean
|
||||
isStartup: boolean
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepAdditional({ form, isBusinessConcept, isStartup, config }: StepAdditionalProps) {
|
||||
const { register, formState: { errors }, setValue, watch } = form
|
||||
const wantsMentorship = watch('wantsMentorship')
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="A few more details"
|
||||
description="Help us understand your background and needs."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-md space-y-8"
|
||||
>
|
||||
{/* Institution (for Business Concepts) */}
|
||||
{isBusinessConcept && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="h-5 w-5 text-muted-foreground" />
|
||||
<Label htmlFor="institution">
|
||||
University/School <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
<Input
|
||||
id="institution"
|
||||
placeholder="MIT, Stanford, INSEAD..."
|
||||
{...register('institution')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.institution && (
|
||||
<p className="text-sm text-destructive">{errors.institution.message}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the name of your university or educational institution.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Startup Created Date (for Startups) */}
|
||||
{isStartup && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground" />
|
||||
<Label htmlFor="startupCreatedDate">
|
||||
When was your startup created? <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
<Input
|
||||
id="startupCreatedDate"
|
||||
type="date"
|
||||
{...register('startupCreatedDate')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.startupCreatedDate && (
|
||||
<p className="text-sm text-destructive">{errors.startupCreatedDate.message}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the date your startup was officially registered or founded.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Mentorship */}
|
||||
{config?.features?.enableMentorship !== false && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Heart className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="wantsMentorship" className="text-base font-medium">
|
||||
Would you like mentorship support?
|
||||
</Label>
|
||||
<Switch
|
||||
id="wantsMentorship"
|
||||
checked={wantsMentorship}
|
||||
onCheckedChange={(checked) => setValue('wantsMentorship', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Our mentors are industry experts who can help guide your project.
|
||||
This is optional but highly recommended.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Referral Source */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label htmlFor="referralSource">
|
||||
How did you hear about us? <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="referralSource"
|
||||
placeholder="Friend, Social media, Event..."
|
||||
{...register('referralSource')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { Calendar, GraduationCap, Heart } from 'lucide-react'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import type { WizardConfig } from '@/types/wizard-config'
|
||||
|
||||
interface StepAdditionalProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
isBusinessConcept: boolean
|
||||
isStartup: boolean
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepAdditional({ form, isBusinessConcept, isStartup, config }: StepAdditionalProps) {
|
||||
const { register, formState: { errors }, setValue, watch } = form
|
||||
const wantsMentorship = watch('wantsMentorship')
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="A few more details"
|
||||
description="Help us understand your background and needs."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-md space-y-8"
|
||||
>
|
||||
{/* Institution (for Business Concepts) */}
|
||||
{isBusinessConcept && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="h-5 w-5 text-muted-foreground" />
|
||||
<Label htmlFor="institution">
|
||||
University/School <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
<Input
|
||||
id="institution"
|
||||
placeholder="MIT, Stanford, INSEAD..."
|
||||
{...register('institution')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.institution && (
|
||||
<p className="text-sm text-destructive">{errors.institution.message}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the name of your university or educational institution.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Startup Created Date (for Startups) */}
|
||||
{isStartup && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground" />
|
||||
<Label htmlFor="startupCreatedDate">
|
||||
When was your startup created? <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
<Input
|
||||
id="startupCreatedDate"
|
||||
type="date"
|
||||
{...register('startupCreatedDate')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.startupCreatedDate && (
|
||||
<p className="text-sm text-destructive">{errors.startupCreatedDate.message}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the date your startup was officially registered or founded.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Mentorship */}
|
||||
{config?.features?.enableMentorship !== false && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Heart className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="wantsMentorship" className="text-base font-medium">
|
||||
Would you like mentorship support?
|
||||
</Label>
|
||||
<Switch
|
||||
id="wantsMentorship"
|
||||
checked={wantsMentorship}
|
||||
onCheckedChange={(checked) => setValue('wantsMentorship', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Our mentors are industry experts who can help guide your project.
|
||||
This is optional but highly recommended.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Referral Source */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label htmlFor="referralSource">
|
||||
How did you hear about us? <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="referralSource"
|
||||
placeholder="Friend, Social media, Event..."
|
||||
{...register('referralSource')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,130 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { PhoneInput } from '@/components/ui/phone-input'
|
||||
import { CountrySelect } from '@/components/ui/country-select'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import type { WizardConfig } from '@/types/wizard-config'
|
||||
import { isFieldVisible, isFieldRequired, getFieldConfig } from '@/lib/wizard-config'
|
||||
|
||||
interface StepContactProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepContact({ form, config }: StepContactProps) {
|
||||
const { register, formState: { errors }, setValue, watch } = form
|
||||
const country = watch('country')
|
||||
const phone = watch('contactPhone')
|
||||
|
||||
const showPhone = !config || isFieldVisible(config, 'contactPhone')
|
||||
const showCountry = !config || isFieldVisible(config, 'country')
|
||||
const showCity = !config || isFieldVisible(config, 'city')
|
||||
const phoneRequired = !config || isFieldRequired(config, 'contactPhone')
|
||||
const countryRequired = !config || isFieldRequired(config, 'country')
|
||||
const phoneLabel = config ? getFieldConfig(config, 'contactPhone').label : undefined
|
||||
const countryLabel = config ? getFieldConfig(config, 'country').label : undefined
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Tell us about yourself"
|
||||
description="We'll use this information to contact you about your application."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-md space-y-6"
|
||||
>
|
||||
{/* Full Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactName">
|
||||
Full Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="contactName"
|
||||
placeholder="John Smith"
|
||||
{...register('contactName')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.contactName && (
|
||||
<p className="text-sm text-destructive">{errors.contactName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactEmail">
|
||||
Email Address <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="contactEmail"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
{...register('contactEmail')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.contactEmail && (
|
||||
<p className="text-sm text-destructive">{errors.contactEmail.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
{showPhone && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactPhone">
|
||||
{phoneLabel ?? 'Phone Number'}{' '}
|
||||
{phoneRequired ? <span className="text-destructive">*</span> : <span className="text-muted-foreground text-xs">(optional)</span>}
|
||||
</Label>
|
||||
<PhoneInput
|
||||
value={phone}
|
||||
onChange={(value) => setValue('contactPhone', value || '')}
|
||||
defaultCountry="MC"
|
||||
className="h-12"
|
||||
/>
|
||||
{errors.contactPhone && (
|
||||
<p className="text-sm text-destructive">{errors.contactPhone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Country */}
|
||||
{showCountry && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{countryLabel ?? 'Country'}{' '}
|
||||
{countryRequired ? <span className="text-destructive">*</span> : <span className="text-muted-foreground text-xs">(optional)</span>}
|
||||
</Label>
|
||||
<CountrySelect
|
||||
value={country}
|
||||
onChange={(value) => setValue('country', value)}
|
||||
placeholder="Select your country"
|
||||
className="h-12"
|
||||
/>
|
||||
{errors.country && (
|
||||
<p className="text-sm text-destructive">{errors.country.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* City (optional) */}
|
||||
{showCity && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">
|
||||
City <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="city"
|
||||
placeholder="Monaco"
|
||||
{...register('city')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { PhoneInput } from '@/components/ui/phone-input'
|
||||
import { CountrySelect } from '@/components/ui/country-select'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import type { WizardConfig } from '@/types/wizard-config'
|
||||
import { isFieldVisible, isFieldRequired, getFieldConfig } from '@/lib/wizard-config'
|
||||
|
||||
interface StepContactProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepContact({ form, config }: StepContactProps) {
|
||||
const { register, formState: { errors }, setValue, watch } = form
|
||||
const country = watch('country')
|
||||
const phone = watch('contactPhone')
|
||||
|
||||
const showPhone = !config || isFieldVisible(config, 'contactPhone')
|
||||
const showCountry = !config || isFieldVisible(config, 'country')
|
||||
const showCity = !config || isFieldVisible(config, 'city')
|
||||
const phoneRequired = !config || isFieldRequired(config, 'contactPhone')
|
||||
const countryRequired = !config || isFieldRequired(config, 'country')
|
||||
const phoneLabel = config ? getFieldConfig(config, 'contactPhone').label : undefined
|
||||
const countryLabel = config ? getFieldConfig(config, 'country').label : undefined
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Tell us about yourself"
|
||||
description="We'll use this information to contact you about your application."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-md space-y-6"
|
||||
>
|
||||
{/* Full Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactName">
|
||||
Full Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="contactName"
|
||||
placeholder="John Smith"
|
||||
{...register('contactName')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.contactName && (
|
||||
<p className="text-sm text-destructive">{errors.contactName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactEmail">
|
||||
Email Address <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="contactEmail"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
{...register('contactEmail')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.contactEmail && (
|
||||
<p className="text-sm text-destructive">{errors.contactEmail.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
{showPhone && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactPhone">
|
||||
{phoneLabel ?? 'Phone Number'}{' '}
|
||||
{phoneRequired ? <span className="text-destructive">*</span> : <span className="text-muted-foreground text-xs">(optional)</span>}
|
||||
</Label>
|
||||
<PhoneInput
|
||||
value={phone}
|
||||
onChange={(value) => setValue('contactPhone', value || '')}
|
||||
defaultCountry="MC"
|
||||
className="h-12"
|
||||
/>
|
||||
{errors.contactPhone && (
|
||||
<p className="text-sm text-destructive">{errors.contactPhone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Country */}
|
||||
{showCountry && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{countryLabel ?? 'Country'}{' '}
|
||||
{countryRequired ? <span className="text-destructive">*</span> : <span className="text-muted-foreground text-xs">(optional)</span>}
|
||||
</Label>
|
||||
<CountrySelect
|
||||
value={country}
|
||||
onChange={(value) => setValue('country', value)}
|
||||
placeholder="Select your country"
|
||||
className="h-12"
|
||||
/>
|
||||
{errors.country && (
|
||||
<p className="text-sm text-destructive">{errors.country.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* City (optional) */}
|
||||
{showCity && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">
|
||||
City <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="city"
|
||||
placeholder="Monaco"
|
||||
{...register('city')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,130 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import { type DropdownOption, type WizardConfig, DEFAULT_OCEAN_ISSUES } from '@/types/wizard-config'
|
||||
import { isFieldVisible, getFieldConfig } from '@/lib/wizard-config'
|
||||
|
||||
interface StepProjectProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
oceanIssues?: DropdownOption[]
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepProject({ form, oceanIssues, config }: StepProjectProps) {
|
||||
const issueOptions = oceanIssues ?? DEFAULT_OCEAN_ISSUES
|
||||
const { register, formState: { errors }, setValue, watch } = form
|
||||
const oceanIssue = watch('oceanIssue')
|
||||
const description = watch('description') || ''
|
||||
const showTeamName = !config || isFieldVisible(config, 'teamName')
|
||||
const descriptionLabel = config ? getFieldConfig(config, 'description').label : undefined
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Tell us about your project"
|
||||
description="Share the details of your ocean protection initiative."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-lg space-y-6"
|
||||
>
|
||||
{/* Project Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="projectName">
|
||||
Name of your project/startup <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="projectName"
|
||||
placeholder="Ocean Guardian AI"
|
||||
{...register('projectName')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.projectName && (
|
||||
<p className="text-sm text-destructive">{errors.projectName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Name (optional) */}
|
||||
{showTeamName && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="teamName">
|
||||
Team Name <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="teamName"
|
||||
placeholder="Blue Innovation Team"
|
||||
{...register('teamName')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ocean Issue */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
What type of ocean issue does your project address? <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={oceanIssue}
|
||||
onValueChange={(value) => setValue('oceanIssue', value)}
|
||||
>
|
||||
<SelectTrigger className="h-12 text-base">
|
||||
<SelectValue placeholder="Select an ocean issue" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{issueOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.oceanIssue && (
|
||||
<p className="text-sm text-destructive">{errors.oceanIssue.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">
|
||||
Briefly describe your project idea and objectives <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Keep it brief - you'll have the opportunity to provide more details later.
|
||||
</p>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Our project aims to..."
|
||||
rows={5}
|
||||
maxLength={2000}
|
||||
{...register('description')}
|
||||
className="text-base resize-none"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{errors.description ? (
|
||||
<span className="text-destructive">{errors.description.message}</span>
|
||||
) : (
|
||||
'Minimum 20 characters'
|
||||
)}
|
||||
</span>
|
||||
<span>{description.length} characters</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import { type DropdownOption, type WizardConfig, DEFAULT_OCEAN_ISSUES } from '@/types/wizard-config'
|
||||
import { isFieldVisible, getFieldConfig } from '@/lib/wizard-config'
|
||||
|
||||
interface StepProjectProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
oceanIssues?: DropdownOption[]
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepProject({ form, oceanIssues, config }: StepProjectProps) {
|
||||
const issueOptions = oceanIssues ?? DEFAULT_OCEAN_ISSUES
|
||||
const { register, formState: { errors }, setValue, watch } = form
|
||||
const oceanIssue = watch('oceanIssue')
|
||||
const description = watch('description') || ''
|
||||
const showTeamName = !config || isFieldVisible(config, 'teamName')
|
||||
const descriptionLabel = config ? getFieldConfig(config, 'description').label : undefined
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Tell us about your project"
|
||||
description="Share the details of your ocean protection initiative."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-lg space-y-6"
|
||||
>
|
||||
{/* Project Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="projectName">
|
||||
Name of your project/startup <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="projectName"
|
||||
placeholder="Ocean Guardian AI"
|
||||
{...register('projectName')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.projectName && (
|
||||
<p className="text-sm text-destructive">{errors.projectName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Name (optional) */}
|
||||
{showTeamName && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="teamName">
|
||||
Team Name <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="teamName"
|
||||
placeholder="Blue Innovation Team"
|
||||
{...register('teamName')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ocean Issue */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
What type of ocean issue does your project address? <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={oceanIssue}
|
||||
onValueChange={(value) => setValue('oceanIssue', value)}
|
||||
>
|
||||
<SelectTrigger className="h-12 text-base">
|
||||
<SelectValue placeholder="Select an ocean issue" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{issueOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.oceanIssue && (
|
||||
<p className="text-sm text-destructive">{errors.oceanIssue.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">
|
||||
Briefly describe your project idea and objectives <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Keep it brief - you'll have the opportunity to provide more details later.
|
||||
</p>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Our project aims to..."
|
||||
rows={5}
|
||||
maxLength={2000}
|
||||
{...register('description')}
|
||||
className="text-base resize-none"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{errors.description ? (
|
||||
<span className="text-destructive">{errors.description.message}</span>
|
||||
) : (
|
||||
'Minimum 20 characters'
|
||||
)}
|
||||
</span>
|
||||
<span>{description.length} characters</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,206 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Briefcase,
|
||||
Waves,
|
||||
Users,
|
||||
GraduationCap,
|
||||
Calendar,
|
||||
Heart,
|
||||
MessageSquare,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import { countries } from '@/components/ui/country-select'
|
||||
import { type WizardConfig, DEFAULT_OCEAN_ISSUES, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
|
||||
|
||||
interface StepReviewProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
programName: string
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepReview({ form, programName, config }: StepReviewProps) {
|
||||
const { formState: { errors }, setValue, watch } = form
|
||||
const data = watch()
|
||||
|
||||
const countryName = countries.find((c) => c.code === data.country)?.name || data.country
|
||||
|
||||
const getOceanIssueLabel = (value: string): string => {
|
||||
const issues = config?.oceanIssues ?? DEFAULT_OCEAN_ISSUES
|
||||
return issues.find((i) => i.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
const getCategoryLabel = (value: string): string => {
|
||||
const cats = config?.competitionCategories ?? DEFAULT_COMPETITION_CATEGORIES
|
||||
return cats.find((c) => c.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Review your application"
|
||||
description="Please review your information before submitting."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-lg space-y-6"
|
||||
>
|
||||
{/* Contact Info Section */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Contact Information
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.contactName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.contactEmail}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.contactPhone}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.city ? `${data.city}, ${countryName}` : countryName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Info Section */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
Project Details
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Project Name:</span>
|
||||
<p className="font-medium">{data.projectName}</p>
|
||||
</div>
|
||||
{data.teamName && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Team Name:</span>
|
||||
<p className="font-medium">{data.teamName}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{getCategoryLabel(data.competitionCategory)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Waves className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{getOceanIssueLabel(data.oceanIssue)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Description:</span>
|
||||
<p className="mt-1 text-sm text-foreground/80 whitespace-pre-wrap">
|
||||
{data.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Members Section */}
|
||||
{data.teamMembers && data.teamMembers.length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Team Members ({data.teamMembers.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{data.teamMembers.map((member, index) => (
|
||||
<div key={index} className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{member.name}</span>
|
||||
<span className="text-muted-foreground"> - {member.email}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{member.role === 'MEMBER' ? 'Member' : 'Advisor'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Info Section */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Additional Information
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{data.institution && (
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.institution}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.startupCreatedDate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Founded: {new Date(data.startupCreatedDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className={`h-4 w-4 ${data.wantsMentorship ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<span>
|
||||
{data.wantsMentorship
|
||||
? 'Interested in mentorship'
|
||||
: 'Not interested in mentorship'}
|
||||
</span>
|
||||
</div>
|
||||
{data.referralSource && (
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Heard about us via: {data.referralSource}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GDPR Consent */}
|
||||
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="gdprConsent"
|
||||
checked={data.gdprConsent}
|
||||
onCheckedChange={(checked) => setValue('gdprConsent', checked === true)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="gdprConsent" className="text-sm font-medium">
|
||||
I consent to the processing of my personal data <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
By submitting this application, I agree that {programName} may process my personal
|
||||
data in accordance with their privacy policy. My data will be used solely for the
|
||||
purpose of evaluating my application and communicating with me about the program.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{errors.gdprConsent && (
|
||||
<p className="mt-2 text-sm text-destructive">{errors.gdprConsent.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Briefcase,
|
||||
Waves,
|
||||
Users,
|
||||
GraduationCap,
|
||||
Calendar,
|
||||
Heart,
|
||||
MessageSquare,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import { countries } from '@/components/ui/country-select'
|
||||
import { type WizardConfig, DEFAULT_OCEAN_ISSUES, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
|
||||
|
||||
interface StepReviewProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
programName: string
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepReview({ form, programName, config }: StepReviewProps) {
|
||||
const { formState: { errors }, setValue, watch } = form
|
||||
const data = watch()
|
||||
|
||||
const countryName = countries.find((c) => c.code === data.country)?.name || data.country
|
||||
|
||||
const getOceanIssueLabel = (value: string): string => {
|
||||
const issues = config?.oceanIssues ?? DEFAULT_OCEAN_ISSUES
|
||||
return issues.find((i) => i.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
const getCategoryLabel = (value: string): string => {
|
||||
const cats = config?.competitionCategories ?? DEFAULT_COMPETITION_CATEGORIES
|
||||
return cats.find((c) => c.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Review your application"
|
||||
description="Please review your information before submitting."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-lg space-y-6"
|
||||
>
|
||||
{/* Contact Info Section */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Contact Information
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.contactName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.contactEmail}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.contactPhone}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.city ? `${data.city}, ${countryName}` : countryName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Info Section */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
Project Details
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Project Name:</span>
|
||||
<p className="font-medium">{data.projectName}</p>
|
||||
</div>
|
||||
{data.teamName && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Team Name:</span>
|
||||
<p className="font-medium">{data.teamName}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{getCategoryLabel(data.competitionCategory)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Waves className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{getOceanIssueLabel(data.oceanIssue)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Description:</span>
|
||||
<p className="mt-1 text-sm text-foreground/80 whitespace-pre-wrap">
|
||||
{data.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Members Section */}
|
||||
{data.teamMembers && data.teamMembers.length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Team Members ({data.teamMembers.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{data.teamMembers.map((member, index) => (
|
||||
<div key={index} className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{member.name}</span>
|
||||
<span className="text-muted-foreground"> - {member.email}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{member.role === 'MEMBER' ? 'Member' : 'Advisor'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Info Section */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Additional Information
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{data.institution && (
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.institution}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.startupCreatedDate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Founded: {new Date(data.startupCreatedDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className={`h-4 w-4 ${data.wantsMentorship ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<span>
|
||||
{data.wantsMentorship
|
||||
? 'Interested in mentorship'
|
||||
: 'Not interested in mentorship'}
|
||||
</span>
|
||||
</div>
|
||||
{data.referralSource && (
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Heard about us via: {data.referralSource}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GDPR Consent */}
|
||||
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="gdprConsent"
|
||||
checked={data.gdprConsent}
|
||||
onCheckedChange={(checked) => setValue('gdprConsent', checked === true)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="gdprConsent" className="text-sm font-medium">
|
||||
I consent to the processing of my personal data <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
By submitting this application, I agree that {programName} may process my personal
|
||||
data in accordance with their privacy policy. My data will be used solely for the
|
||||
purpose of evaluating my application and communicating with me about the program.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{errors.gdprConsent && (
|
||||
<p className="mt-2 text-sm text-destructive">{errors.gdprConsent.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,186 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { UseFormReturn, useFieldArray } from 'react-hook-form'
|
||||
import { Plus, Trash2, Users } from 'lucide-react'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import { TeamMemberRole } from '@prisma/client'
|
||||
import type { WizardConfig } from '@/types/wizard-config'
|
||||
|
||||
const roleOptions: { value: TeamMemberRole; label: string }[] = [
|
||||
{ value: 'MEMBER', label: 'Team Member' },
|
||||
{ value: 'ADVISOR', label: 'Advisor' },
|
||||
]
|
||||
|
||||
interface StepTeamProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepTeam({ form }: StepTeamProps) {
|
||||
const { control, register, formState: { errors } } = form
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'teamMembers',
|
||||
})
|
||||
|
||||
const addMember = () => {
|
||||
append({ name: '', email: '', role: 'MEMBER', title: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Your team members"
|
||||
description="Add the other members of your team. They will receive an invitation to create their account."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-lg space-y-6"
|
||||
>
|
||||
{fields.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 py-12 text-center">
|
||||
<Users className="mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">
|
||||
No team members added yet.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
You can add team members here, or skip this step if you're applying solo.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addMember}
|
||||
className="mt-4"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Team Member
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{fields.map((field, index) => (
|
||||
<motion.div
|
||||
key={field.id}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h4 className="font-medium text-sm text-muted-foreground">
|
||||
Team Member {index + 1}
|
||||
</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`teamMembers.${index}.name`}>
|
||||
Full Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`teamMembers.${index}.name`}
|
||||
placeholder="Jane Doe"
|
||||
{...register(`teamMembers.${index}.name`)}
|
||||
/>
|
||||
{errors.teamMembers?.[index]?.name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.teamMembers[index]?.name?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`teamMembers.${index}.email`}>
|
||||
Email <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`teamMembers.${index}.email`}
|
||||
type="email"
|
||||
placeholder="jane@example.com"
|
||||
{...register(`teamMembers.${index}.email`)}
|
||||
/>
|
||||
{errors.teamMembers?.[index]?.email && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.teamMembers[index]?.email?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
value={form.watch(`teamMembers.${index}.role`)}
|
||||
onValueChange={(value) =>
|
||||
form.setValue(`teamMembers.${index}.role`, value as TeamMemberRole)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roleOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Title/Position */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`teamMembers.${index}.title`}>
|
||||
Title/Position <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`teamMembers.${index}.title`}
|
||||
placeholder="CTO, Designer, etc."
|
||||
{...register(`teamMembers.${index}.title`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addMember}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Another Team Member
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { UseFormReturn, useFieldArray } from 'react-hook-form'
|
||||
import { Plus, Trash2, Users } from 'lucide-react'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import { TeamMemberRole } from '@prisma/client'
|
||||
import type { WizardConfig } from '@/types/wizard-config'
|
||||
|
||||
const roleOptions: { value: TeamMemberRole; label: string }[] = [
|
||||
{ value: 'MEMBER', label: 'Team Member' },
|
||||
{ value: 'ADVISOR', label: 'Advisor' },
|
||||
]
|
||||
|
||||
interface StepTeamProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
config?: WizardConfig
|
||||
}
|
||||
|
||||
export function StepTeam({ form }: StepTeamProps) {
|
||||
const { control, register, formState: { errors } } = form
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'teamMembers',
|
||||
})
|
||||
|
||||
const addMember = () => {
|
||||
append({ name: '', email: '', role: 'MEMBER', title: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Your team members"
|
||||
description="Add the other members of your team. They will receive an invitation to create their account."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-lg space-y-6"
|
||||
>
|
||||
{fields.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 py-12 text-center">
|
||||
<Users className="mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">
|
||||
No team members added yet.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
You can add team members here, or skip this step if you're applying solo.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addMember}
|
||||
className="mt-4"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Team Member
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{fields.map((field, index) => (
|
||||
<motion.div
|
||||
key={field.id}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h4 className="font-medium text-sm text-muted-foreground">
|
||||
Team Member {index + 1}
|
||||
</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`teamMembers.${index}.name`}>
|
||||
Full Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`teamMembers.${index}.name`}
|
||||
placeholder="Jane Doe"
|
||||
{...register(`teamMembers.${index}.name`)}
|
||||
/>
|
||||
{errors.teamMembers?.[index]?.name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.teamMembers[index]?.name?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`teamMembers.${index}.email`}>
|
||||
Email <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`teamMembers.${index}.email`}
|
||||
type="email"
|
||||
placeholder="jane@example.com"
|
||||
{...register(`teamMembers.${index}.email`)}
|
||||
/>
|
||||
{errors.teamMembers?.[index]?.email && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.teamMembers[index]?.email?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
value={form.watch(`teamMembers.${index}.role`)}
|
||||
onValueChange={(value) =>
|
||||
form.setValue(`teamMembers.${index}.role`, value as TeamMemberRole)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roleOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Title/Position */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`teamMembers.${index}.title`}>
|
||||
Title/Position <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`teamMembers.${index}.title`}
|
||||
placeholder="CTO, Designer, etc."
|
||||
{...register(`teamMembers.${index}.title`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addMember}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Another Team Member
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,119 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Waves, Rocket, GraduationCap, type LucideIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { type DropdownOption, type WelcomeMessage, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
GraduationCap,
|
||||
Rocket,
|
||||
}
|
||||
|
||||
interface StepWelcomeProps {
|
||||
programName: string
|
||||
programYear: number
|
||||
value: string | null
|
||||
onChange: (value: string) => void
|
||||
categories?: DropdownOption[]
|
||||
welcomeMessage?: WelcomeMessage
|
||||
}
|
||||
|
||||
export function StepWelcome({ programName, programYear, value, onChange, categories, welcomeMessage }: StepWelcomeProps) {
|
||||
const categoryOptions = categories ?? DEFAULT_COMPETITION_CATEGORIES
|
||||
|
||||
return (
|
||||
<WizardStepContent>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Logo/Icon */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', duration: 0.8 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
|
||||
<Waves className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Welcome text */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
|
||||
{welcomeMessage?.title ?? programName}
|
||||
</h1>
|
||||
<p className="mt-2 text-xl text-primary font-semibold">
|
||||
{programYear} Application
|
||||
</p>
|
||||
<p className="mt-4 max-w-md text-muted-foreground">
|
||||
{welcomeMessage?.description ?? 'Join us in protecting our oceans. Select your category to begin.'}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Category selection */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="mt-10 grid w-full max-w-2xl gap-4 md:grid-cols-2"
|
||||
>
|
||||
{categoryOptions.map((category) => {
|
||||
const Icon = (category.icon ? ICON_MAP[category.icon] : undefined) ?? Waves
|
||||
const isSelected = value === category.value
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.value}
|
||||
type="button"
|
||||
onClick={() => onChange(category.value)}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center rounded-xl border-2 p-6 text-center transition-all hover:border-primary/50 hover:shadow-md',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 shadow-md'
|
||||
: 'border-border bg-background'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mb-4 flex h-14 w-14 items-center justify-center rounded-full transition-colors',
|
||||
isSelected ? 'bg-primary text-primary-foreground' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{category.label}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{category.description}
|
||||
</p>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
layoutId="selected-indicator"
|
||||
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Waves, Rocket, GraduationCap, type LucideIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { type DropdownOption, type WelcomeMessage, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
GraduationCap,
|
||||
Rocket,
|
||||
}
|
||||
|
||||
interface StepWelcomeProps {
|
||||
programName: string
|
||||
programYear: number
|
||||
value: string | null
|
||||
onChange: (value: string) => void
|
||||
categories?: DropdownOption[]
|
||||
welcomeMessage?: WelcomeMessage
|
||||
}
|
||||
|
||||
export function StepWelcome({ programName, programYear, value, onChange, categories, welcomeMessage }: StepWelcomeProps) {
|
||||
const categoryOptions = categories ?? DEFAULT_COMPETITION_CATEGORIES
|
||||
|
||||
return (
|
||||
<WizardStepContent>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Logo/Icon */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', duration: 0.8 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
|
||||
<Waves className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Welcome text */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
|
||||
{welcomeMessage?.title ?? programName}
|
||||
</h1>
|
||||
<p className="mt-2 text-xl text-primary font-semibold">
|
||||
{programYear} Application
|
||||
</p>
|
||||
<p className="mt-4 max-w-md text-muted-foreground">
|
||||
{welcomeMessage?.description ?? 'Join us in protecting our oceans. Select your category to begin.'}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Category selection */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="mt-10 grid w-full max-w-2xl gap-4 md:grid-cols-2"
|
||||
>
|
||||
{categoryOptions.map((category) => {
|
||||
const Icon = (category.icon ? ICON_MAP[category.icon] : undefined) ?? Waves
|
||||
const isSelected = value === category.value
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.value}
|
||||
type="button"
|
||||
onClick={() => onChange(category.value)}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center rounded-xl border-2 p-6 text-center transition-all hover:border-primary/50 hover:shadow-md',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 shadow-md'
|
||||
: 'border-border bg-background'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mb-4 flex h-14 w-14 items-center justify-center rounded-full transition-colors',
|
||||
isSelected ? 'bg-primary text-primary-foreground' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{category.label}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{category.description}
|
||||
</p>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
layoutId="selected-indicator"
|
||||
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,489 +1,489 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Database,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NotionImportFormProps {
|
||||
programId: string
|
||||
stageName?: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
|
||||
|
||||
export function NotionImportForm({
|
||||
programId,
|
||||
stageName,
|
||||
onSuccess,
|
||||
}: NotionImportFormProps) {
|
||||
const [step, setStep] = useState<Step>('connect')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [databaseId, setDatabaseId] = useState('')
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null)
|
||||
|
||||
// Mapping state
|
||||
const [mappings, setMappings] = useState({
|
||||
title: '',
|
||||
teamName: '',
|
||||
description: '',
|
||||
tags: '',
|
||||
})
|
||||
const [includeUnmapped, setIncludeUnmapped] = useState(true)
|
||||
|
||||
// Results
|
||||
const [importResults, setImportResults] = useState<{
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: Array<{ recordId: string; error: string }>
|
||||
} | null>(null)
|
||||
|
||||
const testConnection = trpc.notionImport.testConnection.useMutation()
|
||||
const { data: schema, refetch: refetchSchema } =
|
||||
trpc.notionImport.getDatabaseSchema.useQuery(
|
||||
{ apiKey, databaseId },
|
||||
{ enabled: false }
|
||||
)
|
||||
const { data: preview, refetch: refetchPreview } =
|
||||
trpc.notionImport.previewData.useQuery(
|
||||
{ apiKey, databaseId, limit: 5 },
|
||||
{ enabled: false }
|
||||
)
|
||||
const importMutation = trpc.notionImport.importProjects.useMutation()
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!apiKey || !databaseId) {
|
||||
toast.error('Please enter both API key and database ID')
|
||||
return
|
||||
}
|
||||
|
||||
setIsConnecting(true)
|
||||
setConnectionError(null)
|
||||
|
||||
try {
|
||||
const result = await testConnection.mutateAsync({ apiKey })
|
||||
if (!result.success) {
|
||||
setConnectionError(result.error || 'Connection failed')
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch schema
|
||||
await refetchSchema()
|
||||
setStep('map')
|
||||
} catch (error) {
|
||||
setConnectionError(
|
||||
error instanceof Error ? error.message : 'Connection failed'
|
||||
)
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!mappings.title) {
|
||||
toast.error('Please map the Title field')
|
||||
return
|
||||
}
|
||||
|
||||
await refetchPreview()
|
||||
setStep('preview')
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
setStep('import')
|
||||
|
||||
try {
|
||||
const result = await importMutation.mutateAsync({
|
||||
apiKey,
|
||||
databaseId,
|
||||
programId,
|
||||
mappings: {
|
||||
title: mappings.title,
|
||||
teamName: mappings.teamName || undefined,
|
||||
description: mappings.description || undefined,
|
||||
tags: mappings.tags || undefined,
|
||||
},
|
||||
includeUnmappedInMetadata: includeUnmapped,
|
||||
})
|
||||
|
||||
setImportResults(result)
|
||||
setStep('complete')
|
||||
|
||||
if (result.imported > 0) {
|
||||
toast.success(`Imported ${result.imported} projects`)
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Import failed'
|
||||
)
|
||||
setStep('preview')
|
||||
}
|
||||
}
|
||||
|
||||
const properties = schema?.properties || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{['connect', 'map', 'preview', 'import', 'complete'].map((s, i) => (
|
||||
<div key={s} className="flex items-center">
|
||||
{i > 0 && <div className="w-8 h-0.5 bg-muted mx-1" />}
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
step === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: ['connect', 'map', 'preview', 'import', 'complete'].indexOf(step) > i
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Connect */}
|
||||
{step === 'connect' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Connect to Notion
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your Notion API key and database ID to connect
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">Notion API Key</Label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
placeholder="secret_..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create an integration at{' '}
|
||||
<a
|
||||
href="https://www.notion.so/my-integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
notion.so/my-integrations
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="databaseId">Database ID</Label>
|
||||
<Input
|
||||
id="databaseId"
|
||||
placeholder="abc123..."
|
||||
value={databaseId}
|
||||
onChange={(e) => setDatabaseId(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The ID from your Notion database URL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{connectionError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Connection Failed</AlertTitle>
|
||||
<AlertDescription>{connectionError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !apiKey || !databaseId}
|
||||
>
|
||||
{isConnecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Connect
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Map columns */}
|
||||
{step === 'map' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Map Columns</CardTitle>
|
||||
<CardDescription>
|
||||
Map Notion properties to project fields. Database: {schema?.title}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Title <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={mappings.title}
|
||||
onValueChange={(v) => setMappings((m) => ({ ...m, title: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select property" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{properties.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.name}{' '}
|
||||
<span className="text-muted-foreground">({p.type})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Team Name</Label>
|
||||
<Select
|
||||
value={mappings.teamName}
|
||||
onValueChange={(v) =>
|
||||
setMappings((m) => ({ ...m, teamName: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select property (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{properties.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Select
|
||||
value={mappings.description}
|
||||
onValueChange={(v) =>
|
||||
setMappings((m) => ({ ...m, description: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select property (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{properties.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
<Select
|
||||
value={mappings.tags}
|
||||
onValueChange={(v) => setMappings((m) => ({ ...m, tags: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select property (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{properties
|
||||
.filter((p) => p.type === 'multi_select' || p.type === 'select')
|
||||
.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="includeUnmapped"
|
||||
checked={includeUnmapped}
|
||||
onCheckedChange={(c) => setIncludeUnmapped(!!c)}
|
||||
/>
|
||||
<Label htmlFor="includeUnmapped" className="font-normal">
|
||||
Store unmapped columns in metadata
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('connect')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handlePreview} disabled={!mappings.title}>
|
||||
Preview
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Preview */}
|
||||
{step === 'preview' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview Import</CardTitle>
|
||||
<CardDescription>
|
||||
Review the first {preview?.count || 0} records before importing
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Title</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Team</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview?.records.map((record, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="px-4 py-2">
|
||||
{String(record.properties[mappings.title] || '-')}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{mappings.teamName
|
||||
? String(record.properties[mappings.teamName] || '-')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{mappings.tags && record.properties[mappings.tags]
|
||||
? (
|
||||
record.properties[mappings.tags] as string[]
|
||||
).map((tag, j) => (
|
||||
<Badge key={j} variant="secondary" className="mr-1">
|
||||
{tag}
|
||||
</Badge>
|
||||
))
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Ready to import</AlertTitle>
|
||||
<AlertDescription>
|
||||
This will import all records from the Notion database into{' '}
|
||||
<strong>{stageName}</strong>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('map')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleImport}>
|
||||
Import All Records
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 4: Importing */}
|
||||
{step === 'import' && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
|
||||
<p className="text-lg font-medium">Importing projects...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please wait while we import your data from Notion
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 5: Complete */}
|
||||
{step === 'complete' && importResults && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
||||
<p className="text-lg font-medium">Import Complete</p>
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{importResults.imported}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">projects imported</p>
|
||||
</div>
|
||||
{importResults.skipped > 0 && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{importResults.skipped} records skipped
|
||||
</p>
|
||||
)}
|
||||
{importResults.errors.length > 0 && (
|
||||
<div className="mt-4 w-full max-w-md">
|
||||
<p className="text-sm font-medium text-destructive mb-2">
|
||||
Errors ({importResults.errors.length}):
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto text-xs text-muted-foreground">
|
||||
{importResults.errors.slice(0, 5).map((e, i) => (
|
||||
<p key={i}>
|
||||
{e.recordId}: {e.error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Database,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NotionImportFormProps {
|
||||
programId: string
|
||||
stageName?: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
|
||||
|
||||
export function NotionImportForm({
|
||||
programId,
|
||||
stageName,
|
||||
onSuccess,
|
||||
}: NotionImportFormProps) {
|
||||
const [step, setStep] = useState<Step>('connect')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [databaseId, setDatabaseId] = useState('')
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null)
|
||||
|
||||
// Mapping state
|
||||
const [mappings, setMappings] = useState({
|
||||
title: '',
|
||||
teamName: '',
|
||||
description: '',
|
||||
tags: '',
|
||||
})
|
||||
const [includeUnmapped, setIncludeUnmapped] = useState(true)
|
||||
|
||||
// Results
|
||||
const [importResults, setImportResults] = useState<{
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: Array<{ recordId: string; error: string }>
|
||||
} | null>(null)
|
||||
|
||||
const testConnection = trpc.notionImport.testConnection.useMutation()
|
||||
const { data: schema, refetch: refetchSchema } =
|
||||
trpc.notionImport.getDatabaseSchema.useQuery(
|
||||
{ apiKey, databaseId },
|
||||
{ enabled: false }
|
||||
)
|
||||
const { data: preview, refetch: refetchPreview } =
|
||||
trpc.notionImport.previewData.useQuery(
|
||||
{ apiKey, databaseId, limit: 5 },
|
||||
{ enabled: false }
|
||||
)
|
||||
const importMutation = trpc.notionImport.importProjects.useMutation()
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!apiKey || !databaseId) {
|
||||
toast.error('Please enter both API key and database ID')
|
||||
return
|
||||
}
|
||||
|
||||
setIsConnecting(true)
|
||||
setConnectionError(null)
|
||||
|
||||
try {
|
||||
const result = await testConnection.mutateAsync({ apiKey })
|
||||
if (!result.success) {
|
||||
setConnectionError(result.error || 'Connection failed')
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch schema
|
||||
await refetchSchema()
|
||||
setStep('map')
|
||||
} catch (error) {
|
||||
setConnectionError(
|
||||
error instanceof Error ? error.message : 'Connection failed'
|
||||
)
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!mappings.title) {
|
||||
toast.error('Please map the Title field')
|
||||
return
|
||||
}
|
||||
|
||||
await refetchPreview()
|
||||
setStep('preview')
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
setStep('import')
|
||||
|
||||
try {
|
||||
const result = await importMutation.mutateAsync({
|
||||
apiKey,
|
||||
databaseId,
|
||||
programId,
|
||||
mappings: {
|
||||
title: mappings.title,
|
||||
teamName: mappings.teamName || undefined,
|
||||
description: mappings.description || undefined,
|
||||
tags: mappings.tags || undefined,
|
||||
},
|
||||
includeUnmappedInMetadata: includeUnmapped,
|
||||
})
|
||||
|
||||
setImportResults(result)
|
||||
setStep('complete')
|
||||
|
||||
if (result.imported > 0) {
|
||||
toast.success(`Imported ${result.imported} projects`)
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Import failed'
|
||||
)
|
||||
setStep('preview')
|
||||
}
|
||||
}
|
||||
|
||||
const properties = schema?.properties || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{['connect', 'map', 'preview', 'import', 'complete'].map((s, i) => (
|
||||
<div key={s} className="flex items-center">
|
||||
{i > 0 && <div className="w-8 h-0.5 bg-muted mx-1" />}
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
step === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: ['connect', 'map', 'preview', 'import', 'complete'].indexOf(step) > i
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Connect */}
|
||||
{step === 'connect' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Connect to Notion
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your Notion API key and database ID to connect
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">Notion API Key</Label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
placeholder="secret_..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create an integration at{' '}
|
||||
<a
|
||||
href="https://www.notion.so/my-integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
notion.so/my-integrations
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="databaseId">Database ID</Label>
|
||||
<Input
|
||||
id="databaseId"
|
||||
placeholder="abc123..."
|
||||
value={databaseId}
|
||||
onChange={(e) => setDatabaseId(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The ID from your Notion database URL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{connectionError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Connection Failed</AlertTitle>
|
||||
<AlertDescription>{connectionError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !apiKey || !databaseId}
|
||||
>
|
||||
{isConnecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Connect
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Map columns */}
|
||||
{step === 'map' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Map Columns</CardTitle>
|
||||
<CardDescription>
|
||||
Map Notion properties to project fields. Database: {schema?.title}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Title <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={mappings.title}
|
||||
onValueChange={(v) => setMappings((m) => ({ ...m, title: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select property" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{properties.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.name}{' '}
|
||||
<span className="text-muted-foreground">({p.type})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Team Name</Label>
|
||||
<Select
|
||||
value={mappings.teamName}
|
||||
onValueChange={(v) =>
|
||||
setMappings((m) => ({ ...m, teamName: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select property (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{properties.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Select
|
||||
value={mappings.description}
|
||||
onValueChange={(v) =>
|
||||
setMappings((m) => ({ ...m, description: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select property (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{properties.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
<Select
|
||||
value={mappings.tags}
|
||||
onValueChange={(v) => setMappings((m) => ({ ...m, tags: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select property (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{properties
|
||||
.filter((p) => p.type === 'multi_select' || p.type === 'select')
|
||||
.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="includeUnmapped"
|
||||
checked={includeUnmapped}
|
||||
onCheckedChange={(c) => setIncludeUnmapped(!!c)}
|
||||
/>
|
||||
<Label htmlFor="includeUnmapped" className="font-normal">
|
||||
Store unmapped columns in metadata
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('connect')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handlePreview} disabled={!mappings.title}>
|
||||
Preview
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Preview */}
|
||||
{step === 'preview' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview Import</CardTitle>
|
||||
<CardDescription>
|
||||
Review the first {preview?.count || 0} records before importing
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Title</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Team</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview?.records.map((record, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="px-4 py-2">
|
||||
{String(record.properties[mappings.title] || '-')}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{mappings.teamName
|
||||
? String(record.properties[mappings.teamName] || '-')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{mappings.tags && record.properties[mappings.tags]
|
||||
? (
|
||||
record.properties[mappings.tags] as string[]
|
||||
).map((tag, j) => (
|
||||
<Badge key={j} variant="secondary" className="mr-1">
|
||||
{tag}
|
||||
</Badge>
|
||||
))
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Ready to import</AlertTitle>
|
||||
<AlertDescription>
|
||||
This will import all records from the Notion database into{' '}
|
||||
<strong>{stageName}</strong>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('map')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleImport}>
|
||||
Import All Records
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 4: Importing */}
|
||||
{step === 'import' && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
|
||||
<p className="text-lg font-medium">Importing projects...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please wait while we import your data from Notion
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 5: Complete */}
|
||||
{step === 'complete' && importResults && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
||||
<p className="text-lg font-medium">Import Complete</p>
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{importResults.imported}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">projects imported</p>
|
||||
</div>
|
||||
{importResults.skipped > 0 && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{importResults.skipped} records skipped
|
||||
</p>
|
||||
)}
|
||||
{importResults.errors.length > 0 && (
|
||||
<div className="mt-4 w-full max-w-md">
|
||||
<p className="text-sm font-medium text-destructive mb-2">
|
||||
Errors ({importResults.errors.length}):
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto text-xs text-muted-foreground">
|
||||
{importResults.errors.slice(0, 5).map((e, i) => (
|
||||
<p key={i}>
|
||||
{e.recordId}: {e.error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user