Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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&apos;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&apos;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>
)
}

View File

@@ -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>
)
}

View File

@@ -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&apos;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&apos;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>
)
}

View File

@@ -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

View File

@@ -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