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)
|
||||
title String
|
||||
description String?
|
||||
location String?
|
||||
emoji String @default("🎉")
|
||||
order Int @default(0)
|
||||
slots TimeSlot[]
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user