mirror of
https://github.com/lukevella/rallly.git
synced 2025-08-06 09:59:00 +02:00
✨ Add option to cancel a scheduled event (#1800)
This commit is contained in:
parent
4b4dfef3e5
commit
20f7c6bfb7
17 changed files with 429 additions and 84 deletions
|
@ -395,5 +395,6 @@
|
||||||
"actionErrorUnauthorized": "You are not authorized to perform this action",
|
"actionErrorUnauthorized": "You are not authorized to perform this action",
|
||||||
"actionErrorNotFound": "The resource was not found",
|
"actionErrorNotFound": "The resource was not found",
|
||||||
"actionErrorForbidden": "You are not allowed to perform this action",
|
"actionErrorForbidden": "You are not allowed to perform this action",
|
||||||
"actionErrorInternalServerError": "An internal server error occurred"
|
"actionErrorInternalServerError": "An internal server error occurred",
|
||||||
|
"cancelEvent": "Cancel Event"
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,6 +38,9 @@ export function EventsTabbedView({ children }: { children: React.ReactNode }) {
|
||||||
<TabsTrigger value="past">
|
<TabsTrigger value="past">
|
||||||
<Trans i18nKey="past" defaults="Past" />
|
<Trans i18nKey="past" defaults="Past" />
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="canceled">
|
||||||
|
<Trans i18nKey="canceled" defaults="Canceled" />
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent
|
<TabsContent
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
|
|
@ -161,6 +161,7 @@ export default async function Page(props: {
|
||||||
end={event.end}
|
end={event.end}
|
||||||
allDay={event.allDay}
|
allDay={event.allDay}
|
||||||
invites={event.invites}
|
invites={event.invites}
|
||||||
|
status={event.status}
|
||||||
/>
|
/>
|
||||||
</StackedListItem>
|
</StackedListItem>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { Trans } from "@/components/trans";
|
||||||
import { IfParticipantsVisible } from "@/components/visibility";
|
import { IfParticipantsVisible } from "@/components/visibility";
|
||||||
import { usePoll } from "@/contexts/poll";
|
import { usePoll } from "@/contexts/poll";
|
||||||
import { useDayjs } from "@/utils/dayjs";
|
import { useDayjs } from "@/utils/dayjs";
|
||||||
|
import { Badge } from "@rallly/ui/badge";
|
||||||
|
|
||||||
function FinalDate({ start }: { start: Date }) {
|
function FinalDate({ start }: { start: Date }) {
|
||||||
const poll = usePoll();
|
const poll = usePoll();
|
||||||
|
@ -92,6 +93,11 @@ export function ScheduledEvent() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{event.status === "canceled" && (
|
||||||
|
<Badge>
|
||||||
|
<Trans i18nKey="canceled" defaults="Canceled" />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<IfParticipantsVisible>
|
<IfParticipantsVisible>
|
||||||
|
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
Comment,
|
Comment,
|
||||||
Participant,
|
Participant,
|
||||||
Poll,
|
Poll,
|
||||||
|
ScheduledEvent,
|
||||||
Space,
|
Space,
|
||||||
SpaceMember,
|
SpaceMember,
|
||||||
Subscription,
|
Subscription,
|
||||||
|
@ -29,7 +30,8 @@ export type Action =
|
||||||
| "delete"
|
| "delete"
|
||||||
| "manage"
|
| "manage"
|
||||||
| "finalize"
|
| "finalize"
|
||||||
| "access";
|
| "access"
|
||||||
|
| "cancel";
|
||||||
|
|
||||||
export type Subject =
|
export type Subject =
|
||||||
| Subjects<{
|
| Subjects<{
|
||||||
|
@ -40,6 +42,7 @@ export type Subject =
|
||||||
Participant: Participant;
|
Participant: Participant;
|
||||||
SpaceMember: SpaceMember;
|
SpaceMember: SpaceMember;
|
||||||
Subscription: Subscription;
|
Subscription: Subscription;
|
||||||
|
ScheduledEvent: ScheduledEvent;
|
||||||
}>
|
}>
|
||||||
| "ControlPanel";
|
| "ControlPanel";
|
||||||
|
|
||||||
|
@ -91,5 +94,7 @@ export const defineAbilityFor = (
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
can("cancel", "ScheduledEvent", { userId: user.id });
|
||||||
|
|
||||||
return build();
|
return build();
|
||||||
};
|
};
|
||||||
|
|
84
apps/web/src/features/scheduled-event/actions.ts
Normal file
84
apps/web/src/features/scheduled-event/actions.ts
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
"use server";
|
||||||
|
import { ActionError, authActionClient } from "@/features/safe-action/server";
|
||||||
|
import { getEmailClient } from "@/utils/emails";
|
||||||
|
import { subject } from "@casl/ability";
|
||||||
|
import { prisma } from "@rallly/database";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { formatEventDateTime } from "./utils";
|
||||||
|
|
||||||
|
export const cancelEventAction = authActionClient
|
||||||
|
.inputSchema(
|
||||||
|
z.object({
|
||||||
|
eventId: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
|
const event = await prisma.scheduledEvent.findUnique({
|
||||||
|
where: {
|
||||||
|
id: parsedInput.eventId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Event not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.ability.cannot("cancel", subject("ScheduledEvent", event))) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You do not have permission to cancel this event",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.scheduledEvent.update({
|
||||||
|
where: {
|
||||||
|
id: parsedInput.eventId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "canceled",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
|
||||||
|
// notify attendees
|
||||||
|
const attendees = await prisma.scheduledEventInvite.findMany({
|
||||||
|
where: {
|
||||||
|
scheduledEventId: parsedInput.eventId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
inviteeEmail: true,
|
||||||
|
inviteeName: true,
|
||||||
|
inviteeTimeZone: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const attendee of attendees) {
|
||||||
|
if (attendee.status !== "declined") {
|
||||||
|
const { day, dow, date, time } = formatEventDateTime({
|
||||||
|
start: event.start,
|
||||||
|
end: event.end,
|
||||||
|
allDay: event.allDay,
|
||||||
|
timeZone: event.timeZone,
|
||||||
|
inviteeTimeZone: attendee.inviteeTimeZone,
|
||||||
|
});
|
||||||
|
|
||||||
|
getEmailClient().queueTemplate("EventCanceledEmail", {
|
||||||
|
to: attendee.inviteeEmail,
|
||||||
|
props: {
|
||||||
|
title: event.title,
|
||||||
|
hostName: ctx.user.name,
|
||||||
|
day,
|
||||||
|
dow,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -1,31 +1,55 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
|
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
|
||||||
import { StackedList } from "@/components/stacked-list";
|
import { StackedList } from "@/components/stacked-list";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
|
import { useSafeAction } from "@/features/safe-action/client";
|
||||||
import { FormattedDateTime } from "@/features/timezone/client/formatted-date-time";
|
import { FormattedDateTime } from "@/features/timezone/client/formatted-date-time";
|
||||||
|
import type { ScheduledEventStatus } from "@rallly/database";
|
||||||
|
import { Badge } from "@rallly/ui/badge";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@rallly/ui/dropdown-menu";
|
||||||
|
import { Icon } from "@rallly/ui/icon";
|
||||||
|
import { MoreHorizontalIcon, XIcon } from "lucide-react";
|
||||||
|
import { cancelEventAction } from "../actions";
|
||||||
|
|
||||||
export const ScheduledEventList = StackedList;
|
export const ScheduledEventList = StackedList;
|
||||||
|
|
||||||
export function ScheduledEventListItem({
|
export function ScheduledEventListItem({
|
||||||
|
eventId,
|
||||||
title,
|
title,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
allDay,
|
allDay,
|
||||||
invites,
|
invites,
|
||||||
floating: isFloating,
|
floating: isFloating,
|
||||||
|
status,
|
||||||
}: {
|
}: {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
status: ScheduledEventStatus;
|
||||||
start: Date;
|
start: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
allDay: boolean;
|
allDay: boolean;
|
||||||
invites: { id: string; inviteeName: string; inviteeImage?: string }[];
|
invites: { id: string; inviteeName: string; inviteeImage?: string }[];
|
||||||
floating: boolean;
|
floating: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const cancelEvent = useSafeAction(cancelEventAction);
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full gap-6">
|
<div className="flex w-full gap-6">
|
||||||
<div className="flex flex-1 flex-col gap-y-1 lg:flex-row-reverse lg:justify-end lg:gap-x-4">
|
<div className="flex flex-1 flex-col gap-y-1 lg:flex-row-reverse lg:justify-end lg:gap-x-4">
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<div>{title}</div>
|
<div>{title}</div>
|
||||||
|
{status === "canceled" && (
|
||||||
|
<Badge>
|
||||||
|
<Trans i18nKey="canceled" defaults="Canceled" />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center whitespace-nowrap text-sm lg:min-w-40">
|
<div className="flex items-center whitespace-nowrap text-sm lg:min-w-40">
|
||||||
<div>
|
<div>
|
||||||
|
@ -69,6 +93,31 @@ export function ScheduledEventListItem({
|
||||||
max={5}
|
max={5}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{status !== "canceled" && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Icon>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
cancelEvent.executeAsync({
|
||||||
|
eventId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon>
|
||||||
|
<XIcon />
|
||||||
|
</Icon>
|
||||||
|
<Trans i18nKey="cancelEvent" defaults="Cancel Event" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -11,7 +11,7 @@ dayjs.extend(utc);
|
||||||
const mapStatus = {
|
const mapStatus = {
|
||||||
upcoming: "confirmed",
|
upcoming: "confirmed",
|
||||||
unconfirmed: "unconfirmed",
|
unconfirmed: "unconfirmed",
|
||||||
past: undefined,
|
past: "confirmed",
|
||||||
canceled: "canceled",
|
canceled: "canceled",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ function getEventsWhereInput({
|
||||||
...(status === "upcoming" && {
|
...(status === "upcoming" && {
|
||||||
OR: [
|
OR: [
|
||||||
{ allDay: false, start: { gte: now } },
|
{ allDay: false, start: { gte: now } },
|
||||||
{ allDay: true, start: { gte: todayStart, lte: todayEnd } },
|
{ allDay: true, start: { gte: todayStart } },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
...(status === "past" && {
|
...(status === "past" && {
|
||||||
|
|
75
apps/web/src/features/scheduled-event/utils.ts
Normal file
75
apps/web/src/features/scheduled-event/utils.ts
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import advancedFormat from "dayjs/plugin/advancedFormat";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(advancedFormat);
|
||||||
|
|
||||||
|
export interface FormattedEventDateTime {
|
||||||
|
date: string;
|
||||||
|
day: string;
|
||||||
|
dow: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormatEventDateTimeOptions {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
allDay: boolean;
|
||||||
|
timeZone?: string | null;
|
||||||
|
inviteeTimeZone?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats event date and time based on event type and timezone settings
|
||||||
|
*
|
||||||
|
* Handles three scenarios:
|
||||||
|
* 1. All-day events (same date for everyone)
|
||||||
|
* 2. Events with a timezone (adjust to invitee's timezone if available)
|
||||||
|
* 3. Events without a timezone (floating time - show in UTC)
|
||||||
|
*/
|
||||||
|
export const formatEventDateTime = ({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
allDay,
|
||||||
|
timeZone,
|
||||||
|
inviteeTimeZone,
|
||||||
|
}: FormatEventDateTimeOptions): FormattedEventDateTime => {
|
||||||
|
if (allDay) {
|
||||||
|
// For all-day events, show the same date to everyone
|
||||||
|
const eventDate = dayjs(start).utc();
|
||||||
|
return {
|
||||||
|
date: eventDate.format("MMMM D, YYYY"),
|
||||||
|
day: eventDate.format("D"),
|
||||||
|
dow: eventDate.format("ddd"),
|
||||||
|
time: "All day",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeZone) {
|
||||||
|
// If event has a timezone, adjust to invitee's timezone if available
|
||||||
|
const targetTimeZone = inviteeTimeZone || timeZone;
|
||||||
|
|
||||||
|
const startTime = dayjs(start).tz(targetTimeZone);
|
||||||
|
const endTime = dayjs(end).tz(targetTimeZone);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: startTime.format("MMMM D, YYYY"),
|
||||||
|
day: startTime.format("D"),
|
||||||
|
dow: startTime.format("ddd"),
|
||||||
|
time: `${startTime.format("HH:mm")} - ${endTime.format("HH:mm z")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = dayjs(start).utc();
|
||||||
|
const endTime = dayjs(end).utc();
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: startTime.format("MMMM D, YYYY"),
|
||||||
|
day: startTime.format("D"),
|
||||||
|
dow: startTime.format("ddd"),
|
||||||
|
time: `${startTime.format("HH:mm")} - ${endTime.format("HH:mm")}`,
|
||||||
|
};
|
||||||
|
};
|
|
@ -12,8 +12,8 @@ import { z } from "zod";
|
||||||
import { moderateContent } from "@/features/moderation";
|
import { moderateContent } from "@/features/moderation";
|
||||||
import { getEmailClient } from "@/utils/emails";
|
import { getEmailClient } from "@/utils/emails";
|
||||||
|
|
||||||
|
import { formatEventDateTime } from "@/features/scheduled-event/utils";
|
||||||
import { getDefaultSpace } from "@/features/spaces/queries";
|
import { getDefaultSpace } from "@/features/spaces/queries";
|
||||||
import { getTimeZoneAbbreviation } from "../../utils/date";
|
|
||||||
import {
|
import {
|
||||||
createRateLimitMiddleware,
|
createRateLimitMiddleware,
|
||||||
possiblyPublicProcedure,
|
possiblyPublicProcedure,
|
||||||
|
@ -479,6 +479,7 @@ export const polls = router({
|
||||||
start: true,
|
start: true,
|
||||||
end: true,
|
end: true,
|
||||||
allDay: true,
|
allDay: true,
|
||||||
|
status: true,
|
||||||
invites: {
|
invites: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -526,6 +527,7 @@ export const polls = router({
|
||||||
(invite) =>
|
(invite) =>
|
||||||
invite.status === "accepted" || invite.status === "tentative",
|
invite.status === "accepted" || invite.status === "tentative",
|
||||||
),
|
),
|
||||||
|
status: res.scheduledEvent.status,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
@ -629,56 +631,64 @@ export const polls = router({
|
||||||
eventStart = eventStart.utc();
|
eventStart = eventStart.utc();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!poll.spaceId) {
|
const { spaceId } = poll;
|
||||||
|
|
||||||
|
if (!spaceId) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: "Poll has no space",
|
message: "Poll has no space",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.poll.update({
|
const scheduledEvent = await prisma.$transaction(async (tx) => {
|
||||||
where: {
|
// create scheduled event
|
||||||
id: input.pollId,
|
const event = await tx.scheduledEvent.create({
|
||||||
},
|
data: {
|
||||||
data: {
|
start: eventStart.toDate(),
|
||||||
status: "finalized",
|
end: eventStart.add(option.duration, "minute").toDate(),
|
||||||
scheduledEvent: {
|
title: poll.title,
|
||||||
create: {
|
location: poll.location,
|
||||||
id: input.pollId,
|
timeZone: poll.timeZone,
|
||||||
start: eventStart.toDate(),
|
userId: ctx.user.id,
|
||||||
end: eventStart.add(option.duration, "minute").toDate(),
|
spaceId,
|
||||||
title: poll.title,
|
allDay: option.duration === 0,
|
||||||
location: poll.location,
|
status: "confirmed",
|
||||||
timeZone: poll.timeZone,
|
invites: {
|
||||||
userId: ctx.user.id,
|
createMany: {
|
||||||
spaceId: poll.spaceId,
|
data: poll.participants
|
||||||
allDay: option.duration === 0,
|
.filter((p) => p.email || p.user?.email) // Filter out participants without email
|
||||||
status: "confirmed",
|
.map((p) => ({
|
||||||
invites: {
|
inviteeName: p.name,
|
||||||
createMany: {
|
inviteeEmail:
|
||||||
data: poll.participants
|
p.user?.email ?? p.email ?? `${p.id}@rallly.co`,
|
||||||
.filter((p) => p.email || p.user?.email) // Filter out participants without email
|
inviteeTimeZone: p.user?.timeZone ?? poll.timeZone, // We should track participant's timezone
|
||||||
.map((p) => ({
|
status: (
|
||||||
inviteeName: p.name,
|
{
|
||||||
inviteeEmail:
|
yes: "accepted",
|
||||||
p.user?.email ?? p.email ?? `${p.id}@rallly.co`,
|
ifNeedBe: "tentative",
|
||||||
inviteeTimeZone: p.user?.timeZone ?? poll.timeZone, // We should track participant's timezone
|
no: "declined",
|
||||||
status: (
|
} as const
|
||||||
{
|
)[
|
||||||
yes: "accepted",
|
p.votes.find((v) => v.optionId === input.optionId)
|
||||||
ifNeedBe: "tentative",
|
?.type ?? "no"
|
||||||
no: "declined",
|
],
|
||||||
} as const
|
})),
|
||||||
)[
|
|
||||||
p.votes.find((v) => v.optionId === input.optionId)
|
|
||||||
?.type ?? "no"
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
|
// update poll status
|
||||||
|
await tx.poll.update({
|
||||||
|
where: {
|
||||||
|
id: poll.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "finalized",
|
||||||
|
scheduledEventId: event.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return event;
|
||||||
});
|
});
|
||||||
|
|
||||||
const attendees = poll.participants.filter((p) =>
|
const attendees = poll.participants.filter((p) =>
|
||||||
|
@ -699,6 +709,8 @@ export const polls = router({
|
||||||
: eventStart.add(1, "day");
|
: eventStart.add(1, "day");
|
||||||
|
|
||||||
const event = ics.createEvent({
|
const event = ics.createEvent({
|
||||||
|
uid: scheduledEvent.id,
|
||||||
|
sequence: 0, // TODO: Get sequence from database
|
||||||
title: poll.title,
|
title: poll.title,
|
||||||
location: poll.location ?? undefined,
|
location: poll.location ?? undefined,
|
||||||
description: poll.description ?? undefined,
|
description: poll.description ?? undefined,
|
||||||
|
@ -742,20 +754,6 @@ export const polls = router({
|
||||||
message: "Failed to generate ics",
|
message: "Failed to generate ics",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const timeZoneAbbrev = poll.timeZone
|
|
||||||
? getTimeZoneAbbreviation(eventStart.toDate(), poll.timeZone)
|
|
||||||
: "";
|
|
||||||
const date = eventStart.format("dddd, MMMM D, YYYY");
|
|
||||||
const day = eventStart.format("D");
|
|
||||||
const dow = eventStart.format("ddd");
|
|
||||||
const startTime = eventStart.format("hh:mm A");
|
|
||||||
const endTime = eventEnd.format("hh:mm A");
|
|
||||||
|
|
||||||
const time =
|
|
||||||
option.duration > 0
|
|
||||||
? `${startTime} - ${endTime} ${timeZoneAbbrev}`
|
|
||||||
: "All-day";
|
|
||||||
|
|
||||||
const participantsToEmail: Array<{
|
const participantsToEmail: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
@ -788,6 +786,13 @@ export const polls = router({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { date, day, dow, time } = formatEventDateTime({
|
||||||
|
start: scheduledEvent.start,
|
||||||
|
end: scheduledEvent.end,
|
||||||
|
allDay: scheduledEvent.allDay,
|
||||||
|
timeZone: scheduledEvent.timeZone,
|
||||||
|
});
|
||||||
|
|
||||||
ctx.user.getEmailClient().queueTemplate("FinalizeHostEmail", {
|
ctx.user.getEmailClient().queueTemplate("FinalizeHostEmail", {
|
||||||
to: poll.user.email,
|
to: poll.user.email,
|
||||||
props: {
|
props: {
|
||||||
|
@ -811,6 +816,13 @@ export const polls = router({
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const p of participantsToEmail) {
|
for (const p of participantsToEmail) {
|
||||||
|
const { date, day, dow, time } = formatEventDateTime({
|
||||||
|
start: scheduledEvent.start,
|
||||||
|
end: scheduledEvent.end,
|
||||||
|
allDay: scheduledEvent.allDay,
|
||||||
|
timeZone: scheduledEvent.timeZone,
|
||||||
|
// inviteeTimeZone: p.timeZone, // TODO: implement this
|
||||||
|
});
|
||||||
getEmailClient(p.locale ?? undefined).queueTemplate(
|
getEmailClient(p.locale ?? undefined).queueTemplate(
|
||||||
"FinalizeParticipantEmail",
|
"FinalizeParticipantEmail",
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import dayjs from "dayjs";
|
|
||||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
|
||||||
import timezone from "dayjs/plugin/timezone";
|
|
||||||
import spacetime from "spacetime";
|
|
||||||
import soft from "timezone-soft";
|
|
||||||
|
|
||||||
dayjs.extend(localizedFormat);
|
|
||||||
dayjs.extend(timezone);
|
|
||||||
|
|
||||||
export const getTimeZoneAbbreviation = (date: Date, timeZone: string) => {
|
|
||||||
const timeZoneDisplayFormat = soft(timeZone)[0];
|
|
||||||
const spaceTimeDate = spacetime(date, timeZone);
|
|
||||||
if (!timeZoneDisplayFormat) {
|
|
||||||
console.error(`No timezone display format for ${timeZone}`);
|
|
||||||
}
|
|
||||||
const standardAbbrev = timeZoneDisplayFormat?.standard.abbr ?? timeZone;
|
|
||||||
const dstAbbrev = timeZoneDisplayFormat?.daylight?.abbr ?? timeZone;
|
|
||||||
const abbrev = spaceTimeDate.isDST() ? dstAbbrev : standardAbbrev;
|
|
||||||
return abbrev;
|
|
||||||
};
|
|
|
@ -74,5 +74,9 @@
|
||||||
"license_key_support": "Reply to this email or contact us at <a>{supportEmail}</a> if you need help.",
|
"license_key_support": "Reply to this email or contact us at <a>{supportEmail}</a> if you need help.",
|
||||||
"license_key_signoff": "Thank you for choosing Rallly!",
|
"license_key_signoff": "Thank you for choosing Rallly!",
|
||||||
"license_key_preview": "Your license key has been generated.",
|
"license_key_preview": "Your license key has been generated.",
|
||||||
"license_key_subject": "Your Rallly Self-Hosted {tier} License"
|
"license_key_subject": "Your Rallly Self-Hosted {tier} License",
|
||||||
|
"eventCanceledContent": "<b>{{hostName}}</b> has canceled <b>{{title}}</b> that was scheduled for:",
|
||||||
|
"eventCanceledPreview": "Event canceled",
|
||||||
|
"eventCanceledHeading": "Event canceled",
|
||||||
|
"eventCanceledSubject": "Canceled: {title}"
|
||||||
}
|
}
|
||||||
|
|
16
packages/emails/src/previews/event-canceled.tsx
Normal file
16
packages/emails/src/previews/event-canceled.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { previewEmailContext } from "../components/email-context";
|
||||||
|
import { EventCanceledEmail } from "../templates/event-canceled";
|
||||||
|
|
||||||
|
export default function EventCanceledPreview() {
|
||||||
|
return (
|
||||||
|
<EventCanceledEmail
|
||||||
|
title="Untitled Poll"
|
||||||
|
hostName="Host"
|
||||||
|
day="12"
|
||||||
|
dow="Fri"
|
||||||
|
date="Friday, 12th June 2020"
|
||||||
|
time="6:00 PM to 11:00 PM BST"
|
||||||
|
ctx={previewEmailContext}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import { AbandonedCheckoutEmail } from "./templates/abandoned-checkout";
|
import { AbandonedCheckoutEmail } from "./templates/abandoned-checkout";
|
||||||
import { ChangeEmailRequest } from "./templates/change-email-request";
|
import { ChangeEmailRequest } from "./templates/change-email-request";
|
||||||
|
import { EventCanceledEmail } from "./templates/event-canceled";
|
||||||
import { FinalizeHostEmail } from "./templates/finalized-host";
|
import { FinalizeHostEmail } from "./templates/finalized-host";
|
||||||
import { FinalizeParticipantEmail } from "./templates/finalized-participant";
|
import { FinalizeParticipantEmail } from "./templates/finalized-participant";
|
||||||
import { LicenseKeyEmail } from "./templates/license-key";
|
import { LicenseKeyEmail } from "./templates/license-key";
|
||||||
|
@ -23,6 +24,7 @@ const templates = {
|
||||||
ChangeEmailRequest,
|
ChangeEmailRequest,
|
||||||
AbandonedCheckoutEmail,
|
AbandonedCheckoutEmail,
|
||||||
LicenseKeyEmail,
|
LicenseKeyEmail,
|
||||||
|
EventCanceledEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emailTemplates = Object.keys(templates) as TemplateName[];
|
export const emailTemplates = Object.keys(templates) as TemplateName[];
|
||||||
|
|
107
packages/emails/src/templates/event-canceled.tsx
Normal file
107
packages/emails/src/templates/event-canceled.tsx
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
import { Column, Row, Section } from "@react-email/components";
|
||||||
|
import { Trans } from "react-i18next/TransWithoutContext";
|
||||||
|
|
||||||
|
import { EmailLayout } from "../components/email-layout";
|
||||||
|
import { Heading, Text, borderColor } from "../components/styled-components";
|
||||||
|
import type { EmailContext } from "../types";
|
||||||
|
|
||||||
|
export interface EventCanceledEmailProps {
|
||||||
|
title: string;
|
||||||
|
hostName: string;
|
||||||
|
day: string;
|
||||||
|
dow: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
ctx: EmailContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventCanceledEmail = ({
|
||||||
|
title,
|
||||||
|
hostName,
|
||||||
|
day,
|
||||||
|
dow,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
ctx,
|
||||||
|
}: EventCanceledEmailProps) => {
|
||||||
|
return (
|
||||||
|
<EmailLayout
|
||||||
|
ctx={ctx}
|
||||||
|
preview={ctx.t("eventCanceledPreview", {
|
||||||
|
defaultValue: "Event canceled",
|
||||||
|
ns: "emails",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Heading>
|
||||||
|
{ctx.t("eventCanceledHeading", {
|
||||||
|
defaultValue: "Event canceled",
|
||||||
|
ns: "emails",
|
||||||
|
})}
|
||||||
|
</Heading>
|
||||||
|
<Text>
|
||||||
|
<Trans
|
||||||
|
i18n={ctx.i18n}
|
||||||
|
t={ctx.t}
|
||||||
|
i18nKey="eventCanceledContent"
|
||||||
|
ns="emails"
|
||||||
|
defaults="<b>{hostName}</b> has canceled <b>{title}</b> that was scheduled for:"
|
||||||
|
values={{ hostName, title }}
|
||||||
|
components={{
|
||||||
|
b: <strong />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
<Section data-testid="date-section">
|
||||||
|
<Row>
|
||||||
|
<Column style={{ width: 48 }}>
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
borderRadius: 5,
|
||||||
|
margin: 0,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
textAlign: "center",
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{ margin: "0 0 4px 0", fontSize: 10, lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
{dow}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
lineHeight: 1,
|
||||||
|
fontWeight: "bold",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Column>
|
||||||
|
<Column style={{ paddingLeft: 16 }} align="left">
|
||||||
|
<Text style={{ margin: 0, fontWeight: "bold" }}>{date}</Text>
|
||||||
|
<Text light={true} style={{ margin: 0 }}>
|
||||||
|
{time}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
</EmailLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
EventCanceledEmail.getSubject = (
|
||||||
|
props: EventCanceledEmailProps,
|
||||||
|
ctx: EmailContext,
|
||||||
|
) => {
|
||||||
|
return ctx.t("eventCanceledSubject", {
|
||||||
|
defaultValue: "Canceled: {title}",
|
||||||
|
title: props.title,
|
||||||
|
ns: "emails",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export { EventCanceledEmail };
|
|
@ -123,7 +123,7 @@ FinalizeHostEmail.getSubject = (
|
||||||
ctx: EmailContext,
|
ctx: EmailContext,
|
||||||
) => {
|
) => {
|
||||||
return ctx.t("finalizeHost_subject", {
|
return ctx.t("finalizeHost_subject", {
|
||||||
defaultValue: "Date booked for {{title}}",
|
defaultValue: "Date booked for {title}",
|
||||||
title: props.title,
|
title: props.title,
|
||||||
ns: "emails",
|
ns: "emails",
|
||||||
});
|
});
|
||||||
|
|
|
@ -51,7 +51,7 @@ const FinalizeParticipantEmail = ({
|
||||||
t={ctx.t}
|
t={ctx.t}
|
||||||
i18nKey="finalizeParticipant_content"
|
i18nKey="finalizeParticipant_content"
|
||||||
ns="emails"
|
ns="emails"
|
||||||
defaults="<b>{{hostName}}</b> has booked <b>{{title}}</b> for the following date:"
|
defaults="<b>{hostName}</b> has booked <b>{title}</b> for the following date:"
|
||||||
values={{ hostName, title }}
|
values={{ hostName, title }}
|
||||||
components={{
|
components={{
|
||||||
b: <strong />,
|
b: <strong />,
|
||||||
|
@ -114,7 +114,7 @@ FinalizeParticipantEmail.getSubject = (
|
||||||
ctx: EmailContext,
|
ctx: EmailContext,
|
||||||
) => {
|
) => {
|
||||||
return ctx.t("finalizeParticipant_subject", {
|
return ctx.t("finalizeParticipant_subject", {
|
||||||
defaultValue: "Date booked for {{title}}",
|
defaultValue: "Date booked for {title}",
|
||||||
title: props.title,
|
title: props.title,
|
||||||
ns: "emails",
|
ns: "emails",
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue