refactor(ui): rebuild ProposalForm, CreateClient, ManageClient on shadcn/ui
ProposalForm: - Step tabs -> Radix Tabs (roving tabindex, arrow-key nav, proper ARIA) driven by the same `step` state as before. - Evasive-no toggle -> Switch, wrapped in a <label> so the whole row (not just the small control) is tappable. - All raw <input>/<textarea> -> Input/Textarea; buttonVariants 'primary' -> 'default' to match the new variant names. - New StepActions wrapper makes Back/Next/Publish sticky to the viewport bottom on mobile (safe-area aware) so the wizard never requires scrolling to find the next action; reverts to normal inline flow at sm+. CreateClient / ManageClient: - Card/Button/Input swapped to shadcn equivalents. - Copy-link feedback moved from a per-button `copied` boolean + setTimeout to a single sonner toast call, removing the duplicated state pattern. - ManageClient's delete confirmation moved from window.confirm() to an AlertDialog, matching the skill's accessible-primitives guidance and giving it proper focus trapping on mobile. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,11 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { toast } from 'sonner';
|
||||
import ProposalForm, { type ProposalFormData } from '@/components/ProposalForm';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { generateKey, encryptField, encryptOptional } from '@/lib/crypto-client';
|
||||
|
||||
interface Links {
|
||||
@@ -48,8 +51,6 @@ async function encryptPayload(key: string, data: ProposalFormData) {
|
||||
export default function CreateClient() {
|
||||
const [links, setLinks] = useState<Links | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [copiedPublic, setCopiedPublic] = useState(false);
|
||||
const [copiedManage, setCopiedManage] = useState(false);
|
||||
|
||||
async function handleSubmit(data: ProposalFormData, _publish: boolean) {
|
||||
setError('');
|
||||
@@ -71,19 +72,18 @@ export default function CreateClient() {
|
||||
});
|
||||
}
|
||||
|
||||
function copyLink(url: string, which: 'public' | 'manage') {
|
||||
function copyLink(url: string) {
|
||||
navigator.clipboard.writeText(url);
|
||||
if (which === 'public') { setCopiedPublic(true); setTimeout(() => setCopiedPublic(false), 2000); }
|
||||
else { setCopiedManage(true); setTimeout(() => setCopiedManage(false), 2000); }
|
||||
toast.success('Link copied');
|
||||
}
|
||||
|
||||
if (links) {
|
||||
return (
|
||||
<Card className="rounded-3xl shadow-2xl p-8 text-center space-y-6">
|
||||
<Card className="rounded-3xl shadow-2xl p-6 sm:p-8 text-center space-y-6">
|
||||
<div className="text-5xl">🎉</div>
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-extrabold text-gray-900">Your proposal is ready!</h2>
|
||||
<p className="text-gray-500 mt-1">Share the first link, keep the second one private.</p>
|
||||
<h2 className="font-display text-2xl font-extrabold text-foreground">Your proposal is ready!</h2>
|
||||
<p className="text-muted-foreground mt-1">Share the first link, keep the second one private.</p>
|
||||
</div>
|
||||
|
||||
<div className="text-left space-y-4">
|
||||
@@ -92,8 +92,7 @@ export default function CreateClient() {
|
||||
label="Share with them"
|
||||
sublabel="Send this link to the person you're asking out"
|
||||
url={links.publicUrl}
|
||||
copied={copiedPublic}
|
||||
onCopy={() => copyLink(links.publicUrl, 'public')}
|
||||
onCopy={() => copyLink(links.publicUrl)}
|
||||
highlight
|
||||
/>
|
||||
<LinkBox
|
||||
@@ -101,14 +100,13 @@ export default function CreateClient() {
|
||||
label="Your private management link"
|
||||
sublabel="Bookmark this: it lets you see their response and delete the proposal"
|
||||
url={links.manageUrl}
|
||||
copied={copiedManage}
|
||||
onCopy={() => copyLink(links.manageUrl, 'manage')}
|
||||
onCopy={() => copyLink(links.manageUrl)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Want to create another one?{' '}
|
||||
<Link href="/create" className="text-pink-600 font-semibold hover:underline">
|
||||
<Link href="/create" className="text-primary font-semibold hover:underline">
|
||||
Make a new proposal →
|
||||
</Link>
|
||||
</p>
|
||||
@@ -120,38 +118,31 @@ export default function CreateClient() {
|
||||
}
|
||||
|
||||
function LinkBox({
|
||||
emoji, label, sublabel, url, copied, onCopy, highlight,
|
||||
emoji, label, sublabel, url, onCopy, highlight,
|
||||
}: {
|
||||
emoji: string; label: string; sublabel: string; url: string;
|
||||
copied: boolean; onCopy: () => void; highlight?: boolean;
|
||||
onCopy: () => void; highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-2xl p-4 border-2 ${highlight ? 'border-pink-300 bg-pink-50' : 'border-gray-100 bg-gray-50'}`}>
|
||||
<div className={`rounded-2xl p-4 border-2 ${highlight ? 'border-primary/40 bg-accent' : 'border-border bg-secondary'}`}>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-2xl">{emoji}</span>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-sm">{label}</p>
|
||||
<p className="text-xs text-gray-500">{sublabel}</p>
|
||||
<p className="font-semibold text-foreground text-sm">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{sublabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
<Input
|
||||
readOnly value={url} aria-label={label}
|
||||
className="flex-1 text-xs bg-white border border-gray-200 rounded-xl px-3 py-2 text-gray-600 min-w-0
|
||||
focus:border-pink-400 focus:ring-2 focus:ring-pink-100 outline-none transition"
|
||||
className="h-10 flex-1 text-xs bg-background min-w-0"
|
||||
/>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className={`shrink-0 px-3 py-2 rounded-xl text-sm font-semibold transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pink-300 ${
|
||||
copied ? 'bg-green-100 text-green-700' : 'bg-gray-100 hover:bg-gray-200 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span aria-live="polite">{copied ? '✓ Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
<Button type="button" variant="secondary" onClick={onCopy} className="shrink-0 h-10">
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer"
|
||||
className="block text-xs text-pink-600 hover:underline mt-1.5 truncate">
|
||||
className="block text-xs text-primary hover:underline mt-1.5 truncate py-0.5">
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,23 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import QRCode from 'qrcode';
|
||||
import { toast } from 'sonner';
|
||||
import { themes, getGradientStyle, formatSlotDate, type Theme } from '@/lib/themes';
|
||||
import { keyFromHash, decryptField, decryptOptional } from '@/lib/crypto-client';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { buttonVariants } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import CalendarButtons from '@/components/CalendarButtons';
|
||||
import type { Proposal, ActivityOption, TimeSlot, Response } from '@prisma/client';
|
||||
|
||||
@@ -46,8 +59,6 @@ export default function ManageClient({
|
||||
manageUrl: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [copiedPublic, setCopiedPublic] = useState(false);
|
||||
const [copiedManage, setCopiedManage] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [showQr, setShowQr] = useState(false);
|
||||
@@ -122,10 +133,9 @@ export default function ManageClient({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showQr, hash]);
|
||||
|
||||
function copy(url: string, which: 'public' | 'manage') {
|
||||
function copy(url: string) {
|
||||
navigator.clipboard.writeText(url);
|
||||
if (which === 'public') { setCopiedPublic(true); setTimeout(() => setCopiedPublic(false), 2000); }
|
||||
else { setCopiedManage(true); setTimeout(() => setCopiedManage(false), 2000); }
|
||||
toast.success('Link copied');
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
@@ -136,7 +146,6 @@ export default function ManageClient({
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Delete this proposal? This removes all data including the response.')) return;
|
||||
setDeleting(true);
|
||||
await fetch(`/api/manage/${token}`, { method: 'DELETE' });
|
||||
router.push('/');
|
||||
@@ -155,13 +164,13 @@ export default function ManageClient({
|
||||
|
||||
if (keyMissing) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl p-8 sm:p-10 text-center max-w-md w-full border border-gray-100">
|
||||
<div className="min-h-[100dvh] bg-background flex items-center justify-center px-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl p-8 sm:p-10 text-center max-w-md w-full border border-border">
|
||||
<div className="text-5xl mb-4">🔒</div>
|
||||
<h1 className="font-display text-2xl font-extrabold text-gray-900 mb-2">
|
||||
<h1 className="font-display text-2xl font-extrabold text-foreground mb-2">
|
||||
This link looks incomplete
|
||||
</h1>
|
||||
<p className="text-gray-500">
|
||||
<p className="text-muted-foreground">
|
||||
Make sure you copied the whole link, including everything after the “#”.
|
||||
</p>
|
||||
</div>
|
||||
@@ -171,23 +180,23 @@ export default function ManageClient({
|
||||
|
||||
if (!decrypted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div className="min-h-[100dvh] bg-background flex items-center justify-center px-4">
|
||||
<div className="text-6xl animate-pulse">💌</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-[100dvh] bg-background">
|
||||
{/* Header banner */}
|
||||
<div className={gradStyle.className ?? ''} style={gradStyle.style}>
|
||||
<div className="max-w-3xl mx-auto px-4 py-8 text-white">
|
||||
<div className="max-w-3xl mx-auto px-4 py-6 sm:py-8 text-white">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Link href="/" className="text-white/70 hover:text-white text-sm transition-colors">💌 lovelope.app</Link>
|
||||
<span className="text-white/40">›</span>
|
||||
<span className="text-sm font-semibold">Manage proposal</span>
|
||||
</div>
|
||||
<h1 className="font-display text-xl sm:text-2xl font-extrabold mb-1">{decrypted.title}</h1>
|
||||
<h1 className="font-display text-xl sm:text-2xl font-extrabold mb-1 text-balance">{decrypted.title}</h1>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-white/80 text-sm">For <strong>{decrypted.recipientName}</strong></span>
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${statusBadge[proposal.status]} bg-white/20 text-white`}>
|
||||
@@ -201,16 +210,16 @@ export default function ManageClient({
|
||||
{/* Response card */}
|
||||
{decrypted.response ? (
|
||||
<Card>
|
||||
<h2 className="font-display font-bold text-gray-900 mb-4">Response received</h2>
|
||||
<h2 className="font-display font-bold text-foreground mb-4">Response received</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-5xl">
|
||||
{answerEmoji[decrypted.response.answer as keyof typeof answerEmoji]}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-display text-2xl font-extrabold text-gray-900">
|
||||
<p className="font-display text-2xl font-extrabold text-foreground">
|
||||
{answerLabel[decrypted.response.answer as keyof typeof answerLabel]}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(proposal.response!.respondedAt).toLocaleDateString('en-US', {
|
||||
month: 'long', day: 'numeric', year: 'numeric',
|
||||
})}
|
||||
@@ -218,22 +227,22 @@ export default function ManageClient({
|
||||
</div>
|
||||
</div>
|
||||
{selectedActivity && (
|
||||
<div className="mt-4 bg-gray-50 rounded-xl p-4">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Chose</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
<div className="mt-4 bg-secondary rounded-xl p-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">Chose</p>
|
||||
<p className="font-semibold text-foreground">
|
||||
{selectedActivity.emoji} {selectedActivity.title}
|
||||
</p>
|
||||
{selectedSlot && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
🗓 {formatSlotDate(selectedSlot.startsAt, selectedSlot.label)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{decrypted.response.note && (
|
||||
<div className="mt-3 bg-gray-50 rounded-xl p-4">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Note</p>
|
||||
<p className="text-gray-700 italic">“{decrypted.response.note}”</p>
|
||||
<div className="mt-3 bg-secondary rounded-xl p-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">Note</p>
|
||||
<p className="text-foreground/90 italic">“{decrypted.response.note}”</p>
|
||||
</div>
|
||||
)}
|
||||
{calendarSlot && (
|
||||
@@ -247,8 +256,8 @@ export default function ManageClient({
|
||||
) : (
|
||||
<Card className="text-center">
|
||||
<div className="text-4xl mb-2">⏳</div>
|
||||
<p className="font-semibold text-gray-700">Waiting for a response</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
<p className="font-semibold text-foreground">Waiting for a response</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Share the public link with {decrypted.recipientName} to get started.
|
||||
</p>
|
||||
</Card>
|
||||
@@ -257,10 +266,10 @@ export default function ManageClient({
|
||||
{/* Links + QR */}
|
||||
<Card className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display font-bold text-gray-900">Your links</h2>
|
||||
<h2 className="font-display font-bold text-foreground">Your links</h2>
|
||||
<button
|
||||
onClick={() => setShowQr((v) => !v)}
|
||||
className="text-xs font-semibold text-gray-500 hover:text-pink-500 transition-colors flex items-center gap-1"
|
||||
className="text-xs font-semibold text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 py-1"
|
||||
>
|
||||
📱 {showQr ? 'Hide QR' : 'Show QR code'}
|
||||
</button>
|
||||
@@ -275,36 +284,30 @@ export default function ManageClient({
|
||||
alt="QR code for public proposal link"
|
||||
width={200}
|
||||
height={200}
|
||||
className="rounded-xl border border-gray-100 shadow-sm"
|
||||
className="rounded-xl border border-border shadow-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[200px] h-[200px] rounded-xl border border-gray-100 bg-gray-50 animate-pulse" />
|
||||
<div className="w-[200px] h-[200px] rounded-xl border border-border bg-secondary animate-pulse" />
|
||||
)}
|
||||
<p className="text-xs text-gray-400">Scan to open the proposal</p>
|
||||
<p className="text-xs text-muted-foreground">Scan to open the proposal</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{([
|
||||
{ emoji: '📤', label: 'Public link (share with them)', url: fullPublicUrl, which: 'public' as const, copied: copiedPublic },
|
||||
{ emoji: '🔒', label: 'Management link (keep private)', url: fullManageUrl, which: 'manage' as const, copied: copiedManage },
|
||||
] as const).map(({ emoji, label, url, which, copied }) => (
|
||||
{ emoji: '📤', label: 'Public link (share with them)', url: fullPublicUrl, which: 'public' as const },
|
||||
{ emoji: '🔒', label: 'Management link (keep private)', url: fullManageUrl, which: 'manage' as const },
|
||||
] as const).map(({ emoji, label, url, which }) => (
|
||||
<div key={which}>
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">{emoji} {label}</p>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">{emoji} {label}</p>
|
||||
<div className="flex gap-2">
|
||||
<input readOnly value={url} aria-label={label}
|
||||
className="flex-1 text-xs bg-gray-50 border border-gray-200 rounded-xl px-3 py-2.5 text-gray-600 min-w-0
|
||||
focus:border-pink-400 focus:ring-2 focus:ring-pink-100 outline-none transition" />
|
||||
<button onClick={() => copy(url, which)}
|
||||
className={`shrink-0 px-4 py-2.5 rounded-xl text-sm font-semibold transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pink-300 ${
|
||||
copied ? 'bg-green-100 text-green-700' : 'bg-gray-100 hover:bg-gray-200'
|
||||
}`}>
|
||||
<span aria-live="polite">{copied ? '✓' : 'Copy'}</span>
|
||||
</button>
|
||||
<Input readOnly value={url} aria-label={label} className="h-11 flex-1 text-xs bg-secondary min-w-0" />
|
||||
<Button type="button" variant="secondary" className="shrink-0 h-11" onClick={() => copy(url)}>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
{which === 'public' && (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-xs text-pink-600 hover:underline mt-1 block">
|
||||
className="text-xs text-primary hover:underline mt-1 block py-0.5">
|
||||
Preview →
|
||||
</a>
|
||||
)}
|
||||
@@ -314,16 +317,16 @@ export default function ManageClient({
|
||||
|
||||
{/* Activities summary */}
|
||||
<Card>
|
||||
<h2 className="font-display font-bold text-gray-900 mb-4">Activities ({decrypted.activities.length})</h2>
|
||||
<h2 className="font-display font-bold text-foreground mb-4">Activities ({decrypted.activities.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{decrypted.activities.map((a) => (
|
||||
<div key={a.id} className={`rounded-xl border p-3 flex items-start gap-3 ${
|
||||
proposal.response?.selectedActivityId === a.id ? 'border-green-300 bg-green-50' : 'border-gray-100'
|
||||
proposal.response?.selectedActivityId === a.id ? 'border-green-300 bg-green-50' : 'border-border'
|
||||
}`}>
|
||||
<span className="text-xl shrink-0">{a.emoji}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-gray-900 text-sm">{a.title}</p>
|
||||
{a.description && <p className="text-xs text-gray-500">{a.description}</p>}
|
||||
<p className="font-semibold text-foreground text-sm">{a.title}</p>
|
||||
{a.description && <p className="text-xs text-muted-foreground">{a.description}</p>}
|
||||
{a.slots.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{a.slots.map((s) => (
|
||||
@@ -331,7 +334,7 @@ export default function ManageClient({
|
||||
className={`text-xs px-2 py-0.5 rounded-lg font-medium ${
|
||||
proposal.response?.selectedTimeSlotId === s.id
|
||||
? 'bg-green-200 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
: 'bg-secondary text-muted-foreground'
|
||||
}`}>
|
||||
{formatSlotDate(s.startsAt, s.label)}
|
||||
</span>
|
||||
@@ -350,20 +353,40 @@ export default function ManageClient({
|
||||
{/* Actions */}
|
||||
<Card className="space-y-3">
|
||||
{proposal.status === 'draft' && (
|
||||
<button onClick={handlePublish} disabled={publishing}
|
||||
className={buttonVariants({ size: 'lg', className: 'w-full' })}>
|
||||
<Button onClick={handlePublish} disabled={publishing} size="lg" className="w-full">
|
||||
{publishing ? 'Publishing…' : '📤 Publish & share'}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button onClick={handleDelete} disabled={deleting}
|
||||
className={buttonVariants({ variant: 'danger', size: 'lg', className: 'w-full' })}>
|
||||
{deleting ? 'Deleting…' : '🗑 Delete proposal & all data'}
|
||||
</button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="lg" className="w-full" disabled={deleting}>
|
||||
{deleting ? 'Deleting…' : '🗑 Delete proposal & all data'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this proposal?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes all data, including {decrypted.recipientName}'s response, permanently.
|
||||
This can't be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => void handleDelete()}
|
||||
className={buttonVariants({ variant: 'destructive' })}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 pb-4">
|
||||
<p className="text-center text-xs text-muted-foreground pb-4">
|
||||
Want to ask someone else out?{' '}
|
||||
<Link href="/create" className="text-pink-600 font-semibold hover:underline">Create a new proposal →</Link>
|
||||
<Link href="/create" className="text-primary font-semibold hover:underline">Create a new proposal →</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+233
-235
@@ -2,9 +2,14 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { themes, type Theme } from '@/lib/themes';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Field, { inputClasses } from '@/components/ui/Field';
|
||||
import { buttonVariants } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import Field from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SlotData {
|
||||
label: string;
|
||||
@@ -60,10 +65,25 @@ const COLOR_SWATCHES = [
|
||||
'#ffffff', '#94a3b8', '#334155', '#0f172a',
|
||||
];
|
||||
|
||||
const inputCls = inputClasses;
|
||||
const primaryBtn = buttonVariants({ variant: 'primary', size: 'lg', className: 'w-full' });
|
||||
const primaryBtn = buttonVariants({ variant: 'default', size: 'lg', className: 'w-full' });
|
||||
const secondaryBtn = buttonVariants({ variant: 'secondary', size: 'lg', className: 'flex-1' });
|
||||
|
||||
// Sticky on mobile so the wizard's next/back/publish actions never require
|
||||
// scrolling past a long form; reverts to normal inline flow at sm+ where
|
||||
// there's enough screen to see both the content and the actions at once.
|
||||
function StepActions({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky bottom-3 z-10 flex gap-3 rounded-2xl border border-border bg-background/95 p-3 shadow-lg backdrop-blur',
|
||||
'sm:static sm:border-0 sm:bg-transparent sm:p-0 sm:shadow-none sm:backdrop-blur-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type GradientStop = 'From' | 'Via' | 'To';
|
||||
|
||||
const STOP_LABELS: Record<GradientStop, string> = { From: 'From', Via: 'Via', To: 'To' };
|
||||
@@ -85,24 +105,24 @@ function ColorPicker({
|
||||
{(['From', 'Via', 'To'] as GradientStop[]).map((stop) => (
|
||||
<button key={stop} type="button" onClick={() => setActiveStop(stop)}
|
||||
className={`flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border-2 transition-all text-sm font-semibold ${
|
||||
activeStop === stop ? 'border-pink-400 bg-pink-50' : 'border-gray-100 hover:border-gray-300'
|
||||
activeStop === stop ? 'border-primary bg-accent' : 'border-border hover:border-muted-foreground/40'
|
||||
}`}
|
||||
>
|
||||
<span className="w-5 h-5 rounded-full border border-gray-300 shadow-sm shrink-0"
|
||||
<span className="w-5 h-5 rounded-full border border-border shadow-sm shrink-0"
|
||||
style={{ backgroundColor: vals[stop] }} />
|
||||
<span className="text-gray-700 text-xs">{STOP_LABELS[stop]}</span>
|
||||
{activeStop === stop && <span className="ml-auto text-pink-400 text-xs">✓</span>}
|
||||
<span className="text-muted-foreground text-xs">{STOP_LABELS[stop]}</span>
|
||||
{activeStop === stop && <span className="ml-auto text-primary text-xs">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-8 gap-1.5 p-3 bg-gray-50 rounded-xl">
|
||||
<div className="grid grid-cols-6 sm:grid-cols-8 gap-1.5 p-3 bg-secondary rounded-xl">
|
||||
{COLOR_SWATCHES.map((color) => (
|
||||
<button key={color} type="button" onClick={() => setters[activeStop](color)}
|
||||
title={color}
|
||||
className={`w-full aspect-square rounded-lg border-2 transition-all ${
|
||||
className={`w-full aspect-square min-h-8 rounded-lg border-2 transition-all ${
|
||||
currentColor.toLowerCase() === color.toLowerCase()
|
||||
? 'border-gray-900 scale-110 shadow-md'
|
||||
? 'border-foreground scale-110 shadow-md'
|
||||
: 'border-transparent hover:scale-110 hover:shadow-sm'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
@@ -111,17 +131,16 @@ function ColorPicker({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-9 h-9 rounded-xl border-2 border-gray-200 shrink-0"
|
||||
<span className="w-9 h-9 rounded-xl border-2 border-border shrink-0"
|
||||
style={{ backgroundColor: currentColor }} />
|
||||
<input
|
||||
<Input
|
||||
type="text" value={currentColor}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setters[activeStop](v);
|
||||
}}
|
||||
maxLength={7} placeholder="#000000"
|
||||
className="flex-1 px-3 py-2 rounded-xl border border-gray-200 font-mono text-sm
|
||||
focus:border-pink-400 focus:ring-2 focus:ring-pink-100 outline-none transition"
|
||||
className="h-10 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,11 +172,11 @@ function GifPicker({ value, onChange }: { value: string; onChange: (url: string)
|
||||
|
||||
if (value) {
|
||||
return (
|
||||
<div className="mt-2 relative rounded-2xl overflow-hidden border border-gray-200 bg-gray-50 max-h-48 flex items-center justify-center">
|
||||
<div className="mt-2 relative rounded-2xl overflow-hidden border border-border bg-secondary max-h-48 flex items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={value} alt="Selected GIF" className="max-h-48 object-contain" />
|
||||
<button type="button" onClick={() => onChange('')}
|
||||
className="absolute top-2 right-2 bg-black/60 text-white rounded-full w-7 h-7 flex items-center
|
||||
className="absolute top-2 right-2 bg-black/60 text-white rounded-full w-8 h-8 flex items-center
|
||||
justify-center text-sm font-bold hover:bg-black/80 transition-colors">
|
||||
×
|
||||
</button>
|
||||
@@ -167,11 +186,10 @@ function GifPicker({ value, onChange }: { value: string; onChange: (url: string)
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mt-2">
|
||||
<input
|
||||
<Input
|
||||
type="text" value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search for a GIF…"
|
||||
className={inputCls}
|
||||
/>
|
||||
|
||||
{results.length > 0 && (
|
||||
@@ -179,7 +197,7 @@ function GifPicker({ value, onChange }: { value: string; onChange: (url: string)
|
||||
{results.map((g) => (
|
||||
<button key={g.id} type="button" onClick={() => onChange(g.url)}
|
||||
className="aspect-video rounded-lg overflow-hidden border-2 border-transparent
|
||||
hover:border-pink-400 transition-all bg-gray-100">
|
||||
hover:border-primary transition-all bg-secondary">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={g.preview} alt={g.title} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
@@ -188,14 +206,14 @@ function GifPicker({ value, onChange }: { value: string; onChange: (url: string)
|
||||
)}
|
||||
|
||||
{searching && (
|
||||
<p className="text-sm text-gray-400 text-center py-2">Searching…</p>
|
||||
<p className="text-sm text-muted-foreground text-center py-2">Searching…</p>
|
||||
)}
|
||||
|
||||
{searched && !searching && results.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-2">No results, try another search</p>
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No results, try another search</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-300 text-center pt-1">Powered by Giphy</p>
|
||||
<p className="text-xs text-muted-foreground/70 text-center pt-1">Powered by Giphy</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -312,260 +330,240 @@ export default function ProposalForm({
|
||||
const detailsOk = senderName.trim() && recipientName.trim() && title.trim() && message.trim();
|
||||
const activitiesOk = activities.length >= 2 && activities.every((a) => a.title.trim());
|
||||
|
||||
const steps = ['Details', 'Activities', 'Theme'] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Step tabs */}
|
||||
<div className="flex gap-1 bg-gray-100 p-1 rounded-2xl">
|
||||
{(['details', 'activities', 'theme'] as const).map((s, i) => (
|
||||
<button key={s} type="button" onClick={() => setStep(s)}
|
||||
aria-current={step === s ? 'step' : undefined}
|
||||
className={`flex-1 py-2.5 rounded-xl text-sm font-semibold transition-all
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pink-300 ${
|
||||
step === s ? 'bg-white shadow text-gray-900' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{i + 1}. {steps[i]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Tabs value={step} onValueChange={(v) => setStep(v as typeof step)} className="space-y-5">
|
||||
<TabsList className="grid w-full grid-cols-3 h-auto bg-secondary p-1 rounded-2xl">
|
||||
<TabsTrigger value="details" className="rounded-xl py-2.5 text-sm font-semibold data-[state=active]:shadow">
|
||||
1. Details
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activities" className="rounded-xl py-2.5 text-sm font-semibold data-[state=active]:shadow">
|
||||
2. Activities
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="theme" className="rounded-xl py-2.5 text-sm font-semibold data-[state=active]:shadow">
|
||||
3. Theme
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Step 1: Details ── */}
|
||||
{step === 'details' && (
|
||||
<TabsContent value="details" className="mt-0 space-y-4">
|
||||
<Card className="space-y-4">
|
||||
<Field label="Your first name" htmlFor="senderName">
|
||||
<input id="senderName" type="text" required maxLength={60}
|
||||
<Input id="senderName" type="text" required maxLength={60}
|
||||
value={senderName} onChange={(e) => setSenderName(e.target.value)}
|
||||
placeholder="Jordan"
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Their first name" htmlFor="recipientName">
|
||||
<input id="recipientName" type="text" required maxLength={60}
|
||||
<Input id="recipientName" type="text" required maxLength={60}
|
||||
value={recipientName} onChange={(e) => setRecipientName(e.target.value)}
|
||||
placeholder="Alex"
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Proposal title" htmlFor="title">
|
||||
<input id="title" type="text" required maxLength={120}
|
||||
<Input id="title" type="text" required maxLength={120}
|
||||
value={title} onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Want to go on an adventure with me?"
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Personal message" htmlFor="message">
|
||||
<textarea id="message" required maxLength={2000} rows={4}
|
||||
<Textarea id="message" required maxLength={2000} rows={4}
|
||||
value={message} onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={`Hey ${recipientName || '…'} !`}
|
||||
className={`${inputCls} resize-none`}
|
||||
className="resize-none"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 text-right mt-1">{message.length}/2000</p>
|
||||
<p className="text-xs text-muted-foreground text-right mt-1">{message.length}/2000</p>
|
||||
</Field>
|
||||
</Card>
|
||||
<StepActions>
|
||||
<button type="button" disabled={!detailsOk} onClick={() => setStep('activities')}
|
||||
className={primaryBtn}>
|
||||
Next: Activities →
|
||||
</button>
|
||||
</Card>
|
||||
)}
|
||||
</StepActions>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Step 2: Activities ── */}
|
||||
{step === 'activities' && (
|
||||
<div className="space-y-4">
|
||||
{activities.map((act, ai) => (
|
||||
<Card key={ai} className="p-4 sm:p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-display font-bold text-gray-700 text-sm">Activity {ai + 1}</span>
|
||||
{activities.length > 2 && (
|
||||
<button type="button" onClick={() => removeActivity(ai)}
|
||||
className="text-red-400 hover:text-red-600 text-sm transition-colors">
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="relative">
|
||||
<button type="button"
|
||||
className="text-3xl w-12 h-12 flex items-center justify-center rounded-xl border border-gray-200 hover:border-pink-300 transition-colors">
|
||||
{act.emoji}
|
||||
</button>
|
||||
<select value={act.emoji} onChange={(e) => updateActivity(ai, { emoji: e.target.value })}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full">
|
||||
{EMOJI_PRESETS.map((em) => <option key={em} value={em}>{em}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<input type="text" required maxLength={100}
|
||||
value={act.title} onChange={(e) => updateActivity(ai, { title: e.target.value })}
|
||||
placeholder="Activity name"
|
||||
className={`flex-1 ${inputCls}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea maxLength={300} rows={2}
|
||||
value={act.description}
|
||||
onChange={(e) => updateActivity(ai, { description: e.target.value })}
|
||||
placeholder="Short description (optional)"
|
||||
className={`w-full text-sm resize-none ${inputCls}`}
|
||||
/>
|
||||
|
||||
{/* Time slots */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Date & time options
|
||||
</p>
|
||||
<button type="button" onClick={() => addSlot(ai)}
|
||||
className="text-xs text-pink-600 font-semibold hover:underline">
|
||||
+ Add slot
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{act.slots.map((slot, si) => (
|
||||
<div key={si} className="flex gap-2 items-center">
|
||||
<div className="flex-1">
|
||||
<input type="datetime-local" value={slot.startsAt}
|
||||
onChange={(e) => updateSlot(ai, si, e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg border border-gray-200 text-sm
|
||||
focus:border-pink-400 focus:ring-2 focus:ring-pink-100 outline-none transition"
|
||||
/>
|
||||
{slot.label && <p className="text-xs text-gray-400 mt-0.5 ml-1">{slot.label}</p>}
|
||||
</div>
|
||||
<button type="button" onClick={() => removeSlot(ai, si)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors px-2 text-lg shrink-0">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{activities.length < 5 && (
|
||||
<button type="button" onClick={addActivity}
|
||||
className="w-full py-3.5 border-2 border-dashed border-gray-200 rounded-2xl
|
||||
text-gray-500 hover:border-pink-300 hover:text-pink-500 font-semibold transition-all text-sm">
|
||||
+ Add activity (max 5)
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={() => setStep('details')} className={secondaryBtn}>
|
||||
← Back
|
||||
</button>
|
||||
<button type="button" disabled={!activitiesOk} onClick={() => setStep('theme')} className={primaryBtn}>
|
||||
Next: Theme →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Theme + GIF ── */}
|
||||
{step === 'theme' && (
|
||||
<div className="space-y-4">
|
||||
{/* Gradient preview */}
|
||||
<div
|
||||
{...gradientPreview}
|
||||
className={`h-20 rounded-2xl shadow-md transition-all ${gradientPreview.className ?? ''}`}
|
||||
style={gradientPreview.style}
|
||||
/>
|
||||
|
||||
<Card className="space-y-5">
|
||||
<h2 className="font-display font-bold text-gray-900">Choose a vibe</h2>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{themeList.map(([key, t]) => (
|
||||
<button key={key} type="button"
|
||||
onClick={() => { handleThemeChange(key); setUseCustom(false); }}
|
||||
className={`flex items-center gap-2 p-3 rounded-xl border-2 transition-all text-left ${
|
||||
theme === key && !useCustom
|
||||
? 'border-pink-400 bg-pink-50'
|
||||
: 'border-gray-100 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${t.gradient} shrink-0`} />
|
||||
<span className="text-sm font-semibold text-gray-700">{t.emoji} {t.label}</span>
|
||||
{theme === key && !useCustom && <span className="ml-auto text-pink-500 text-xs">✓</span>}
|
||||
<TabsContent value="activities" className="mt-0 space-y-4">
|
||||
{activities.map((act, ai) => (
|
||||
<Card key={ai} className="p-4 sm:p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-display font-bold text-foreground text-sm">Activity {ai + 1}</span>
|
||||
{activities.length > 2 && (
|
||||
<button type="button" onClick={() => removeActivity(ai)}
|
||||
className="text-destructive/80 hover:text-destructive text-sm transition-colors py-1 px-1">
|
||||
Remove
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom colors */}
|
||||
<div>
|
||||
<button type="button" onClick={() => setUseCustom((v) => !v)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-xl border-2 transition-all ${
|
||||
useCustom ? 'border-pink-400 bg-pink-50' : 'border-gray-100 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="font-semibold text-gray-700 text-sm">🎨 Custom colors</span>
|
||||
<span className="text-xs text-gray-400">{useCustom ? '▲ Hide' : '▼ Pick colors'}</span>
|
||||
</button>
|
||||
|
||||
{useCustom && (
|
||||
<ColorPicker
|
||||
gradFrom={gradFrom} gradVia={gradVia} gradTo={gradTo}
|
||||
setGradFrom={setGradFrom} setGradVia={setGradVia} setGradTo={setGradTo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* GIF section */}
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1">
|
||||
🎭 Add a GIF (optional)
|
||||
</label>
|
||||
<GifPicker value={gifUrl} onChange={setGifUrl} />
|
||||
<div className="flex gap-3">
|
||||
<div className="relative">
|
||||
<button type="button"
|
||||
className="text-3xl w-12 h-12 flex items-center justify-center rounded-xl border border-input hover:border-primary/50 transition-colors">
|
||||
{act.emoji}
|
||||
</button>
|
||||
<select value={act.emoji} onChange={(e) => updateActivity(ai, { emoji: e.target.value })}
|
||||
aria-label="Activity emoji"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full">
|
||||
{EMOJI_PRESETS.map((em) => <option key={em} value={em}>{em}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<Input type="text" required maxLength={100}
|
||||
value={act.title} onChange={(e) => updateActivity(ai, { title: e.target.value })}
|
||||
placeholder="Activity name"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Evasive No toggle */}
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-700 text-sm">🎪 Make “No” nearly impossible</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">The No button runs away when they try to click it</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEvasiveNo((v) => !v)}
|
||||
aria-pressed={evasiveNo}
|
||||
className={`relative shrink-0 w-12 h-6 rounded-full transition-colors ${
|
||||
evasiveNo ? 'bg-pink-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span className={`block w-5 h-5 bg-white rounded-full shadow transition-transform absolute top-0.5 ${
|
||||
evasiveNo ? 'translate-x-6' : 'translate-x-0.5'
|
||||
}`} />
|
||||
<Textarea maxLength={300} rows={2}
|
||||
value={act.description}
|
||||
onChange={(e) => updateActivity(ai, { description: e.target.value })}
|
||||
placeholder="Short description (optional)"
|
||||
className="text-sm resize-none"
|
||||
/>
|
||||
|
||||
{/* Time slots */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Date & time options
|
||||
</p>
|
||||
<button type="button" onClick={() => addSlot(ai)}
|
||||
className="text-xs text-primary font-semibold hover:underline py-1">
|
||||
+ Add slot
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{act.slots.map((slot, si) => (
|
||||
<div key={si} className="flex gap-2 items-center">
|
||||
<div className="flex-1">
|
||||
<Input type="datetime-local" value={slot.startsAt}
|
||||
onChange={(e) => updateSlot(ai, si, e.target.value)}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
{slot.label && <p className="text-xs text-muted-foreground mt-0.5 ml-1">{slot.label}</p>}
|
||||
</div>
|
||||
<button type="button" onClick={() => removeSlot(ai, si)}
|
||||
aria-label="Remove time slot"
|
||||
className="text-muted-foreground hover:text-destructive transition-colors px-2 h-11 text-lg shrink-0">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{submitError && (
|
||||
<p className="text-red-600 text-sm bg-red-50 border border-red-200 rounded-xl px-4 py-3">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
{activities.length < 5 && (
|
||||
<button type="button" onClick={addActivity}
|
||||
className="w-full py-3.5 border-2 border-dashed border-input rounded-2xl
|
||||
text-muted-foreground hover:border-primary/50 hover:text-primary font-semibold transition-all text-sm">
|
||||
+ Add activity (max 5)
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={() => setStep('activities')} className={secondaryBtn}>
|
||||
← Back
|
||||
</button>
|
||||
{!hideDraft && (
|
||||
<button type="button" disabled={submitting} onClick={() => void handleSubmit(false)}
|
||||
className={secondaryBtn}>
|
||||
Save as draft
|
||||
<StepActions>
|
||||
<button type="button" onClick={() => setStep('details')} className={secondaryBtn}>
|
||||
← Back
|
||||
</button>
|
||||
<button type="button" disabled={!activitiesOk} onClick={() => setStep('theme')} className={primaryBtn}>
|
||||
Next: Theme →
|
||||
</button>
|
||||
</StepActions>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Step 3: Theme + GIF ── */}
|
||||
<TabsContent value="theme" className="mt-0 space-y-4">
|
||||
{/* Gradient preview */}
|
||||
<div
|
||||
{...gradientPreview}
|
||||
className={`h-20 rounded-2xl shadow-md transition-all ${gradientPreview.className ?? ''}`}
|
||||
style={gradientPreview.style}
|
||||
/>
|
||||
|
||||
<Card className="space-y-5">
|
||||
<h2 className="font-display font-bold text-foreground">Choose a vibe</h2>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{themeList.map(([key, t]) => (
|
||||
<button key={key} type="button"
|
||||
onClick={() => { handleThemeChange(key); setUseCustom(false); }}
|
||||
className={`flex items-center gap-2 p-3 rounded-xl border-2 transition-all text-left ${
|
||||
theme === key && !useCustom
|
||||
? 'border-primary bg-accent'
|
||||
: 'border-border hover:border-muted-foreground/40'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${t.gradient} shrink-0`} />
|
||||
<span className="text-sm font-semibold text-foreground">{t.emoji} {t.label}</span>
|
||||
{theme === key && !useCustom && <span className="ml-auto text-primary text-xs">✓</span>}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={submitting} onClick={() => void handleSubmit(true)}
|
||||
className={primaryBtn}>
|
||||
{submitting ? '…' : publishLabel}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom colors */}
|
||||
<div>
|
||||
<button type="button" onClick={() => setUseCustom((v) => !v)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-xl border-2 transition-all ${
|
||||
useCustom ? 'border-primary bg-accent' : 'border-border hover:border-muted-foreground/40'
|
||||
}`}
|
||||
>
|
||||
<span className="font-semibold text-foreground text-sm">🎨 Custom colors</span>
|
||||
<span className="text-xs text-muted-foreground">{useCustom ? '▲ Hide' : '▼ Pick colors'}</span>
|
||||
</button>
|
||||
|
||||
{useCustom && (
|
||||
<ColorPicker
|
||||
gradFrom={gradFrom} gradVia={gradVia} gradTo={gradTo}
|
||||
setGradFrom={setGradFrom} setGradVia={setGradVia} setGradTo={setGradTo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* GIF section */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<label className="block text-sm font-semibold text-foreground mb-1">
|
||||
🎭 Add a GIF (optional)
|
||||
</label>
|
||||
<GifPicker value={gifUrl} onChange={setGifUrl} />
|
||||
</div>
|
||||
|
||||
{/* Evasive No toggle */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<label className="flex items-center justify-between gap-4 cursor-pointer">
|
||||
<div>
|
||||
<p className="font-semibold text-foreground text-sm">🎪 Make “No” nearly impossible</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">The No button runs away when they try to click it</p>
|
||||
</div>
|
||||
<Switch checked={evasiveNo} onCheckedChange={setEvasiveNo} className="shrink-0" />
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{submitError && (
|
||||
<p className="text-destructive text-sm bg-destructive/10 border border-destructive/20 rounded-xl px-4 py-3">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<StepActions>
|
||||
<button type="button" onClick={() => setStep('activities')} className={secondaryBtn}>
|
||||
← Back
|
||||
</button>
|
||||
{!hideDraft && (
|
||||
<button type="button" disabled={submitting} onClick={() => void handleSubmit(false)}
|
||||
className={secondaryBtn}>
|
||||
Save as draft
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={submitting} onClick={() => void handleSubmit(true)}
|
||||
className={primaryBtn}>
|
||||
{submitting ? '…' : publishLabel}
|
||||
</button>
|
||||
</StepActions>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user