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:
@@ -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[]
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user