Files
lovelope/src/app/create/CreateClient.tsx
T
jeanGaston 1b4ddd043a fix(create): surface submit errors and fix keyboard overlap on mobile
The create-proposal submit had no error handling, so failures (e.g.
crypto.subtle unavailable outside a secure context) silently reset the
button with no feedback. Also set interactiveWidget so the mobile
keyboard resizes the layout instead of covering the sticky submit button.
2026-07-30 10:16:56 +02:00

160 lines
5.5 KiB
TypeScript

'use client';
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 { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
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),
location: await encryptOptional(key, a.location),
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('');
async function handleSubmit(data: ProposalFormData, _publish: boolean) {
setError('');
if (typeof crypto === 'undefined' || !crypto.subtle) {
setError('Your browser blocked secure encryption here (this page needs HTTPS). Try opening the site directly instead of through an in-app browser.');
return;
}
try {
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}`,
});
} catch {
setError('Something went wrong creating your proposal. Please try again.');
}
}
function copyLink(url: string) {
navigator.clipboard.writeText(url);
toast.success('Link copied');
}
if (links) {
return (
<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-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">
<LinkBox
emoji="📤"
label="Share with them"
sublabel="Send this link to the person you're asking out"
url={links.publicUrl}
onCopy={() => copyLink(links.publicUrl)}
highlight
/>
<LinkBox
emoji="🔒"
label="Your private management link"
sublabel="Bookmark this: it lets you see their response and delete the proposal"
url={links.manageUrl}
onCopy={() => copyLink(links.manageUrl)}
/>
</div>
<p className="text-xs text-muted-foreground">
Want to create another one?{' '}
<Link href="/create" className="text-primary font-semibold hover:underline">
Make a new proposal
</Link>
</p>
</Card>
);
}
return <ProposalForm onSubmit={handleSubmit} submitError={error} publishLabel="🚀 Create & share!" hideDraft />;
}
function LinkBox({
emoji, label, sublabel, url, onCopy, highlight,
}: {
emoji: string; label: string; sublabel: string; url: string;
onCopy: () => void; highlight?: boolean;
}) {
return (
<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-foreground text-sm">{label}</p>
<p className="text-xs text-muted-foreground">{sublabel}</p>
</div>
</div>
<div className="flex gap-2">
<Input
readOnly value={url} aria-label={label}
className="h-10 flex-1 text-xs bg-background min-w-0"
/>
<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-primary hover:underline mt-1.5 truncate py-0.5">
{url}
</a>
</div>
);
}