← back to IWasCute
src/components/licensor/CopyrightInfo.tsx
535 lines
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown, ChevronLeft, ChevronRight, Info } from 'lucide-react'
import clsx from 'clsx'
import type { PhotoFile } from './PhotoUpload'
export interface CopyrightData {
photographer: string
relationship: string
year: string
hasPermission: string
estateRights: boolean
applyToAll: boolean
}
interface CopyrightInfoProps {
photos: PhotoFile[]
copyrightInfo: CopyrightData
onCopyrightChange: (info: CopyrightData) => void
onNext: () => void
onBack: () => void
}
const RELATIONSHIP_OPTIONS = [
{ value: '', label: 'Select who took this photo' },
{ value: 'parent', label: 'My parent' },
{ value: 'family', label: 'Other family member' },
{ value: 'professional', label: 'Professional photographer' },
{ value: 'unknown', label: "I don't know" },
]
const PERMISSION_OPTIONS = [
{ value: '', label: 'Select an option' },
{ value: 'yes', label: 'Yes, I have permission' },
{ value: 'no', label: 'No, I do not have explicit permission' },
{ value: 'deceased', label: 'The photographer is deceased' },
]
const CURRENT_YEAR = new Date().getFullYear()
function SelectField({
label,
value,
options,
onChange,
required,
id,
}: {
label: string
value: string
options: { value: string; label: string }[]
onChange: (v: string) => void
required?: boolean
id: string
}) {
return (
<div className="flex flex-col gap-1.5">
<label
htmlFor={id}
className="text-sm font-semibold tracking-wide"
style={{ color: 'var(--color-brown)' }}
>
{label}
{required && (
<span className="ml-1" style={{ color: 'var(--color-peach-dark)' }} aria-hidden>
*
</span>
)}
</label>
<div className="relative">
<select
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
className={clsx(
'w-full appearance-none px-4 py-3 pr-10 rounded-2xl text-sm',
'border outline-none transition-all duration-200',
'focus:ring-2'
)}
style={{
background: 'rgba(255,255,255,0.8)',
borderColor: 'var(--color-cream-dark)',
color: value ? 'var(--color-brown)' : 'var(--color-warm-gray)',
}}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value} disabled={opt.value === ''}>
{opt.label}
</option>
))}
</select>
<ChevronDown
size={16}
className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: 'var(--color-warm-gray)' }}
/>
</div>
</div>
)
}
function TextField({
label,
value,
onChange,
placeholder,
type = 'text',
min,
max,
id,
optional,
}: {
label: string
value: string
onChange: (v: string) => void
placeholder?: string
type?: string
min?: number
max?: number
id: string
optional?: boolean
}) {
return (
<div className="flex flex-col gap-1.5">
<label
htmlFor={id}
className="text-sm font-semibold tracking-wide"
style={{ color: 'var(--color-brown)' }}
>
{label}
{optional && (
<span className="ml-1.5 text-xs font-normal" style={{ color: 'var(--color-warm-gray)' }}>
(optional)
</span>
)}
</label>
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
min={min}
max={max}
className={clsx(
'w-full px-4 py-3 rounded-2xl text-sm',
'border outline-none transition-all duration-200',
'focus:ring-2'
)}
style={{
background: 'rgba(255,255,255,0.8)',
borderColor: 'var(--color-cream-dark)',
color: 'var(--color-brown)',
}}
/>
</div>
)
}
function CopyrightForm({
data,
onChange,
photoIndex,
totalPhotos,
}: {
data: CopyrightData
onChange: (patch: Partial<CopyrightData>) => void
photoIndex?: number
totalPhotos?: number
}) {
const idSuffix = photoIndex !== undefined ? `-${photoIndex}` : '-all'
return (
<div className="flex flex-col gap-5">
{photoIndex !== undefined && totalPhotos !== undefined && totalPhotos > 1 && (
<p
className="text-sm font-medium"
style={{ color: 'var(--color-warm-gray)' }}
>
Photo {photoIndex + 1} of {totalPhotos}
</p>
)}
<SelectField
id={`relationship${idSuffix}`}
label="Who took this photo?"
value={data.relationship}
options={RELATIONSHIP_OPTIONS}
onChange={(v) => onChange({ relationship: v })}
required
/>
<AnimatePresence>
{data.relationship !== 'unknown' && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<TextField
id={`photographer${idSuffix}`}
label="Photographer name"
value={data.photographer}
onChange={(v) => onChange({ photographer: v })}
placeholder="e.g. Mom, Grandpa Joe, Jane Smith Photography"
optional
/>
</motion.div>
)}
</AnimatePresence>
<TextField
id={`year${idSuffix}`}
label="Approximate year photo was taken"
value={data.year}
onChange={(v) => onChange({ year: v })}
type="number"
placeholder={`e.g. ${CURRENT_YEAR - 25}`}
min={1900}
max={CURRENT_YEAR}
/>
<SelectField
id={`permission${idSuffix}`}
label="Do you have permission from the photographer?"
value={data.hasPermission}
options={PERMISSION_OPTIONS}
onChange={(v) => onChange({ hasPermission: v })}
required
/>
<AnimatePresence>
{data.hasPermission === 'deceased' && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<label
className="flex items-start gap-3 cursor-pointer group"
htmlFor={`estate${idSuffix}`}
>
<div className="relative mt-0.5">
<input
id={`estate${idSuffix}`}
type="checkbox"
checked={data.estateRights}
onChange={(e) => onChange({ estateRights: e.target.checked })}
className="sr-only"
/>
<div
className={clsx(
'w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all',
data.estateRights ? 'border-transparent' : ''
)}
style={{
background: data.estateRights ? 'var(--color-sage)' : 'white',
borderColor: data.estateRights ? 'var(--color-sage)' : 'var(--color-cream-dark)',
}}
aria-hidden
>
{data.estateRights && (
<svg width="11" height="11" viewBox="0 0 11 11" fill="none">
<path
d="M2 5.5l2.5 2.5L9 3"
stroke="white"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
</div>
<div>
<p
className="text-sm font-semibold"
style={{ color: 'var(--color-brown)' }}
>
I have evidence of estate rights
</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--color-warm-gray)' }}>
e.g. will, inheritance document, or written permission from the estate executor
</p>
</div>
</label>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export default function CopyrightInfo({
photos,
copyrightInfo,
onCopyrightChange,
onNext,
onBack,
}: CopyrightInfoProps) {
const [activePhotoIndex, setActivePhotoIndex] = useState(0)
const multiPhoto = photos.length > 1
// Per-photo overrides (index → CopyrightData)
const [perPhoto, setPerPhoto] = useState<Record<number, Partial<CopyrightData>>>({})
const handleBatchChange = (patch: Partial<CopyrightData>) => {
onCopyrightChange({ ...copyrightInfo, ...patch })
}
const handlePerPhotoChange = (index: number, patch: Partial<CopyrightData>) => {
setPerPhoto((prev) => ({
...prev,
[index]: { ...(prev[index] ?? {}), ...patch },
}))
}
const getPhotoData = (index: number): CopyrightData => ({
...copyrightInfo,
...(perPhoto[index] ?? {}),
})
const canProceed =
copyrightInfo.relationship !== '' && copyrightInfo.hasPermission !== ''
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div>
<h2
className="text-2xl md:text-3xl font-bold tracking-tight"
style={{ color: 'var(--color-brown)' }}
>
Copyright Information
</h2>
<p className="mt-2 text-base" style={{ color: 'var(--color-warm-gray)' }}>
Help us understand who owns the copyright to your photos.
</p>
</div>
{/* Info banner */}
<div
className="flex items-start gap-3 p-4 rounded-2xl"
style={{ background: 'rgba(181, 213, 197, 0.25)', border: '1px solid var(--color-sage)' }}
role="note"
>
<Info size={16} className="mt-0.5 shrink-0" style={{ color: 'var(--color-sage-dark)' }} />
<p className="text-sm leading-relaxed" style={{ color: 'var(--color-warm-gray)' }}>
In most cases, the person who pressed the shutter button owns the copyright — not the
person in the photo. We need this information to ensure your uploads are compliant.
</p>
</div>
{/* Apply-to-all toggle */}
{multiPhoto && (
<label
className="flex items-center gap-3 cursor-pointer"
htmlFor="apply-all"
>
<div className="relative">
<input
id="apply-all"
type="checkbox"
checked={copyrightInfo.applyToAll}
onChange={(e) => handleBatchChange({ applyToAll: e.target.checked })}
className="sr-only"
/>
<div
className="w-10 h-6 rounded-full transition-colors relative"
style={{
background: copyrightInfo.applyToAll
? 'var(--color-peach)'
: 'var(--color-cream-dark)',
}}
aria-hidden
>
<div
className="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-all duration-200"
style={{
left: copyrightInfo.applyToAll ? '1.25rem' : '0.25rem',
}}
/>
</div>
</div>
<div>
<p
className="text-sm font-semibold"
style={{ color: 'var(--color-brown)' }}
>
Apply same info to all {photos.length} photos
</p>
<p className="text-xs" style={{ color: 'var(--color-warm-gray)' }}>
Toggle off to fill copyright info per photo
</p>
</div>
</label>
)}
{/* Form */}
<div
className="p-6 rounded-3xl"
style={{ background: 'rgba(255,255,255,0.6)', border: '1px solid var(--color-cream-dark)' }}
>
{!multiPhoto || copyrightInfo.applyToAll ? (
<CopyrightForm
data={copyrightInfo}
onChange={handleBatchChange}
totalPhotos={photos.length}
/>
) : (
<div className="flex flex-col gap-6">
{/* Per-photo nav */}
<div className="flex items-center justify-between">
<button
onClick={() => setActivePhotoIndex((i) => Math.max(0, i - 1))}
disabled={activePhotoIndex === 0}
className="flex items-center gap-1 text-sm font-medium px-3 py-2 rounded-xl transition-all disabled:opacity-30"
style={{ color: 'var(--color-warm-gray)' }}
aria-label="Previous photo"
>
<ChevronLeft size={16} />
Previous
</button>
{/* Thumbnail strip */}
<div className="flex gap-2">
{photos.map((photo, i) => (
<button
key={photo.id}
onClick={() => setActivePhotoIndex(i)}
className={clsx(
'w-10 h-10 rounded-xl overflow-hidden border-2 transition-all',
i === activePhotoIndex
? 'scale-110'
: 'opacity-60 hover:opacity-80'
)}
style={{
borderColor:
i === activePhotoIndex
? 'var(--color-peach)'
: 'transparent',
}}
aria-label={`Select photo ${i + 1}`}
aria-pressed={i === activePhotoIndex}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.preview}
alt={`Photo ${i + 1}`}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
<button
onClick={() =>
setActivePhotoIndex((i) => Math.min(photos.length - 1, i + 1))
}
disabled={activePhotoIndex === photos.length - 1}
className="flex items-center gap-1 text-sm font-medium px-3 py-2 rounded-xl transition-all disabled:opacity-30"
style={{ color: 'var(--color-warm-gray)' }}
aria-label="Next photo"
>
Next
<ChevronRight size={16} />
</button>
</div>
{/* Active photo form */}
<AnimatePresence mode="wait">
<motion.div
key={activePhotoIndex}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
>
<CopyrightForm
data={getPhotoData(activePhotoIndex)}
onChange={(patch) => handlePerPhotoChange(activePhotoIndex, patch)}
photoIndex={activePhotoIndex}
totalPhotos={photos.length}
/>
</motion.div>
</AnimatePresence>
</div>
)}
</div>
{/* Navigation */}
<div className="flex items-center justify-between pt-2">
<button
onClick={onBack}
className="flex items-center gap-2 px-6 py-3 rounded-3xl text-sm font-semibold tracking-wide transition-all hover:scale-105"
style={{
background: 'var(--color-cream-dark)',
color: 'var(--color-warm-gray)',
}}
>
<ChevronLeft size={16} />
Back
</button>
<motion.button
whileHover={canProceed ? { scale: 1.03 } : {}}
whileTap={canProceed ? { scale: 0.97 } : {}}
onClick={onNext}
disabled={!canProceed}
className={clsx(
'flex items-center gap-2 px-8 py-3.5 rounded-3xl text-sm font-semibold tracking-wide',
'transition-all duration-200',
!canProceed && 'opacity-40 cursor-not-allowed'
)}
style={{
background: canProceed ? 'var(--color-peach)' : 'var(--color-cream-dark)',
color: 'var(--color-brown)',
}}
aria-disabled={!canProceed}
>
Continue to Likeness Release
<ChevronRight size={16} />
</motion.button>
</div>
</div>
)
}