diff --git a/src/app/create/CreateClient.tsx b/src/app/create/CreateClient.tsx index 0a1c563..96ad597 100644 --- a/src/app/create/CreateClient.tsx +++ b/src/app/create/CreateClient.tsx @@ -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(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 ( - +
πŸŽ‰
-

Your proposal is ready!

-

Share the first link, keep the second one private.

+

Your proposal is ready!

+

Share the first link, keep the second one private.

@@ -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 /> copyLink(links.manageUrl, 'manage')} + onCopy={() => copyLink(links.manageUrl)} />
-

+

Want to create another one?{' '} - + Make a new proposal β†’

@@ -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 ( -
+
{emoji}
-

{label}

-

{sublabel}

+

{label}

+

{sublabel}

- - +
+ className="block text-xs text-primary hover:underline mt-1.5 truncate py-0.5"> {url}
diff --git a/src/app/manage/[token]/ManageClient.tsx b/src/app/manage/[token]/ManageClient.tsx index ab20395..14643d6 100644 --- a/src/app/manage/[token]/ManageClient.tsx +++ b/src/app/manage/[token]/ManageClient.tsx @@ -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 ( -
-
+
+
πŸ”’
-

+

This link looks incomplete

-

+

Make sure you copied the whole link, including everything after the β€œ#”.

@@ -171,23 +180,23 @@ export default function ManageClient({ if (!decrypted) { return ( -
+
πŸ’Œ
); } return ( -
+
{/* Header banner */}
-
+
πŸ’Œ lovelope.app β€Ί Manage proposal
-

{decrypted.title}

+

{decrypted.title}

For {decrypted.recipientName} @@ -201,16 +210,16 @@ export default function ManageClient({ {/* Response card */} {decrypted.response ? ( -

Response received

+

Response received

{answerEmoji[decrypted.response.answer as keyof typeof answerEmoji]}
-

+

{answerLabel[decrypted.response.answer as keyof typeof answerLabel]}

-

+

{new Date(proposal.response!.respondedAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', })} @@ -218,22 +227,22 @@ export default function ManageClient({

{selectedActivity && ( -
-

Chose

-

+

+

Chose

+

{selectedActivity.emoji} {selectedActivity.title}

{selectedSlot && ( -

+

πŸ—“ {formatSlotDate(selectedSlot.startsAt, selectedSlot.label)}

)}
)} {decrypted.response.note && ( -
-

Note

-

“{decrypted.response.note}”

+
+

Note

+

“{decrypted.response.note}”

)} {calendarSlot && ( @@ -247,8 +256,8 @@ export default function ManageClient({ ) : (
⏳
-

Waiting for a response

-

+

Waiting for a response

+

Share the public link with {decrypted.recipientName} to get started.

@@ -257,10 +266,10 @@ export default function ManageClient({ {/* Links + QR */}
-

Your links

+

Your links

@@ -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" /> ) : ( -
+
)} -

Scan to open the proposal

+

Scan to open the proposal

)} {([ - { 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 }) => (
-

{emoji} {label}

+

{emoji} {label}

- - + +
{which === 'public' && ( + className="text-xs text-primary hover:underline mt-1 block py-0.5"> Preview β†’ )} @@ -314,16 +317,16 @@ export default function ManageClient({ {/* Activities summary */} -

Activities ({decrypted.activities.length})

+

Activities ({decrypted.activities.length})

{decrypted.activities.map((a) => (
{a.emoji}
-

{a.title}

- {a.description &&

{a.description}

} +

{a.title}

+ {a.description &&

{a.description}

} {a.slots.length > 0 && (
{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)} @@ -350,20 +353,40 @@ export default function ManageClient({ {/* Actions */} {proposal.status === 'draft' && ( - + )} - + + + + + + + Delete this proposal? + + This removes all data, including {decrypted.recipientName}'s response, permanently. + This can't be undone. + + + + Cancel + void handleDelete()} + className={buttonVariants({ variant: 'destructive' })} + > + Delete + + + + -

+

Want to ask someone else out?{' '} - Create a new proposal β†’ + Create a new proposal β†’

diff --git a/src/components/ProposalForm.tsx b/src/components/ProposalForm.tsx index fe95969..0b06308 100644 --- a/src/components/ProposalForm.tsx +++ b/src/components/ProposalForm.tsx @@ -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) { + return ( +
+ ); +} + type GradientStop = 'From' | 'Via' | 'To'; const STOP_LABELS: Record = { From: 'From', Via: 'Via', To: 'To' }; @@ -85,24 +105,24 @@ function ColorPicker({ {(['From', 'Via', 'To'] as GradientStop[]).map((stop) => ( ))}
-
+
{COLOR_SWATCHES.map((color) => (
- - { 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" />
@@ -153,11 +172,11 @@ function GifPicker({ value, onChange }: { value: string; onChange: (url: string) if (value) { return ( -
+
{/* eslint-disable-next-line @next/next/no-img-element */} Selected GIF @@ -167,11 +186,10 @@ function GifPicker({ value, onChange }: { value: string; onChange: (url: string) return (
- 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) => ( @@ -188,14 +206,14 @@ function GifPicker({ value, onChange }: { value: string; onChange: (url: string) )} {searching && ( -

Searching…

+

Searching…

)} {searched && !searching && results.length === 0 && ( -

No results, try another search

+

No results, try another search

)} -

Powered by Giphy

+

Powered by Giphy

); } @@ -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 ( -
- {/* Step tabs */} -
- {(['details', 'activities', 'theme'] as const).map((s, i) => ( - - ))} -
+ setStep(v as typeof step)} className="space-y-5"> + + + 1. Details + + + 2. Activities + + + 3. Theme + + {/* ── Step 1: Details ── */} - {step === 'details' && ( + - setSenderName(e.target.value)} placeholder="Jordan" - className={inputCls} /> - setRecipientName(e.target.value)} placeholder="Alex" - className={inputCls} /> - setTitle(e.target.value)} placeholder="Want to go on an adventure with me?" - className={inputCls} /> -