feat: allow a single activity proposition and add optional location field

Senders no longer need to fill in two activities to publish; one is
now enough (still capped at 5). Each activity can also specify a
place, which flows into the generated Google Calendar link and .ics
file when the recipient picks a slot.
This commit is contained in:
2026-07-29 20:45:24 +02:00
parent 80bb4c9474
commit 6ea31e8ac0
9 changed files with 55 additions and 18 deletions
+1
View File
@@ -35,6 +35,7 @@ model ActivityOption {
proposal Proposal @relation(fields: [proposalId], references: [id], onDelete: Cascade) proposal Proposal @relation(fields: [proposalId], references: [id], onDelete: Cascade)
title String title String
description String? description String?
location String?
emoji String @default("🎉") emoji String @default("🎉")
order Int @default(0) order Int @default(0)
slots TimeSlot[] slots TimeSlot[]
+1
View File
@@ -23,6 +23,7 @@ async function encryptPayload(key: string, data: ProposalFormData) {
data.activities.map(async (a) => ({ data.activities.map(async (a) => ({
title: await encryptField(key, a.title), title: await encryptField(key, a.title),
description: await encryptOptional(key, a.description), description: await encryptOptional(key, a.description),
location: await encryptOptional(key, a.location),
emoji: a.emoji, emoji: a.emoji,
slots: await Promise.all( slots: await Promise.all(
a.slots.map(async (s) => ({ a.slots.map(async (s) => ({
+12 -3
View File
@@ -33,7 +33,7 @@ type FullProposal = Proposal & {
}; };
interface DecryptedSlot { id: string; label: string; startsAt: 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 DecryptedActivity { id: string; emoji: string; title: string; description: string | null; location: string | null; slots: DecryptedSlot[] }
interface DecryptedManage { interface DecryptedManage {
title: string; title: string;
recipientName: string; recipientName: string;
@@ -90,9 +90,10 @@ export default function ManageClient({
decryptField(k, proposal.recipientName), decryptField(k, proposal.recipientName),
]); ]);
const activities = await Promise.all(proposal.activities.map(async (a) => { const activities = await Promise.all(proposal.activities.map(async (a) => {
const [aTitle, aDesc] = await Promise.all([ const [aTitle, aDesc, aLocation] = await Promise.all([
decryptField(k, a.title), decryptField(k, a.title),
decryptOptional(k, a.description), decryptOptional(k, a.description),
decryptOptional(k, a.location),
]); ]);
const slots = await Promise.all(a.slots.map(async (s) => { const slots = await Promise.all(a.slots.map(async (s) => {
const [label, startsAt] = await Promise.all([ const [label, startsAt] = await Promise.all([
@@ -101,7 +102,7 @@ export default function ManageClient({
]); ]);
return { id: s.id, label, startsAt: startsAt ?? null }; return { id: s.id, label, startsAt: startsAt ?? null };
})); }));
return { id: a.id, emoji: a.emoji, title: aTitle, description: aDesc ?? null, slots }; return { id: a.id, emoji: a.emoji, title: aTitle, description: aDesc ?? null, location: aLocation ?? null, slots };
})); }));
let response: DecryptedManage['response'] = null; let response: DecryptedManage['response'] = null;
if (proposal.response) { if (proposal.response) {
@@ -159,6 +160,7 @@ export default function ManageClient({
? { ? {
startsAt: new Date(selectedSlot.startsAt), startsAt: new Date(selectedSlot.startsAt),
title: `${selectedActivity?.title ?? 'Date'} with ${decrypted.recipientName}`, title: `${selectedActivity?.title ?? 'Date'} with ${decrypted.recipientName}`,
location: selectedActivity?.location ?? undefined,
} }
: null; : null;
@@ -237,6 +239,11 @@ export default function ManageClient({
🗓 {formatSlotDate(selectedSlot.startsAt, selectedSlot.label)} 🗓 {formatSlotDate(selectedSlot.startsAt, selectedSlot.label)}
</p> </p>
)} )}
{selectedActivity.location && (
<p className="text-sm text-muted-foreground mt-1">
📍 {selectedActivity.location}
</p>
)}
</div> </div>
)} )}
{decrypted.response.note && ( {decrypted.response.note && (
@@ -250,6 +257,7 @@ export default function ManageClient({
title={calendarSlot.title} title={calendarSlot.title}
startsAt={calendarSlot.startsAt} startsAt={calendarSlot.startsAt}
description={`Arranged via lovelope.app: ${decrypted.title}`} description={`Arranged via lovelope.app: ${decrypted.title}`}
location={calendarSlot.location}
/> />
)} )}
</Card> </Card>
@@ -327,6 +335,7 @@ export default function ManageClient({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-semibold text-foreground text-sm">{a.title}</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.description && <p className="text-xs text-muted-foreground">{a.description}</p>}
{a.location && <p className="text-xs text-muted-foreground">📍 {a.location}</p>}
{a.slots.length > 0 && ( {a.slots.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
{a.slots.map((s) => ( {a.slots.map((s) => (
+18 -4
View File
@@ -21,7 +21,7 @@ interface Props {
} }
interface DecryptedSlot { id: string; label: string; startsAt: 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 DecryptedActivity { id: string; emoji: string; title: string; description: string | null; location: string | null; slots: DecryptedSlot[] }
interface Decrypted { interface Decrypted {
key: string; key: string;
senderName: string; senderName: string;
@@ -117,9 +117,10 @@ export default function ProposalPageClient({
decryptOptional(k, existingAnswer ?? undefined), decryptOptional(k, existingAnswer ?? undefined),
]); ]);
const activities = await Promise.all(proposal.activities.map(async (a) => { const activities = await Promise.all(proposal.activities.map(async (a) => {
const [aTitle, aDesc] = await Promise.all([ const [aTitle, aDesc, aLocation] = await Promise.all([
decryptField(k, a.title), decryptField(k, a.title),
decryptOptional(k, a.description), decryptOptional(k, a.description),
decryptOptional(k, a.location),
]); ]);
const slots = await Promise.all(a.slots.map(async (s) => { const slots = await Promise.all(a.slots.map(async (s) => {
const [label, startsAt] = await Promise.all([ const [label, startsAt] = await Promise.all([
@@ -128,7 +129,7 @@ export default function ProposalPageClient({
]); ]);
return { id: s.id, label, startsAt: startsAt ?? null }; return { id: s.id, label, startsAt: startsAt ?? null };
})); }));
return { id: a.id, emoji: a.emoji, title: aTitle, description: aDesc ?? null, slots }; return { id: a.id, emoji: a.emoji, title: aTitle, description: aDesc ?? null, location: aLocation ?? null, slots };
})); }));
if (!cancelled) { if (!cancelled) {
setDecrypted({ key: k, senderName, recipientName, title, message, gifUrl: gifUrl ?? null, activities }); setDecrypted({ key: k, senderName, recipientName, title, message, gifUrl: gifUrl ?? null, activities });
@@ -228,7 +229,11 @@ export default function ProposalPageClient({
const isMaybe = submittedAnswer === 'maybe'; const isMaybe = submittedAnswer === 'maybe';
const calendarSlot = const calendarSlot =
isYes && submitted && selectedSlot?.startsAt isYes && submitted && selectedSlot?.startsAt
? { startsAt: new Date(selectedSlot.startsAt), title: `${selectedActivity?.title ?? 'Date'} with ${decrypted.recipientName}` } ? {
startsAt: new Date(selectedSlot.startsAt),
title: `${selectedActivity?.title ?? 'Date'} with ${decrypted.recipientName}`,
location: selectedActivity?.location ?? undefined,
}
: null; : null;
return ( return (
@@ -267,6 +272,11 @@ export default function ProposalPageClient({
🗓 {formatSlotDate(selectedSlot.startsAt, selectedSlot.label)} 🗓 {formatSlotDate(selectedSlot.startsAt, selectedSlot.label)}
</p> </p>
)} )}
{selectedActivity.location && (
<p className="text-xs text-orange-500 mt-1">
📍 {selectedActivity.location}
</p>
)}
</div> </div>
)} )}
{calendarSlot && ( {calendarSlot && (
@@ -274,6 +284,7 @@ export default function ProposalPageClient({
title={calendarSlot.title} title={calendarSlot.title}
startsAt={calendarSlot.startsAt} startsAt={calendarSlot.startsAt}
description={`Arranged via lovelope.app: ${decrypted.title}`} description={`Arranged via lovelope.app: ${decrypted.title}`}
location={calendarSlot.location}
/> />
)} )}
</motion.div> </motion.div>
@@ -371,6 +382,9 @@ export default function ProposalPageClient({
{activity.description && ( {activity.description && (
<p className={`text-sm ${theme.textSecondary}`}>{activity.description}</p> <p className={`text-sm ${theme.textSecondary}`}>{activity.description}</p>
)} )}
{activity.location && (
<p className={`text-xs ${theme.textSecondary} mt-0.5`}>📍 {activity.location}</p>
)}
</div> </div>
<div className={`w-6 h-6 rounded-full border-2 shrink-0 flex items-center justify-center transition-all ${ <div className={`w-6 h-6 rounded-full border-2 shrink-0 flex items-center justify-center transition-all ${
isSelected isSelected
+4 -3
View File
@@ -6,11 +6,12 @@ interface Props {
title: string; title: string;
startsAt: Date; startsAt: Date;
description: string; description: string;
location?: string;
} }
export default function CalendarButtons({ title, startsAt, description }: Props) { export default function CalendarButtons({ title, startsAt, description, location }: Props) {
function downloadIcs() { function downloadIcs() {
const blob = new Blob([makeIcs(title, startsAt, description)], { type: 'text/calendar' }); const blob = new Blob([makeIcs(title, startsAt, description, location)], { type: 'text/calendar' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
@@ -22,7 +23,7 @@ export default function CalendarButtons({ title, startsAt, description }: Props)
return ( return (
<div className="mt-4 flex flex-col sm:flex-row gap-2"> <div className="mt-4 flex flex-col sm:flex-row gap-2">
<a <a
href={makeGCalUrl(title, startsAt, description)} href={makeGCalUrl(title, startsAt, description, location)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex-1 flex items-center justify-center gap-2 min-h-11 px-4 py-3 bg-blue-50 text-blue-700 className="flex-1 flex items-center justify-center gap-2 min-h-11 px-4 py-3 bg-blue-50 text-blue-700
+11 -4
View File
@@ -19,6 +19,7 @@ export interface SlotData {
export interface ActivityData { export interface ActivityData {
title: string; title: string;
description: string; description: string;
location: string;
emoji: string; emoji: string;
slots: SlotData[]; slots: SlotData[];
} }
@@ -45,7 +46,7 @@ interface Props {
} }
const EMOJI_PRESETS = ['🎉', '🍷', '🎨', '🌅', '🎭', '🎸', '🏄', '🍕', '☕', '🎬', '🎮', '🌙']; const EMOJI_PRESETS = ['🎉', '🍷', '🎨', '🌅', '🎭', '🎸', '🏄', '🍕', '☕', '🎬', '🎮', '🌙'];
const BLANK_ACTIVITY: ActivityData = { title: '', description: '', emoji: '🎉', slots: [] }; const BLANK_ACTIVITY: ActivityData = { title: '', description: '', location: '', emoji: '🎉', slots: [] };
const DEFAULT_COLORS: Record<Theme, [string, string, string]> = { const DEFAULT_COLORS: Record<Theme, [string, string, string]> = {
sunset: ['#fb923c', '#ec4899', '#f43f5e'], sunset: ['#fb923c', '#ec4899', '#f43f5e'],
@@ -243,7 +244,6 @@ export default function ProposalForm({
// Activities // Activities
const [activities, setActivities] = useState<ActivityData[]>([ const [activities, setActivities] = useState<ActivityData[]>([
{ ...BLANK_ACTIVITY }, { ...BLANK_ACTIVITY },
{ ...BLANK_ACTIVITY },
]); ]);
// Theme // Theme
@@ -328,7 +328,7 @@ export default function ProposalForm({
: { className: `bg-gradient-to-br ${themes[theme].gradient}` }; : { className: `bg-gradient-to-br ${themes[theme].gradient}` };
const detailsOk = senderName.trim() && recipientName.trim() && title.trim() && message.trim(); const detailsOk = senderName.trim() && recipientName.trim() && title.trim() && message.trim();
const activitiesOk = activities.length >= 2 && activities.every((a) => a.title.trim()); const activitiesOk = activities.length >= 1 && activities.every((a) => a.title.trim());
return ( return (
<Tabs value={step} onValueChange={(v) => setStep(v as typeof step)} className="space-y-5"> <Tabs value={step} onValueChange={(v) => setStep(v as typeof step)} className="space-y-5">
@@ -388,7 +388,7 @@ export default function ProposalForm({
<Card key={ai} className="p-4 sm:p-5 space-y-3"> <Card key={ai} className="p-4 sm:p-5 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-display font-bold text-foreground text-sm">Activity {ai + 1}</span> <span className="font-display font-bold text-foreground text-sm">Activity {ai + 1}</span>
{activities.length > 2 && ( {activities.length > 1 && (
<button type="button" onClick={() => removeActivity(ai)} <button type="button" onClick={() => removeActivity(ai)}
className="text-destructive/80 hover:text-destructive text-sm transition-colors py-1 px-1"> className="text-destructive/80 hover:text-destructive text-sm transition-colors py-1 px-1">
Remove Remove
@@ -422,6 +422,13 @@ export default function ProposalForm({
className="text-sm resize-none" className="text-sm resize-none"
/> />
<Input type="text" maxLength={200}
value={act.location}
onChange={(e) => updateActivity(ai, { location: e.target.value })}
placeholder="📍 Location (optional)"
className="text-sm"
/>
{/* Time slots */} {/* Time slots */}
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
+5 -3
View File
@@ -2,7 +2,7 @@ function toGCalDate(d: Date) {
return d.toISOString().replace(/[-:.]/g, '').slice(0, 15) + 'Z'; return d.toISOString().replace(/[-:.]/g, '').slice(0, 15) + 'Z';
} }
export function makeIcs(title: string, startsAt: Date, description: string) { export function makeIcs(title: string, startsAt: Date, description: string, location?: string) {
const start = toGCalDate(startsAt); const start = toGCalDate(startsAt);
const end = toGCalDate(new Date(startsAt.getTime() + 60 * 60 * 1000)); const end = toGCalDate(new Date(startsAt.getTime() + 60 * 60 * 1000));
return [ return [
@@ -10,12 +10,14 @@ export function makeIcs(title: string, startsAt: Date, description: string) {
'BEGIN:VEVENT', 'BEGIN:VEVENT',
`DTSTART:${start}`, `DTEND:${end}`, `DTSTART:${start}`, `DTEND:${end}`,
`SUMMARY:${title}`, `DESCRIPTION:${description}`, `SUMMARY:${title}`, `DESCRIPTION:${description}`,
...(location ? [`LOCATION:${location}`] : []),
'END:VEVENT', 'END:VCALENDAR', 'END:VEVENT', 'END:VCALENDAR',
].join('\r\n'); ].join('\r\n');
} }
export function makeGCalUrl(title: string, startsAt: Date, description: string) { export function makeGCalUrl(title: string, startsAt: Date, description: string, location?: string) {
const start = toGCalDate(startsAt); const start = toGCalDate(startsAt);
const end = toGCalDate(new Date(startsAt.getTime() + 60 * 60 * 1000)); const end = toGCalDate(new Date(startsAt.getTime() + 60 * 60 * 1000));
return `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${start}/${end}&details=${encodeURIComponent(description)}`; const locationParam = location ? `&location=${encodeURIComponent(location)}` : '';
return `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${start}/${end}&details=${encodeURIComponent(description)}${locationParam}`;
} }
+1
View File
@@ -29,6 +29,7 @@ export async function createProposal(
create: data.activities.map((a, i) => ({ create: data.activities.map((a, i) => ({
title: a.title, title: a.title,
description: a.description ?? null, description: a.description ?? null,
location: a.location ?? null,
emoji: a.emoji ?? '🎉', emoji: a.emoji ?? '🎉',
order: i, order: i,
slots: a.slots?.length slots: a.slots?.length
+2 -1
View File
@@ -19,6 +19,7 @@ export const slotSchema = z.object({
export const activitySchema = z.object({ export const activitySchema = z.object({
title: cipherText(100), title: cipherText(100),
description: cipherTextOptional(300), description: cipherTextOptional(300),
location: cipherTextOptional(200),
emoji: z.string().max(8).optional(), emoji: z.string().max(8).optional(),
slots: z.array(slotSchema).optional(), slots: z.array(slotSchema).optional(),
}); });
@@ -37,7 +38,7 @@ export const proposalBodySchema = z.object({
gifUrl: cipherTextOptional(500), gifUrl: cipherTextOptional(500),
evasiveNo: z.boolean().optional().default(false), evasiveNo: z.boolean().optional().default(false),
expiresAt: z.string().datetime().optional(), expiresAt: z.string().datetime().optional(),
activities: z.array(activitySchema).min(2).max(5), activities: z.array(activitySchema).min(1).max(5),
}); });
export type ProposalBody = z.infer<typeof proposalBodySchema>; export type ProposalBody = z.infer<typeof proposalBodySchema>;