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)
title String
description String?
location String?
emoji String @default("🎉")
order Int @default(0)
slots TimeSlot[]
+1
View File
@@ -23,6 +23,7 @@ async function encryptPayload(key: string, data: ProposalFormData) {
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) => ({
+12 -3
View File
@@ -33,7 +33,7 @@ type FullProposal = Proposal & {
};
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 {
title: string;
recipientName: string;
@@ -90,9 +90,10 @@ export default function ManageClient({
decryptField(k, proposal.recipientName),
]);
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),
decryptOptional(k, a.description),
decryptOptional(k, a.location),
]);
const slots = await Promise.all(a.slots.map(async (s) => {
const [label, startsAt] = await Promise.all([
@@ -101,7 +102,7 @@ export default function ManageClient({
]);
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;
if (proposal.response) {
@@ -159,6 +160,7 @@ export default function ManageClient({
? {
startsAt: new Date(selectedSlot.startsAt),
title: `${selectedActivity?.title ?? 'Date'} with ${decrypted.recipientName}`,
location: selectedActivity?.location ?? undefined,
}
: null;
@@ -237,6 +239,11 @@ export default function ManageClient({
🗓 {formatSlotDate(selectedSlot.startsAt, selectedSlot.label)}
</p>
)}
{selectedActivity.location && (
<p className="text-sm text-muted-foreground mt-1">
📍 {selectedActivity.location}
</p>
)}
</div>
)}
{decrypted.response.note && (
@@ -250,6 +257,7 @@ export default function ManageClient({
title={calendarSlot.title}
startsAt={calendarSlot.startsAt}
description={`Arranged via lovelope.app: ${decrypted.title}`}
location={calendarSlot.location}
/>
)}
</Card>
@@ -327,6 +335,7 @@ export default function ManageClient({
<div className="flex-1 min-w-0">
<p className="font-semibold text-foreground text-sm">{a.title}</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 && (
<div className="flex flex-wrap gap-1 mt-1">
{a.slots.map((s) => (
+18 -4
View File
@@ -21,7 +21,7 @@ interface Props {
}
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 {
key: string;
senderName: string;
@@ -117,9 +117,10 @@ export default function ProposalPageClient({
decryptOptional(k, existingAnswer ?? undefined),
]);
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),
decryptOptional(k, a.description),
decryptOptional(k, a.location),
]);
const slots = await Promise.all(a.slots.map(async (s) => {
const [label, startsAt] = await Promise.all([
@@ -128,7 +129,7 @@ export default function ProposalPageClient({
]);
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) {
setDecrypted({ key: k, senderName, recipientName, title, message, gifUrl: gifUrl ?? null, activities });
@@ -228,7 +229,11 @@ export default function ProposalPageClient({
const isMaybe = submittedAnswer === 'maybe';
const calendarSlot =
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;
return (
@@ -267,6 +272,11 @@ export default function ProposalPageClient({
🗓 {formatSlotDate(selectedSlot.startsAt, selectedSlot.label)}
</p>
)}
{selectedActivity.location && (
<p className="text-xs text-orange-500 mt-1">
📍 {selectedActivity.location}
</p>
)}
</div>
)}
{calendarSlot && (
@@ -274,6 +284,7 @@ export default function ProposalPageClient({
title={calendarSlot.title}
startsAt={calendarSlot.startsAt}
description={`Arranged via lovelope.app: ${decrypted.title}`}
location={calendarSlot.location}
/>
)}
</motion.div>
@@ -371,6 +382,9 @@ export default function ProposalPageClient({
{activity.description && (
<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 className={`w-6 h-6 rounded-full border-2 shrink-0 flex items-center justify-center transition-all ${
isSelected
+4 -3
View File
@@ -6,11 +6,12 @@ interface Props {
title: string;
startsAt: Date;
description: string;
location?: string;
}
export default function CalendarButtons({ title, startsAt, description }: Props) {
export default function CalendarButtons({ title, startsAt, description, location }: Props) {
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 a = document.createElement('a');
a.href = url;
@@ -22,7 +23,7 @@ export default function CalendarButtons({ title, startsAt, description }: Props)
return (
<div className="mt-4 flex flex-col sm:flex-row gap-2">
<a
href={makeGCalUrl(title, startsAt, description)}
href={makeGCalUrl(title, startsAt, description, location)}
target="_blank"
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
+11 -4
View File
@@ -19,6 +19,7 @@ export interface SlotData {
export interface ActivityData {
title: string;
description: string;
location: string;
emoji: string;
slots: SlotData[];
}
@@ -45,7 +46,7 @@ interface Props {
}
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]> = {
sunset: ['#fb923c', '#ec4899', '#f43f5e'],
@@ -243,7 +244,6 @@ export default function ProposalForm({
// Activities
const [activities, setActivities] = useState<ActivityData[]>([
{ ...BLANK_ACTIVITY },
{ ...BLANK_ACTIVITY },
]);
// Theme
@@ -328,7 +328,7 @@ export default function ProposalForm({
: { 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 activitiesOk = activities.length >= 1 && activities.every((a) => a.title.trim());
return (
<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">
<div className="flex items-center justify-between">
<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)}
className="text-destructive/80 hover:text-destructive text-sm transition-colors py-1 px-1">
Remove
@@ -422,6 +422,13 @@ export default function ProposalForm({
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 */}
<div>
<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';
}
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 end = toGCalDate(new Date(startsAt.getTime() + 60 * 60 * 1000));
return [
@@ -10,12 +10,14 @@ export function makeIcs(title: string, startsAt: Date, description: string) {
'BEGIN:VEVENT',
`DTSTART:${start}`, `DTEND:${end}`,
`SUMMARY:${title}`, `DESCRIPTION:${description}`,
...(location ? [`LOCATION:${location}`] : []),
'END:VEVENT', 'END:VCALENDAR',
].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 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) => ({
title: a.title,
description: a.description ?? null,
location: a.location ?? null,
emoji: a.emoji ?? '🎉',
order: i,
slots: a.slots?.length
+2 -1
View File
@@ -19,6 +19,7 @@ export const slotSchema = z.object({
export const activitySchema = z.object({
title: cipherText(100),
description: cipherTextOptional(300),
location: cipherTextOptional(200),
emoji: z.string().max(8).optional(),
slots: z.array(slotSchema).optional(),
});
@@ -37,7 +38,7 @@ export const proposalBodySchema = z.object({
gifUrl: cipherTextOptional(500),
evasiveNo: z.boolean().optional().default(false),
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>;