chore: remove stale *.old.tsx backups and fix Button/Card casing in git

The *.old.tsx files (page.old.tsx, layout.old.tsx, ManageClient.old.tsx,
etc.) were pre-refactor backups left alongside their replacements,
confirmed unreferenced by any import.

Also fixes a latent git-tracking bug: since the very first baseline
commit tracked these two files as Button.tsx/Card.tsx, and Windows'
case-insensitive filesystem means a same-path case change isn't
recorded as a rename by default, git's tree kept the capitalized path
forever even after they were rewritten to shadcn's lowercase
button.tsx/card.tsx content. This surfaced intermittently as a
TS1261 "differs only in casing" build error whenever git re-checked-out
the tree (e.g. after this branch's merge to master rewrote the working
copy back to the capitalized path). Fixed with a two-hop git mv so the
rename is actually recorded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 20:32:32 +02:00
parent 5a054357fc
commit 80bb4c9474
12 changed files with 0 additions and 1973 deletions
-156
View File
@@ -1,156 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import ProposalForm, { type ProposalFormData } from '@/components/ProposalForm';
import { generateKey, encryptField, encryptOptional } from '@/lib/crypto-client';
interface Links {
publicUrl: string;
manageUrl: string;
}
// Encrypts every personal/narrative field client-side before it ever reaches
// the network; the server only ever receives and stores ciphertext. The key
// itself is generated here and never sent to the server; it only travels in
// the URL fragment of the links shown to the user.
async function encryptPayload(key: string, data: ProposalFormData) {
const activities = await Promise.all(
data.activities.map(async (a) => ({
title: await encryptField(key, a.title),
description: await encryptOptional(key, a.description),
emoji: a.emoji,
slots: await Promise.all(
a.slots.map(async (s) => ({
label: await encryptField(key, s.label),
startsAt: await encryptOptional(key, s.startsAt),
}))
),
}))
);
return {
senderName: await encryptField(key, data.senderName),
recipientName: await encryptField(key, data.recipientName),
title: await encryptField(key, data.title),
message: await encryptField(key, data.message),
theme: data.theme,
gradientFrom: data.gradientFrom,
gradientVia: data.gradientVia,
gradientTo: data.gradientTo,
gifUrl: await encryptOptional(key, data.gifUrl),
evasiveNo: data.evasiveNo,
activities,
};
}
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('');
const key = await generateKey();
const payload = await encryptPayload(key, data);
const res = await fetch('/api/proposals/guest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!res.ok) {
setError('Something went wrong. Please check your inputs and try again.');
return;
}
setLinks({
publicUrl: `${json.publicUrl}#k=${key}`,
manageUrl: `${json.manageUrl}#k=${key}`,
});
}
function copyLink(url: string, which: 'public' | 'manage') {
navigator.clipboard.writeText(url);
if (which === 'public') { setCopiedPublic(true); setTimeout(() => setCopiedPublic(false), 2000); }
else { setCopiedManage(true); setTimeout(() => setCopiedManage(false), 2000); }
}
if (links) {
return (
<div className="bg-white rounded-3xl shadow-2xl 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>
</div>
<div className="text-left space-y-4">
<LinkBox
emoji="📤"
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')}
highlight
/>
<LinkBox
emoji="🔒"
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')}
/>
</div>
<p className="text-xs text-gray-400">
Want to create another one?{' '}
<Link href="/create" className="text-pink-600 font-semibold hover:underline">
Make a new proposal
</Link>
</p>
</div>
);
}
return <ProposalForm onSubmit={handleSubmit} submitError={error} publishLabel="🚀 Create & share!" hideDraft />;
}
function LinkBox({
emoji, label, sublabel, url, copied, onCopy, highlight,
}: {
emoji: string; label: string; sublabel: string; url: string;
copied: 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="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>
</div>
</div>
<div className="flex gap-2">
<input
readOnly value={url}
className="flex-1 text-xs bg-white border border-gray-200 rounded-xl px-3 py-2 text-gray-600 min-w-0"
/>
<button
onClick={onCopy}
className={`shrink-0 px-3 py-2 rounded-xl text-sm font-semibold transition-colors ${
copied ? 'bg-green-100 text-green-700' : 'bg-gray-100 hover:bg-gray-200 text-gray-700'
}`}
>
{copied ? '✓ Copied' : 'Copy'}
</button>
</div>
<a href={url} target="_blank" rel="noopener noreferrer"
className="block text-xs text-pink-600 hover:underline mt-1.5 truncate">
{url}
</a>
</div>
);
}
-18
View File
@@ -1,18 +0,0 @@
import CreateClient from './CreateClient';
export const metadata = { title: 'Create a proposal, no account needed' };
export default function CreatePage() {
return (
<div className="min-h-screen bg-gradient-to-br from-orange-400 via-pink-500 to-rose-500 py-10 px-4">
<div className="max-w-2xl mx-auto">
<div className="text-center mb-8 text-white">
<div className="text-5xl mb-3">💌</div>
<h1 className="font-display text-3xl font-extrabold mb-1">Create your proposal</h1>
<p className="text-white/80">No account needed, you get two private links when you&apos;re done.</p>
</div>
<CreateClient />
</div>
</div>
);
}
-44
View File
@@ -1,44 +0,0 @@
import type { Metadata, Viewport } from 'next';
import { Inter, Poppins } from 'next/font/google';
import Footer from '@/components/Footer';
import './globals.css';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
const poppins = Poppins({
subsets: ['latin'],
weight: ['400', '600', '700', '800'],
variable: '--font-poppins',
});
export const metadata: Metadata = {
title: { default: 'lovelope.app', template: '%s | lovelope.app' },
description: 'The adorable way to ask someone out',
manifest: '/manifest.webmanifest',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'lovelope.app',
},
openGraph: {
title: 'lovelope.app',
description: 'The adorable way to ask someone out',
type: 'website',
},
};
export const viewport: Viewport = {
themeColor: '#ec4899',
width: 'device-width',
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${poppins.variable}`}>
<body className="font-sans bg-gray-50 text-gray-900 min-h-screen flex flex-col">
<div className="flex-1">{children}</div>
<Footer />
</body>
</html>
);
}
-409
View File
@@ -1,409 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import QRCode from 'qrcode';
import { themes, getGradientStyle, formatSlotDate, type Theme } from '@/lib/themes';
import { keyFromHash, decryptField, decryptOptional } from '@/lib/crypto-client';
import type { Proposal, ActivityOption, TimeSlot, Response } from '@prisma/client';
type FullProposal = Proposal & {
activities: (ActivityOption & { slots: TimeSlot[] })[];
response: (Response & {
selectedActivity: ActivityOption | null;
selectedTimeSlot: TimeSlot | null;
}) | null;
};
interface DecryptedSlot { id: string; label: string; startsAt: string | null }
interface DecryptedActivity { id: string; emoji: string; title: string; description: string | null; slots: DecryptedSlot[] }
interface DecryptedManage {
title: string;
recipientName: string;
activities: DecryptedActivity[];
response: { answer: string; note: string | null } | null;
}
const answerEmoji = { yes: '🎉', maybe: '🤔', no: '💔' };
const answerLabel = { yes: 'Yes!', maybe: 'Maybe', no: 'No' };
const statusBadge: Record<string, string> = {
draft: 'bg-gray-100 text-gray-600',
sent: 'bg-blue-100 text-blue-700',
answered: 'bg-green-100 text-green-700',
expired: 'bg-red-100 text-red-600',
};
function toGCalDate(d: Date) {
return d.toISOString().replace(/[-:.]/g, '').slice(0, 15) + 'Z';
}
function makeIcs(title: string, startsAt: Date, description: string) {
const start = toGCalDate(startsAt);
const end = toGCalDate(new Date(startsAt.getTime() + 60 * 60 * 1000));
return [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//lovelope.app//EN',
'BEGIN:VEVENT',
`DTSTART:${start}`, `DTEND:${end}`,
`SUMMARY:${title}`, `DESCRIPTION:${description}`,
'END:VEVENT', 'END:VCALENDAR',
].join('\r\n');
}
function CalendarButtons({ title, startsAt, description }: { title: string; startsAt: Date; description: string }) {
function downloadIcs() {
const blob = new Blob([makeIcs(title, startsAt, description)], { type: 'text/calendar' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'date.ics'; a.click();
URL.revokeObjectURL(url);
}
const gCalUrl = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${toGCalDate(startsAt)}/${toGCalDate(new Date(startsAt.getTime() + 60 * 60 * 1000))}&details=${encodeURIComponent(description)}`;
return (
<div className="mt-4 flex flex-col sm:flex-row gap-2">
<a href={gCalUrl} target="_blank" rel="noopener noreferrer"
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-blue-50 text-blue-700
border border-blue-200 rounded-xl text-sm font-semibold hover:bg-blue-100 transition-colors">
📅 Add to Google Calendar
</a>
<button onClick={downloadIcs}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gray-50 text-gray-700
border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-100 transition-colors">
📥 Download .ics
</button>
</div>
);
}
export default function ManageClient({
proposal, token, publicUrl, manageUrl,
}: {
proposal: FullProposal;
token: string;
publicUrl: string;
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);
const [qrDataUrl, setQrDataUrl] = useState('');
// End-to-end decryption: the key lives only in this page's URL fragment,
// which the server never sees.
const [decrypted, setDecrypted] = useState<DecryptedManage | null>(null);
const [keyMissing, setKeyMissing] = useState(false);
const [hash, setHash] = useState('');
const theme = themes[proposal.theme as Theme];
const customGradient =
proposal.gradientFrom && proposal.gradientVia && proposal.gradientTo
? { from: proposal.gradientFrom, via: proposal.gradientVia, to: proposal.gradientTo }
: null;
const gradStyle = getGradientStyle(proposal.theme as Theme, customGradient);
useEffect(() => {
const h = window.location.hash;
setHash(h);
const k = keyFromHash(h);
if (!k) { setKeyMissing(true); return; }
let cancelled = false;
(async () => {
try {
const [title, recipientName] = await Promise.all([
decryptField(k, proposal.title),
decryptField(k, proposal.recipientName),
]);
const activities = await Promise.all(proposal.activities.map(async (a) => {
const [aTitle, aDesc] = await Promise.all([
decryptField(k, a.title),
decryptOptional(k, a.description),
]);
const slots = await Promise.all(a.slots.map(async (s) => {
const [label, startsAt] = await Promise.all([
decryptField(k, s.label),
decryptOptional(k, s.startsAt),
]);
return { id: s.id, label, startsAt: startsAt ?? null };
}));
return { id: a.id, emoji: a.emoji, title: aTitle, description: aDesc ?? null, slots };
}));
let response: DecryptedManage['response'] = null;
if (proposal.response) {
const [answer, note] = await Promise.all([
decryptField(k, proposal.response.answer),
decryptOptional(k, proposal.response.note),
]);
response = { answer, note: note ?? null };
}
if (!cancelled) setDecrypted({ title, recipientName, activities, response });
} catch {
if (!cancelled) setKeyMissing(true);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fullPublicUrl = `${publicUrl}${hash}`;
const fullManageUrl = `${manageUrl}${hash}`;
useEffect(() => {
if (!showQr || !hash) return;
let cancelled = false;
QRCode.toDataURL(fullPublicUrl, { width: 200, margin: 2 })
.then((url) => { if (!cancelled) setQrDataUrl(url); })
.catch(() => { if (!cancelled) setQrDataUrl(''); });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showQr, hash]);
function copy(url: string, which: 'public' | 'manage') {
navigator.clipboard.writeText(url);
if (which === 'public') { setCopiedPublic(true); setTimeout(() => setCopiedPublic(false), 2000); }
else { setCopiedManage(true); setTimeout(() => setCopiedManage(false), 2000); }
}
async function handlePublish() {
setPublishing(true);
await fetch(`/api/manage/${token}/publish`, { method: 'POST' });
setPublishing(false);
router.refresh();
}
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('/');
}
const selectedActivity = decrypted?.activities.find((a) => a.id === proposal.response?.selectedActivityId);
const selectedSlot = selectedActivity?.slots.find((s) => s.id === proposal.response?.selectedTimeSlotId) ?? null;
const calendarSlot =
decrypted?.response?.answer === 'yes' && selectedSlot?.startsAt
? {
startsAt: new Date(selectedSlot.startsAt),
title: `${selectedActivity?.title ?? 'Date'} with ${decrypted.recipientName}`,
}
: null;
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="text-5xl mb-4">🔒</div>
<h1 className="font-display text-2xl font-extrabold text-gray-900 mb-2">
This link looks incomplete
</h1>
<p className="text-gray-500">
Make sure you copied the whole link, including everything after the #.
</p>
</div>
</div>
);
}
if (!decrypted) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="text-6xl animate-pulse">💌</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header banner */}
<div className={gradStyle.className ?? ''} style={gradStyle.style}>
<div className="max-w-3xl mx-auto px-4 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>
<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`}>
{proposal.status.charAt(0).toUpperCase() + proposal.status.slice(1)}
</span>
</div>
</div>
</div>
<div className="max-w-3xl mx-auto px-4 py-6 sm:py-8 space-y-5">
{/* Response card */}
{decrypted.response ? (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 sm:p-6">
<h2 className="font-display font-bold text-gray-900 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">
{answerLabel[decrypted.response.answer as keyof typeof answerLabel]}
</p>
<p className="text-sm text-gray-500">
{new Date(proposal.response!.respondedAt).toLocaleDateString('en-US', {
month: 'long', day: 'numeric', year: 'numeric',
})}
</p>
</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">
{selectedActivity.emoji} {selectedActivity.title}
</p>
{selectedSlot && (
<p className="text-sm text-gray-500 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">&ldquo;{decrypted.response.note}&rdquo;</p>
</div>
)}
{calendarSlot && (
<CalendarButtons
title={calendarSlot.title}
startsAt={calendarSlot.startsAt}
description={`Arranged via lovelope.app: ${decrypted.title}`}
/>
)}
</div>
) : (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 sm:p-6 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">
Share the public link with {decrypted.recipientName} to get started.
</p>
</div>
)}
{/* Links + QR */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 sm:p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-display font-bold text-gray-900">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"
>
📱 {showQr ? 'Hide QR' : 'Show QR code'}
</button>
</div>
{showQr && (
<div className="flex flex-col items-center gap-2 py-2">
{qrDataUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={qrDataUrl}
alt="QR code for public proposal link"
width={200}
height={200}
className="rounded-xl border border-gray-100 shadow-sm"
/>
) : (
<div className="w-[200px] h-[200px] rounded-xl border border-gray-100 bg-gray-50 animate-pulse" />
)}
<p className="text-xs text-gray-400">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 }) => (
<div key={which}>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">{emoji} {label}</p>
<div className="flex gap-2">
<input readOnly value={url}
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" />
<button onClick={() => copy(url, which)}
className={`shrink-0 px-4 py-2.5 rounded-xl text-sm font-semibold transition-colors ${
copied ? 'bg-green-100 text-green-700' : 'bg-gray-100 hover:bg-gray-200'
}`}>
{copied ? '✓' : 'Copy'}
</button>
</div>
{which === 'public' && (
<a href={url} target="_blank" rel="noopener noreferrer"
className="text-xs text-pink-600 hover:underline mt-1 block">
Preview
</a>
)}
</div>
))}
</div>
{/* Activities summary */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 sm:p-6">
<h2 className="font-display font-bold text-gray-900 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'
}`}>
<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>}
{a.slots.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{a.slots.map((s) => (
<span key={s.id}
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'
}`}>
{formatSlotDate(s.startsAt, s.label)}
</span>
))}
</div>
)}
</div>
{proposal.response?.selectedActivityId === a.id && (
<span className="text-green-600 text-xs font-bold shrink-0"> Chosen</span>
)}
</div>
))}
</div>
</div>
{/* Actions */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 sm:p-6 space-y-3">
{proposal.status === 'draft' && (
<button onClick={handlePublish} disabled={publishing}
className="w-full py-3 bg-gradient-to-r from-orange-500 to-pink-500 text-white
font-semibold rounded-xl hover:shadow-md transition-all disabled:opacity-60">
{publishing ? 'Publishing…' : '📤 Publish & share'}
</button>
)}
<button onClick={handleDelete} disabled={deleting}
className="w-full py-3 bg-red-50 text-red-600 border border-red-200 font-semibold
rounded-xl hover:bg-red-100 transition-colors disabled:opacity-60">
{deleting ? 'Deleting…' : '🗑 Delete proposal & all data'}
</button>
</div>
<p className="text-center text-xs text-gray-400 pb-4">
Want to ask someone else out?{' '}
<Link href="/create" className="text-pink-600 font-semibold hover:underline">Create a new proposal </Link>
</p>
</div>
</div>
);
}
-22
View File
@@ -1,22 +0,0 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="min-h-screen bg-gradient-to-br from-orange-400 via-pink-500 to-rose-500 flex items-center justify-center px-4">
<div className="bg-white rounded-3xl shadow-2xl p-12 text-center max-w-md w-full">
<div className="text-6xl mb-4">🔍</div>
<h1 className="font-display text-3xl font-extrabold text-gray-900 mb-2">Not found</h1>
<p className="text-gray-500 mb-8">
This proposal doesn&apos;t exist or has been removed.
</p>
<Link
href="/"
className="inline-block bg-gradient-to-r from-orange-500 to-pink-500 text-white
font-bold px-8 py-3 rounded-2xl hover:shadow-lg transition-all"
>
Go home
</Link>
</div>
</div>
);
}
-566
View File
@@ -1,566 +0,0 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { themes, getGradientStyle, formatSlotDate, type Theme } from '@/lib/themes';
import LoveFusion from '@/components/LoveFusion';
import { keyFromHash, decryptField, decryptOptional, encryptField, encryptOptional } from '@/lib/crypto-client';
import type { ActivityOption, TimeSlot, Proposal } from '@prisma/client';
type FullProposal = Proposal & {
activities: (ActivityOption & { slots: TimeSlot[] })[];
};
interface Props {
proposal: FullProposal;
slug: string;
isExpired: boolean;
isAnswered: boolean;
existingAnswer: string | null;
}
interface DecryptedSlot { id: string; label: string; startsAt: string | null }
interface DecryptedActivity { id: string; emoji: string; title: string; description: string | null; slots: DecryptedSlot[] }
interface Decrypted {
key: string;
senderName: string;
recipientName: string;
title: string;
message: string;
gifUrl: string | null;
activities: DecryptedActivity[];
}
const container = { hidden: {}, show: { transition: { staggerChildren: 0.1 } } };
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 300, damping: 24 } },
};
function toGCalDate(d: Date) {
return d.toISOString().replace(/[-:.]/g, '').slice(0, 15) + 'Z';
}
function makeIcs(title: string, startsAt: Date, description: string) {
const start = toGCalDate(startsAt);
const end = toGCalDate(new Date(startsAt.getTime() + 60 * 60 * 1000));
return ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//lovelope.app//EN',
'BEGIN:VEVENT', `DTSTART:${start}`, `DTEND:${end}`,
`SUMMARY:${title}`, `DESCRIPTION:${description}`,
'END:VEVENT', 'END:VCALENDAR'].join('\r\n');
}
function CalendarButtons({ title, startsAt, description }: {
title: string; startsAt: Date; description: string;
}) {
function downloadIcs() {
const blob = new Blob([makeIcs(title, startsAt, description)], { type: 'text/calendar' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'date.ics'; a.click();
URL.revokeObjectURL(url);
}
const gCalUrl = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${toGCalDate(startsAt)}/${toGCalDate(new Date(startsAt.getTime() + 60 * 60 * 1000))}&details=${encodeURIComponent(description)}`;
return (
<div className="mt-4 flex flex-col sm:flex-row gap-2">
<a href={gCalUrl} target="_blank" rel="noopener noreferrer"
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-blue-50 text-blue-700
border border-blue-200 rounded-xl text-sm font-semibold hover:bg-blue-100 transition-colors">
📅 Add to Google Calendar
</a>
<button onClick={downloadIcs}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gray-50 text-gray-700
border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-100 transition-colors">
📥 Download .ics
</button>
</div>
);
}
export default function ProposalPageClient({
proposal, slug, isExpired, isAnswered, existingAnswer,
}: Props) {
const theme = themes[proposal.theme as Theme];
const customGradient =
proposal.gradientFrom && proposal.gradientVia && proposal.gradientTo
? { from: proposal.gradientFrom, via: proposal.gradientVia, to: proposal.gradientTo }
: null;
const bgStyle = getGradientStyle(proposal.theme as Theme, customGradient);
const [selectedActivityId, setSelectedActivityId] = useState<string | null>(null);
const [selectedSlotId, setSelectedSlotId] = useState<string | null>(null);
const [answer, setAnswer] = useState<'yes' | 'maybe' | 'no' | null>(null);
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
// existingAnswer is ciphertext until the decrypt effect below resolves it.
const [submittedAnswer, setSubmittedAnswer] = useState<string | null>(null);
const [revealed, setRevealed] = useState(false);
// End-to-end decryption: the key lives only in the URL fragment, which
// the server never sees. Everything personal is ciphertext until this runs.
const [decrypted, setDecrypted] = useState<Decrypted | null>(null);
const [keyMissing, setKeyMissing] = useState(false);
// Moving "No" button state
const [noTransform, setNoTransform] = useState('translate(0px, 0px)');
const [noRuns, setNoRuns] = useState(0);
const confettiRef = useRef<(() => void) | null>(null);
const selectedActivity = decrypted?.activities.find((a) => a.id === selectedActivityId);
const selectedSlot = selectedActivity?.slots.find((s) => s.id === selectedSlotId) ?? null;
const isDark = theme.dark;
const canSubmit = answer !== null && (answer !== 'yes' || selectedActivityId !== null);
const evasiveNo = proposal.evasiveNo;
function evadeNo() {
setNoRuns((prev) => {
const runs = prev + 1;
const spread = Math.min(80 + runs * 20, 300);
const x = (Math.random() - 0.5) * spread * 2;
const y = (Math.random() - 0.5) * spread;
const scale = runs > 4 ? Math.max(0.3, 1 - (runs - 4) * 0.12) : 1;
setNoTransform(`translate(${x}px, ${y}px) scale(${scale})`);
return runs;
});
}
const noOpacity = noRuns > 7 ? Math.max(0.15, 1 - (noRuns - 7) * 0.15) : 1;
useEffect(() => {
import('canvas-confetti').then((mod) => {
const confetti = mod.default;
confettiRef.current = () => {
confetti({ particleCount: 150, spread: 80, origin: { y: 0.6 },
colors: ['#f97316', '#ec4899', '#a855f7', '#facc15', '#34d399'] });
setTimeout(() => {
confetti({ particleCount: 80, spread: 120, origin: { x: 0, y: 0.8 } });
confetti({ particleCount: 80, spread: 120, origin: { x: 1, y: 0.8 } });
}, 400);
};
});
}, []);
useEffect(() => {
const k = keyFromHash(window.location.hash);
if (!k) { setKeyMissing(true); return; }
let cancelled = false;
(async () => {
try {
const [senderName, recipientName, title, message, gifUrl, decryptedExistingAnswer] = await Promise.all([
decryptField(k, proposal.senderName),
decryptField(k, proposal.recipientName),
decryptField(k, proposal.title),
decryptField(k, proposal.message),
decryptOptional(k, proposal.gifUrl),
decryptOptional(k, existingAnswer ?? undefined),
]);
const activities = await Promise.all(proposal.activities.map(async (a) => {
const [aTitle, aDesc] = await Promise.all([
decryptField(k, a.title),
decryptOptional(k, a.description),
]);
const slots = await Promise.all(a.slots.map(async (s) => {
const [label, startsAt] = await Promise.all([
decryptField(k, s.label),
decryptOptional(k, s.startsAt),
]);
return { id: s.id, label, startsAt: startsAt ?? null };
}));
return { id: a.id, emoji: a.emoji, title: aTitle, description: aDesc ?? null, slots };
}));
if (!cancelled) {
setDecrypted({ key: k, senderName, recipientName, title, message, gifUrl: gifUrl ?? null, activities });
if (decryptedExistingAnswer) setSubmittedAnswer(decryptedExistingAnswer);
}
} catch {
if (!cancelled) setKeyMissing(true);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function handleSubmit() {
if (!answer || !decrypted) return;
if (answer === 'yes' && !selectedActivityId) return;
setSubmitting(true);
try {
const [encAnswer, encNote] = await Promise.all([
encryptField(decrypted.key, answer),
encryptOptional(decrypted.key, note || undefined),
]);
const res = await fetch(`/api/p/${slug}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
answer: encAnswer,
selectedActivityId: selectedActivityId ?? undefined,
selectedTimeSlotId: selectedSlotId ?? undefined,
note: encNote,
}),
});
if (res.ok) {
setSubmitted(true);
setSubmittedAnswer(answer);
if (answer === 'yes') setTimeout(() => confettiRef.current?.(), 300);
}
} finally {
setSubmitting(false);
}
}
const bgClassName = bgStyle.className ? `min-h-screen ${bgStyle.className}` : 'min-h-screen';
if (isExpired) {
return (
<div className={bgClassName} style={bgStyle.style}>
<div className="flex items-center justify-center min-h-screen px-4">
<div className="bg-white rounded-3xl shadow-2xl p-8 sm:p-10 text-center max-w-md w-full">
<div className="text-5xl mb-4"></div>
<h1 className="font-display text-2xl font-extrabold text-gray-900 mb-2">
This proposal has expired
</h1>
<p className="text-gray-500">Looks like you missed the window, maybe next time!</p>
</div>
</div>
</div>
);
}
if (keyMissing) {
return (
<div className={bgClassName} style={bgStyle.style}>
<div className="flex items-center justify-center min-h-screen px-4">
<div className="bg-white rounded-3xl shadow-2xl p-8 sm:p-10 text-center max-w-md w-full">
<div className="text-5xl mb-4">🔒</div>
<h1 className="font-display text-2xl font-extrabold text-gray-900 mb-2">
This link looks incomplete
</h1>
<p className="text-gray-500">
Make sure you copied the whole link, including everything after the #.
</p>
</div>
</div>
</div>
);
}
if (!decrypted) {
return (
<div className={bgClassName} style={bgStyle.style}>
<div className="flex items-center justify-center min-h-screen px-4">
<motion.div
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ duration: 1.4, repeat: Infinity }}
className="text-6xl"
>
💌
</motion.div>
</div>
</div>
);
}
if ((isAnswered || submitted) && submittedAnswer) {
const isYes = submittedAnswer === 'yes';
const isMaybe = submittedAnswer === 'maybe';
const calendarSlot =
isYes && submitted && selectedSlot?.startsAt
? { startsAt: new Date(selectedSlot.startsAt), title: `${selectedActivity?.title ?? 'Date'} with ${decrypted.recipientName}` }
: null;
return (
<div className={bgClassName} style={bgStyle.style}>
<div className="flex items-center justify-center min-h-screen px-4">
<motion.div
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 20 }}
className="bg-white rounded-3xl shadow-2xl p-8 sm:p-10 text-center max-w-md w-full"
>
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ repeat: isYes ? Infinity : 0, duration: 1.5 }}
className="text-6xl mb-4"
>
{isYes ? '🎉' : isMaybe ? '🤔' : '💔'}
</motion.div>
<h1 className="font-display text-3xl font-extrabold text-gray-900 mb-2">
{isYes ? 'You said YES! 🎊' : isMaybe ? 'Maybe… intriguing! 🤔' : 'Oof. Maybe another time 💔'}
</h1>
<p className="text-gray-500">
{isYes
? `${decrypted.recipientName}, get ready for an amazing time!`
: isMaybe
? 'Your answer has been sent. The ball is in their court now.'
: 'Your response has been sent. You never know what the future holds.'}
</p>
{submitted && isYes && selectedActivity && (
<div className="mt-6 bg-orange-50 rounded-2xl p-4 border border-orange-100 text-left">
<p className="text-sm text-orange-700 font-semibold">
{selectedActivity.emoji} You chose: {selectedActivity.title}
</p>
{selectedSlot && (
<p className="text-xs text-orange-500 mt-1">
🗓 {formatSlotDate(selectedSlot.startsAt, selectedSlot.label)}
</p>
)}
</div>
)}
{calendarSlot && (
<CalendarButtons
title={calendarSlot.title}
startsAt={calendarSlot.startsAt}
description={`Arranged via lovelope.app: ${decrypted.title}`}
/>
)}
</motion.div>
</div>
</div>
);
}
if (!revealed) {
return (
<div className={bgClassName} style={bgStyle.style}>
<button
type="button"
onClick={() => setRevealed(true)}
className="flex flex-col items-center justify-center min-h-screen w-full px-4 text-center"
>
<motion.div
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 20 }}
>
<LoveFusion className="mb-6" />
<h1 className="font-display text-3xl sm:text-4xl font-extrabold text-white text-balance">
{decrypted.senderName} has a message for you
</h1>
<motion.p
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1.8, repeat: Infinity }}
className="text-white/70 mt-4 text-sm font-semibold uppercase tracking-widest"
>
Tap to open
</motion.p>
</motion.div>
</button>
</div>
);
}
return (
<div className={bgClassName} style={bgStyle.style}>
<div className="max-w-lg mx-auto px-4 py-6 sm:py-10">
<motion.div variants={container} initial="hidden" animate="show" className="space-y-4 sm:space-y-5">
{/* Header */}
<motion.div variants={item}
className={`${theme.cardBg} backdrop-blur-sm rounded-3xl shadow-xl p-6 sm:p-8 border ${theme.cardBorder}`}>
<p className={`text-xs font-semibold uppercase tracking-widest ${theme.accentText} mb-2`}>
A special message from {decrypted.senderName}
</p>
<h1 className={`font-display text-2xl sm:text-3xl font-extrabold ${theme.textPrimary} mb-4 text-balance`}>
{decrypted.title}
</h1>
<p className={`${theme.textSecondary} leading-relaxed whitespace-pre-wrap text-sm sm:text-base`}>
{decrypted.message}
</p>
{decrypted.gifUrl && (
<div className="mt-4 rounded-2xl overflow-hidden border border-white/20 bg-black/10 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={decrypted.gifUrl}
alt="GIF"
className="max-h-80 w-full object-contain"
loading="lazy"
/>
</div>
)}
</motion.div>
{/* Activities */}
<motion.div variants={item}>
<p className="text-xs font-bold text-white/80 uppercase tracking-widest mb-3 px-1">
Pick your favourite 👇
</p>
<div className="space-y-3">
{decrypted.activities.map((activity) => {
const isSelected = selectedActivityId === activity.id;
return (
<motion.div key={activity.id} whileTap={{ scale: 0.98 }}>
<button type="button"
onClick={() => { setSelectedActivityId(isSelected ? null : activity.id); setSelectedSlotId(null); }}
className={`w-full text-left ${theme.cardBg} rounded-2xl border-2 p-4 sm:p-5 transition-all shadow-md ${
isSelected
? `border-white shadow-lg ${isDark ? 'bg-white/20' : 'bg-white'}`
: `${theme.cardBorder} hover:border-white/50`
}`}
>
<div className="flex items-start gap-3 sm:gap-4">
<span className="text-2xl sm:text-3xl shrink-0">{activity.emoji}</span>
<div className="flex-1 min-w-0">
<h3 className={`font-display font-bold text-base sm:text-lg ${theme.textPrimary} mb-0.5`}>
{activity.title}
</h3>
{activity.description && (
<p className={`text-sm ${theme.textSecondary}`}>{activity.description}</p>
)}
</div>
<div className={`w-6 h-6 rounded-full border-2 shrink-0 flex items-center justify-center transition-all ${
isSelected
? `bg-gradient-to-br ${theme.buttonGradient} border-transparent`
: `border-gray-300 ${isDark ? 'border-gray-500' : ''}`
}`}>
{isSelected && <span className="text-white text-xs"></span>}
</div>
</div>
</button>
{/* Time slots */}
<AnimatePresence>
{isSelected && activity.slots.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className={`${theme.cardBg} rounded-b-2xl border-2 border-t-0 ${
isSelected ? 'border-white' : theme.cardBorder
} p-4`}>
<p className={`text-xs font-semibold uppercase tracking-wider ${theme.textSecondary} mb-3`}>
Pick a time
</p>
<div className="flex flex-wrap gap-2">
{activity.slots.map((slot) => (
<button key={slot.id} type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedSlotId(selectedSlotId === slot.id ? null : slot.id);
}}
className={`px-3 py-2 rounded-xl text-sm font-semibold transition-all ${
selectedSlotId === slot.id
? `bg-gradient-to-r ${theme.buttonGradient} text-white shadow-md`
: isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
🗓 {formatSlotDate(slot.startsAt, slot.label)}
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
</motion.div>
{/* Note */}
<motion.div variants={item}
className={`${theme.cardBg} rounded-2xl border ${theme.cardBorder} p-4 sm:p-5 shadow-md`}>
<label className={`block text-sm font-semibold ${theme.textSecondary} mb-2`}>
Leave a note (optional)
</label>
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={3}
placeholder="Anything you want to say…"
className={`w-full px-4 py-3 rounded-xl border outline-none transition resize-none text-sm ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-gray-400 focus:border-white/40'
: 'bg-gray-50 border-gray-200 text-gray-800 placeholder-gray-400 focus:border-pink-300 focus:ring-2 focus:ring-pink-100'
}`}
/>
</motion.div>
{/* Answer buttons */}
<motion.div variants={item}
className={`${theme.cardBg} rounded-2xl border ${theme.cardBorder} p-4 sm:p-5 shadow-md`}>
<p className={`text-sm font-semibold ${theme.textSecondary} mb-3`}>
And your answer is
</p>
<div className="relative" style={{ minHeight: '80px' }}>
<div className="grid grid-cols-2 gap-3 mb-3">
{([
{ key: 'yes' as const, emoji: '🎉', label: 'Yes!' },
{ key: 'maybe' as const, emoji: '🤔', label: 'Maybe' },
]).map(({ key, emoji, label }) => (
<motion.button key={key} type="button" whileTap={{ scale: 0.94 }}
onClick={() => setAnswer(answer === key ? null : key)}
className={`flex flex-col items-center gap-1.5 py-4 rounded-2xl border-2 font-semibold transition-all ${
answer === key
? `bg-gradient-to-br ${theme.buttonGradient} border-transparent text-white shadow-lg`
: isDark
? 'border-white/20 text-white hover:border-white/40'
: 'border-gray-200 text-gray-600 hover:border-pink-300'
}`}
>
<span className="text-2xl">{emoji}</span>
<span className="text-sm">{label}</span>
</motion.button>
))}
</div>
{/* No button: runs away when evasiveNo is enabled */}
<div
style={evasiveNo ? {
transform: noTransform,
opacity: noOpacity,
transition: 'transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease',
display: 'inline-block',
position: 'relative',
zIndex: 10,
} : { display: 'inline-block' }}
onMouseEnter={evasiveNo ? evadeNo : undefined}
>
<motion.button type="button" whileTap={{ scale: 0.94 }}
onClick={() => {
if (evasiveNo && noRuns < 5) {
evadeNo();
return;
}
setAnswer(answer === 'no' ? null : 'no');
}}
className={`flex flex-col items-center gap-1.5 py-4 px-8 rounded-2xl border-2 font-semibold transition-colors ${
answer === 'no'
? `bg-gradient-to-br ${theme.buttonGradient} border-transparent text-white shadow-lg`
: isDark
? 'border-white/20 text-white hover:border-white/40'
: 'border-gray-200 text-gray-600 hover:border-pink-300'
}`}
>
<span className="text-2xl">💔</span>
<span className="text-sm">No</span>
</motion.button>
</div>
</div>
{answer === 'yes' && !selectedActivityId && (
<p className={`mt-2 text-xs font-semibold text-center ${isDark ? 'text-yellow-300' : 'text-orange-500'}`}>
Pick an activity above before sending!
</p>
)}
<motion.button type="button" whileTap={{ scale: 0.97 }}
disabled={!canSubmit || submitting}
onClick={() => void handleSubmit()}
className={`mt-4 w-full py-4 rounded-2xl font-display font-bold text-lg text-white shadow-lg
bg-gradient-to-r ${theme.buttonGradient}
hover:shadow-xl transform hover:-translate-y-0.5 transition-all
disabled:opacity-40 disabled:cursor-not-allowed disabled:transform-none`}
>
{submitting ? 'Sending…' : 'Send my answer 💌'}
</motion.button>
</motion.div>
</motion.div>
</div>
</div>
);
}
-50
View File
@@ -1,50 +0,0 @@
import Link from 'next/link';
import LoveFusion from '@/components/LoveFusion';
export default function HomePage() {
return (
<main className="min-h-screen bg-gradient-to-br from-orange-400 via-pink-500 to-rose-500 flex flex-col items-center justify-center px-4 text-white">
<div className="text-center max-w-2xl w-full">
<LoveFusion className="mb-6" />
<h1 className="font-display text-5xl md:text-6xl font-extrabold mb-4 text-balance">
The adorable way to ask someone out
</h1>
<p className="text-xl md:text-2xl text-white/80 mb-10 text-balance">
Create a personalized proposal, share the link, and watch the magic happen.
</p>
{/* Primary CTA */}
<div className="bg-white/20 backdrop-blur-sm rounded-3xl p-6 mb-6 border border-white/30">
<Link
href="/create"
className="block w-full bg-white text-pink-600 font-extrabold px-8 py-5 rounded-2xl text-xl
shadow-lg hover:shadow-xl transform hover:-translate-y-1 transition-all"
>
Create your proposal 🚀
</Link>
<p className="text-white/60 text-sm mt-3">
You&apos;ll get two private links: one to share, one to manage.
</p>
</div>
{/* Feature cards */}
<div className="mt-10 grid grid-cols-1 sm:grid-cols-3 gap-4 text-left">
{[
{ emoji: '🎨', title: 'Pick a theme', desc: 'Sunset, ocean, midnight… or design your own gradient.' },
{ emoji: '🗓', title: 'Real date picker', desc: 'Offer specific date & time slots for each activity.' },
{ emoji: '🎭', title: 'Add a GIF', desc: 'Make your proposal even more irresistible with a GIF.' },
].map((step) => (
<div
key={step.title}
className="bg-white/20 backdrop-blur-sm rounded-2xl p-5 border border-white/30"
>
<div className="text-3xl mb-2">{step.emoji}</div>
<h3 className="font-display font-bold text-lg mb-1">{step.title}</h3>
<p className="text-white/70 text-sm">{step.desc}</p>
</div>
))}
</div>
</div>
</main>
);
}
-44
View File
@@ -1,44 +0,0 @@
import Link from 'next/link';
const GITHUB_URL = 'https://github.com/jeangaston/lovelope';
export default function Footer() {
return (
<footer className="border-t border-gray-100 bg-white/80 backdrop-blur-sm mt-auto">
<div className="max-w-5xl mx-auto px-4 py-5 flex flex-col gap-3 text-sm text-gray-400">
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-base">💌</span>
<span>
<Link href="/" className="font-semibold text-gray-600 hover:text-pink-500 transition-colors">
lovelope.app
</Link>
{' '}· the adorable way to ask someone out
</span>
</div>
<div className="flex items-center gap-4">
<span className="text-xs hidden sm:inline text-gray-300">Proposals auto-delete after 30 days</span>
<Link href="/create" className="text-pink-500 hover:underline font-medium text-xs">
Create a proposal
</Link>
</div>
</div>
<div className="flex items-center justify-center gap-3 text-xs text-gray-300 border-t border-gray-100 pt-3">
<span>Made with by jeangaston</span>
<span className="text-gray-200">·</span>
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 hover:text-pink-500 transition-colors"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.09 3.29 9.4 7.86 10.93.57.1.79-.25.79-.55v-2.14c-3.2.7-3.87-1.36-3.87-1.36-.53-1.33-1.29-1.68-1.29-1.68-1.05-.72.08-.7.08-.7 1.17.08 1.78 1.2 1.78 1.2 1.03 1.77 2.71 1.26 3.37.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.7 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.18 1.18a10.98 10.98 0 0 1 5.79 0c2.2-1.49 3.17-1.18 3.17-1.18.64 1.59.24 2.76.12 3.05.74.81 1.18 1.83 1.18 3.09 0 4.43-2.7 5.4-5.27 5.69.42.36.78 1.07.78 2.16v3.2c0 .3.21.66.8.55C20.21 21.39 23.5 17.08 23.5 12c0-6.35-5.15-11.5-11.5-11.5Z" />
</svg>
GitHub
</a>
</div>
</div>
</footer>
);
}
-83
View File
@@ -1,83 +0,0 @@
'use client';
import { motion } from 'framer-motion';
// ✉️ + ❤️ = 💌, "envelope" + "love" = "lovelope.app": everything collides,
// contracts into a single point, then bursts into the merged result. Plays
// once, on mount, then holds its final state (no loop).
const times = [0, 0.28, 0.5, 0.58, 0.66, 0.78, 0.88, 1];
const transition = { duration: 2.4, times, ease: 'easeInOut' as const };
export default function LoveFusion({ className = '' }: { className?: string }) {
return (
<div className={`flex flex-col items-center ${className}`}>
<div className="grid place-items-center h-44">
<motion.span
style={{ gridRow: 1, gridColumn: 1 }}
className="text-8xl"
initial={{ x: -72, opacity: 1, scale: 1, rotate: 0 }}
animate={{ x: [-72, -72, 0, 0, 0, 0, 0, 0], opacity: [1, 1, 1, 1, 0, 0, 0, 0], scale: [1, 1, 1, 0.5, 0, 0, 0, 0], rotate: [0, -8, 0, 0, 0, 0, 0, 0] }}
transition={transition}
>
</motion.span>
<motion.span
style={{ gridRow: 1, gridColumn: 1 }}
className="text-8xl"
initial={{ x: 72, opacity: 1, scale: 1, rotate: 0 }}
animate={{ x: [72, 72, 0, 0, 0, 0, 0, 0], opacity: [1, 1, 1, 1, 0, 0, 0, 0], scale: [1, 1, 1, 0.5, 0, 0, 0, 0], rotate: [0, 8, 0, 0, 0, 0, 0, 0] }}
transition={transition}
>
</motion.span>
<motion.div
style={{ gridRow: 1, gridColumn: 1 }}
className="w-5 h-5 rounded-full bg-white shadow-[0_0_28px_12px_rgba(255,255,255,0.85)]"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: [0, 0, 0, 0.9, 1, 0, 0, 0], scale: [0, 0, 0, 0.15, 0.22, 1.8, 0, 0] }}
transition={transition}
/>
<motion.span
style={{ gridRow: 1, gridColumn: 1 }}
className="text-9xl"
initial={{ opacity: 0, scale: 0.3 }}
animate={{ opacity: [0, 0, 0, 0, 0, 0.15, 1, 1], scale: [0.3, 0.3, 0.3, 0.3, 0.3, 0.5, 1.18, 1] }}
transition={transition}
>
💌
</motion.span>
</div>
<div className="grid place-items-center h-14 mt-1">
<motion.span
style={{ gridRow: 1, gridColumn: 1 }}
className="font-display font-extrabold text-2xl sm:text-4xl text-white tracking-tight whitespace-nowrap"
initial={{ x: -60, opacity: 1, scale: 1 }}
animate={{ x: [-60, -60, 0, 0, 0, 0, 0, 0], opacity: [1, 1, 1, 1, 0, 0, 0, 0], scale: [1, 1, 1, 0.5, 0, 0, 0, 0] }}
transition={transition}
>
envelope
</motion.span>
<motion.span
style={{ gridRow: 1, gridColumn: 1 }}
className="font-display font-extrabold text-2xl sm:text-4xl text-yellow-300 tracking-tight whitespace-nowrap"
initial={{ x: 60, opacity: 1, scale: 1 }}
animate={{ x: [60, 60, 0, 0, 0, 0, 0, 0], opacity: [1, 1, 1, 1, 0, 0, 0, 0], scale: [1, 1, 1, 0.5, 0, 0, 0, 0] }}
transition={transition}
>
love
</motion.span>
<motion.span
style={{ gridRow: 1, gridColumn: 1 }}
className="font-display font-extrabold text-2xl sm:text-4xl tracking-tight whitespace-nowrap"
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: [0, 0, 0, 0, 0, 0.2, 1, 1], scale: [0.6, 0.6, 0.6, 0.6, 0.6, 0.8, 1.15, 1] }}
transition={transition}
>
<span className="bg-gradient-to-r from-white to-yellow-300 bg-clip-text text-transparent">lovelope</span>
<span className="text-white/70">.app</span>
</motion.span>
</div>
</div>
);
}
-581
View File
@@ -1,581 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { themes, type Theme } from '@/lib/themes';
export interface SlotData {
label: string;
startsAt: string;
}
export interface ActivityData {
title: string;
description: string;
emoji: string;
slots: SlotData[];
}
export interface ProposalFormData {
senderName: string;
recipientName: string;
title: string;
message: string;
theme: Theme;
gradientFrom?: string;
gradientVia?: string;
gradientTo?: string;
gifUrl?: string;
evasiveNo: boolean;
activities: ActivityData[];
}
interface Props {
onSubmit: (data: ProposalFormData, publish: boolean) => Promise<void>;
submitError?: string;
publishLabel?: string;
hideDraft?: boolean;
}
const EMOJI_PRESETS = ['🎉', '🍷', '🎨', '🌅', '🎭', '🎸', '🏄', '🍕', '☕', '🎬', '🎮', '🌙'];
const BLANK_ACTIVITY: ActivityData = { title: '', description: '', emoji: '🎉', slots: [] };
const DEFAULT_COLORS: Record<Theme, [string, string, string]> = {
sunset: ['#fb923c', '#ec4899', '#f43f5e'],
neon: ['#4ade80', '#06b6d4', '#2563eb'],
pastel: ['#d8b4fe', '#f9a8d4', '#fecdd3'],
cherry: ['#ef4444', '#f43f5e', '#db2777'],
ocean: ['#60a5fa', '#22d3ee', '#14b8a6'],
midnight: ['#3730a3', '#6b21a8', '#1e3a8a'],
};
const COLOR_SWATCHES = [
'#ef4444', '#f97316', '#f59e0b', '#84cc16',
'#22c55e', '#14b8a6', '#06b6d4', '#3b82f6',
'#6366f1', '#a855f7', '#ec4899', '#f43f5e',
'#fca5a5', '#fed7aa', '#fef9c3', '#bbf7d0',
'#bfdbfe', '#ddd6fe', '#fce7f3', '#f1f5f9',
'#ffffff', '#94a3b8', '#334155', '#0f172a',
];
const inputCls =
'w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-pink-400 focus:ring-2 focus:ring-pink-100 outline-none transition';
const primaryBtn =
'flex-1 py-3 bg-gradient-to-r from-orange-500 to-pink-500 text-white font-bold rounded-xl disabled:opacity-40 hover:shadow-md transition-all w-full';
const secondaryBtn =
'flex-1 py-3 border border-gray-200 text-gray-600 font-semibold rounded-xl hover:bg-gray-50 transition-colors';
type GradientStop = 'From' | 'Via' | 'To';
const STOP_LABELS: Record<GradientStop, string> = { From: 'From', Via: 'Via', To: 'To' };
function ColorPicker({
gradFrom, gradVia, gradTo, setGradFrom, setGradVia, setGradTo,
}: {
gradFrom: string; gradVia: string; gradTo: string;
setGradFrom: (c: string) => void; setGradVia: (c: string) => void; setGradTo: (c: string) => void;
}) {
const [activeStop, setActiveStop] = useState<GradientStop>('From');
const vals: Record<GradientStop, string> = { From: gradFrom, Via: gradVia, To: gradTo };
const setters: Record<GradientStop, (c: string) => void> = { From: setGradFrom, Via: setGradVia, To: setGradTo };
const currentColor = vals[activeStop];
return (
<div className="space-y-3 mt-3">
<div className="flex gap-2">
{(['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'
}`}
>
<span className="w-5 h-5 rounded-full border border-gray-300 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>}
</button>
))}
</div>
<div className="grid grid-cols-8 gap-1.5 p-3 bg-gray-50 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 ${
currentColor.toLowerCase() === color.toLowerCase()
? 'border-gray-900 scale-110 shadow-md'
: 'border-transparent hover:scale-110 hover:shadow-sm'
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="flex items-center gap-2">
<span className="w-9 h-9 rounded-xl border-2 border-gray-200 shrink-0"
style={{ backgroundColor: currentColor }} />
<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"
/>
</div>
</div>
);
}
interface GifResult { id: string; title: string; url: string; preview: string; }
function GifPicker({ value, onChange }: { value: string; onChange: (url: string) => void }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<GifResult[]>([]);
const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
useEffect(() => {
const q = query.trim();
if (!q) { setResults([]); setSearched(false); return; }
setSearching(true);
const timer = setTimeout(() => {
fetch(`/api/gif/search?q=${encodeURIComponent(q)}`)
.then((res) => res.json())
.then((json: { data: GifResult[] }) => setResults(json.data ?? []))
.finally(() => { setSearching(false); setSearched(true); });
}, 350);
return () => clearTimeout(timer);
}, [query]);
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">
{/* 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
justify-center text-sm font-bold hover:bg-black/80 transition-colors">
×
</button>
</div>
);
}
return (
<div className="space-y-2 mt-2">
<input
type="text" value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a GIF…"
className={inputCls}
/>
{results.length > 0 && (
<div className="grid grid-cols-3 gap-1.5 max-h-52 overflow-y-auto rounded-xl">
{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">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={g.preview} alt={g.title} className="w-full h-full object-cover" />
</button>
))}
</div>
)}
{searching && (
<p className="text-sm text-gray-400 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-xs text-gray-300 text-center pt-1">Powered by Giphy</p>
</div>
);
}
function formatDatetimeLabel(dt: string): string {
if (!dt) return '';
const d = new Date(dt);
if (isNaN(d.getTime())) return dt;
return d.toLocaleDateString('en-US', {
weekday: 'short', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
});
}
export default function ProposalForm({
onSubmit, submitError, publishLabel = '🚀 Publish!', hideDraft = false,
}: Props) {
const [step, setStep] = useState<'details' | 'activities' | 'theme'>('details');
const [submitting, setSubmitting] = useState(false);
// Details
const [senderName, setSenderName] = useState('');
const [recipientName, setRecipientName] = useState('');
const [title, setTitle] = useState('');
const [message, setMessage] = useState('');
// Activities
const [activities, setActivities] = useState<ActivityData[]>([
{ ...BLANK_ACTIVITY },
{ ...BLANK_ACTIVITY },
]);
// Theme
const [theme, setTheme] = useState<Theme>('sunset');
const [useCustom, setUseCustom] = useState(false);
const [gradFrom, setGradFrom] = useState(DEFAULT_COLORS.sunset[0]);
const [gradVia, setGradVia] = useState(DEFAULT_COLORS.sunset[1]);
const [gradTo, setGradTo] = useState(DEFAULT_COLORS.sunset[2]);
// GIF + evasive No
const [gifUrl, setGifUrl] = useState('');
const [evasiveNo, setEvasiveNo] = useState(false);
function updateActivity(i: number, patch: Partial<ActivityData>) {
setActivities((prev) => prev.map((a, idx) => (idx === i ? { ...a, ...patch } : a)));
}
function addSlot(ai: number) {
const now = new Date();
now.setMinutes(0, 0, 0);
now.setDate(now.getDate() + 7);
const isoLocal = now.toISOString().slice(0, 16);
updateActivity(ai, {
slots: [...activities[ai].slots, { label: formatDatetimeLabel(isoLocal), startsAt: isoLocal }],
});
}
function updateSlot(ai: number, si: number, startsAt: string) {
updateActivity(ai, {
slots: activities[ai].slots.map((s, idx) =>
idx === si ? { label: formatDatetimeLabel(startsAt), startsAt } : s
),
});
}
function removeSlot(ai: number, si: number) {
updateActivity(ai, { slots: activities[ai].slots.filter((_, idx) => idx !== si) });
}
function removeActivity(i: number) {
setActivities((prev) => prev.filter((_, idx) => idx !== i));
}
function addActivity() {
setActivities((prev) => [...prev, { ...BLANK_ACTIVITY }]);
}
function handleThemeChange(t: Theme) {
setTheme(t);
const [f, v, o] = DEFAULT_COLORS[t];
setGradFrom(f); setGradVia(v); setGradTo(o);
}
async function handleSubmit(publish: boolean) {
setSubmitting(true);
try {
await onSubmit(
{
senderName,
recipientName,
title,
message,
theme,
...(useCustom ? { gradientFrom: gradFrom, gradientVia: gradVia, gradientTo: gradTo } : {}),
...(gifUrl.trim() ? { gifUrl: gifUrl.trim() } : {}),
evasiveNo,
activities: activities.map((a) => ({
...a,
slots: a.slots.filter((s) => s.startsAt),
})),
},
publish
);
} finally {
setSubmitting(false);
}
}
const themeList = Object.entries(themes) as [Theme, (typeof themes)[Theme]][];
const gradientPreview = useCustom
? { style: { background: `linear-gradient(to bottom right, ${gradFrom}, ${gradVia}, ${gradTo})` } }
: { className: `bg-gradient-to-br ${themes[theme].gradient}` };
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)}
className={`flex-1 py-2.5 rounded-xl text-sm font-semibold transition-all ${
step === s ? 'bg-white shadow text-gray-900' : 'text-gray-500 hover:text-gray-700'
}`}
>
{i + 1}. {steps[i]}
</button>
))}
</div>
{/* ── Step 1: Details ── */}
{step === 'details' && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 sm:p-6 space-y-4">
<Field label="Your first name">
<input type="text" required maxLength={60}
value={senderName} onChange={(e) => setSenderName(e.target.value)}
placeholder="Jordan"
className={inputCls}
/>
</Field>
<Field label="Their first name">
<input type="text" required maxLength={60}
value={recipientName} onChange={(e) => setRecipientName(e.target.value)}
placeholder="Alex"
className={inputCls}
/>
</Field>
<Field label="Proposal title">
<input 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">
<textarea required maxLength={2000} rows={4}
value={message} onChange={(e) => setMessage(e.target.value)}
placeholder={`Hey ${recipientName || '…'} !`}
className={`${inputCls} resize-none`}
/>
<p className="text-xs text-gray-400 text-right mt-1">{message.length}/2000</p>
</Field>
<button type="button" disabled={!detailsOk} onClick={() => setStep('activities')}
className={primaryBtn}>
Next: Activities
</button>
</div>
)}
{/* ── Step 2: Activities ── */}
{step === 'activities' && (
<div className="space-y-4">
{activities.map((act, ai) => (
<div key={ai} className="bg-white rounded-2xl border border-gray-100 shadow-sm 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>
</div>
))}
{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}
/>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 sm:p-6 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>}
</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>
{/* 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 &ldquo;No&rdquo; 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'
}`} />
</button>
</div>
</div>
</div>
{submitError && (
<p className="text-red-600 text-sm bg-red-50 border border-red-200 rounded-xl px-4 py-3">
{submitError}
</p>
)}
<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="flex-1 py-3 border border-gray-300 text-gray-700 font-semibold rounded-xl
hover:bg-gray-50 transition-colors disabled:opacity-50">
Save as draft
</button>
)}
<button type="button" disabled={submitting} onClick={() => void handleSubmit(true)}
className="flex-1 py-3 text-white font-bold rounded-xl hover:shadow-md transition-all
disabled:opacity-50 bg-gradient-to-r from-orange-500 to-pink-500">
{submitting ? '…' : publishLabel}
</button>
</div>
</div>
)}
</div>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">{label}</label>
{hint && <p className="text-xs text-gray-400 mb-1.5">{hint}</p>}
{children}
</div>
);
}