mirror of
https://github.com/lukevella/rallly.git
synced 2025-08-01 23:48:53 +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",
|
||||
"actionErrorNotFound": "The resource was not found",
|
||||
"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">
|
||||
<Trans i18nKey="past" defaults="Past" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="canceled">
|
||||
<Trans i18nKey="canceled" defaults="Canceled" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
tabIndex={-1}
|
||||
|
|
|
@ -161,6 +161,7 @@ export default async function Page(props: {
|
|||
end={event.end}
|
||||
allDay={event.allDay}
|
||||
invites={event.invites}
|
||||
status={event.status}
|
||||
/>
|
||||
</StackedListItem>
|
||||
))}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { Trans } from "@/components/trans";
|
|||
import { IfParticipantsVisible } from "@/components/visibility";
|
||||
import { usePoll } from "@/contexts/poll";
|
||||
import { useDayjs } from "@/utils/dayjs";
|
||||
import { Badge } from "@rallly/ui/badge";
|
||||
|
||||
function FinalDate({ start }: { start: Date }) {
|
||||
const poll = usePoll();
|
||||
|
@ -92,6 +93,11 @@ export function ScheduledEvent() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{event.status === "canceled" && (
|
||||
<Badge>
|
||||
<Trans i18nKey="canceled" defaults="Canceled" />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<IfParticipantsVisible>
|
||||
|
|
|
@ -9,6 +9,7 @@ import type {
|
|||
Comment,
|
||||
Participant,
|
||||
Poll,
|
||||
ScheduledEvent,
|
||||
Space,
|
||||
SpaceMember,
|
||||
Subscription,
|
||||
|
@ -29,7 +30,8 @@ export type Action =
|
|||
| "delete"
|
||||
| "manage"
|
||||
| "finalize"
|
||||
| "access";
|
||||
| "access"
|
||||
| "cancel";
|
||||
|
||||
export type Subject =
|
||||
| Subjects<{
|
||||
|
@ -40,6 +42,7 @@ export type Subject =
|
|||
Participant: Participant;
|
||||
SpaceMember: SpaceMember;
|
||||
Subscription: Subscription;
|
||||
ScheduledEvent: ScheduledEvent;
|
||||
}>
|
||||
| "ControlPanel";
|
||||
|
||||
|
@ -91,5 +94,7 @@ export const defineAbilityFor = (
|
|||
},
|
||||
});
|
||||
|
||||
can("cancel", "ScheduledEvent", { userId: user.id });
|
||||
|
||||
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 { StackedList } from "@/components/stacked-list";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useSafeAction } from "@/features/safe-action/client";
|
||||
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 function ScheduledEventListItem({
|
||||
eventId,
|
||||
title,
|
||||
start,
|
||||
end,
|
||||
allDay,
|
||||
invites,
|
||||
floating: isFloating,
|
||||
status,
|
||||
}: {
|
||||
eventId: string;
|
||||
title: string;
|
||||
status: ScheduledEventStatus;
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
invites: { id: string; inviteeName: string; inviteeImage?: string }[];
|
||||
floating: boolean;
|
||||
}) {
|
||||
const cancelEvent = useSafeAction(cancelEventAction);
|
||||
return (
|
||||
<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 items-center gap-4 text-sm">
|
||||
<div>{title}</div>
|
||||
{status === "canceled" && (
|
||||
<Badge>
|
||||
<Trans i18nKey="canceled" defaults="Canceled" />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center whitespace-nowrap text-sm lg:min-w-40">
|
||||
<div>
|
||||
|
@ -69,6 +93,31 @@ export function ScheduledEventListItem({
|
|||
max={5}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
|
|
|
@ -11,7 +11,7 @@ dayjs.extend(utc);
|
|||
const mapStatus = {
|
||||
upcoming: "confirmed",
|
||||
unconfirmed: "unconfirmed",
|
||||
past: undefined,
|
||||
past: "confirmed",
|
||||
canceled: "canceled",
|
||||
} as const;
|
||||
|
||||
|
@ -35,7 +35,7 @@ function getEventsWhereInput({
|
|||
...(status === "upcoming" && {
|
||||
OR: [
|
||||
{ allDay: false, start: { gte: now } },
|
||||
{ allDay: true, start: { gte: todayStart, lte: todayEnd } },
|
||||
{ allDay: true, start: { gte: todayStart } },
|
||||
],
|
||||
}),
|
||||
...(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 { getEmailClient } from "@/utils/emails";
|
||||
|
||||
import { formatEventDateTime } from "@/features/scheduled-event/utils";
|
||||
import { getDefaultSpace } from "@/features/spaces/queries";
|
||||
import { getTimeZoneAbbreviation } from "../../utils/date";
|
||||
import {
|
||||
createRateLimitMiddleware,
|
||||
possiblyPublicProcedure,
|
||||
|
@ -479,6 +479,7 @@ export const polls = router({
|
|||
start: true,
|
||||
end: true,
|
||||
allDay: true,
|
||||
status: true,
|
||||
invites: {
|
||||
select: {
|
||||
id: true,
|
||||
|
@ -526,6 +527,7 @@ export const polls = router({
|
|||
(invite) =>
|
||||
invite.status === "accepted" || invite.status === "tentative",
|
||||
),
|
||||
status: res.scheduledEvent.status,
|
||||
}
|
||||
: null;
|
||||
|
||||
|
@ -629,56 +631,64 @@ export const polls = router({
|
|||
eventStart = eventStart.utc();
|
||||
}
|
||||
|
||||
if (!poll.spaceId) {
|
||||
const { spaceId } = poll;
|
||||
|
||||
if (!spaceId) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Poll has no space",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.poll.update({
|
||||
where: {
|
||||
id: input.pollId,
|
||||
},
|
||||
data: {
|
||||
status: "finalized",
|
||||
scheduledEvent: {
|
||||
create: {
|
||||
id: input.pollId,
|
||||
start: eventStart.toDate(),
|
||||
end: eventStart.add(option.duration, "minute").toDate(),
|
||||
title: poll.title,
|
||||
location: poll.location,
|
||||
timeZone: poll.timeZone,
|
||||
userId: ctx.user.id,
|
||||
spaceId: poll.spaceId,
|
||||
allDay: option.duration === 0,
|
||||
status: "confirmed",
|
||||
invites: {
|
||||
createMany: {
|
||||
data: poll.participants
|
||||
.filter((p) => p.email || p.user?.email) // Filter out participants without email
|
||||
.map((p) => ({
|
||||
inviteeName: p.name,
|
||||
inviteeEmail:
|
||||
p.user?.email ?? p.email ?? `${p.id}@rallly.co`,
|
||||
inviteeTimeZone: p.user?.timeZone ?? poll.timeZone, // We should track participant's timezone
|
||||
status: (
|
||||
{
|
||||
yes: "accepted",
|
||||
ifNeedBe: "tentative",
|
||||
no: "declined",
|
||||
} as const
|
||||
)[
|
||||
p.votes.find((v) => v.optionId === input.optionId)
|
||||
?.type ?? "no"
|
||||
],
|
||||
})),
|
||||
},
|
||||
const scheduledEvent = await prisma.$transaction(async (tx) => {
|
||||
// create scheduled event
|
||||
const event = await tx.scheduledEvent.create({
|
||||
data: {
|
||||
start: eventStart.toDate(),
|
||||
end: eventStart.add(option.duration, "minute").toDate(),
|
||||
title: poll.title,
|
||||
location: poll.location,
|
||||
timeZone: poll.timeZone,
|
||||
userId: ctx.user.id,
|
||||
spaceId,
|
||||
allDay: option.duration === 0,
|
||||
status: "confirmed",
|
||||
invites: {
|
||||
createMany: {
|
||||
data: poll.participants
|
||||
.filter((p) => p.email || p.user?.email) // Filter out participants without email
|
||||
.map((p) => ({
|
||||
inviteeName: p.name,
|
||||
inviteeEmail:
|
||||
p.user?.email ?? p.email ?? `${p.id}@rallly.co`,
|
||||
inviteeTimeZone: p.user?.timeZone ?? poll.timeZone, // We should track participant's timezone
|
||||
status: (
|
||||
{
|
||||
yes: "accepted",
|
||||
ifNeedBe: "tentative",
|
||||
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) =>
|
||||
|
@ -699,6 +709,8 @@ export const polls = router({
|
|||
: eventStart.add(1, "day");
|
||||
|
||||
const event = ics.createEvent({
|
||||
uid: scheduledEvent.id,
|
||||
sequence: 0, // TODO: Get sequence from database
|
||||
title: poll.title,
|
||||
location: poll.location ?? undefined,
|
||||
description: poll.description ?? undefined,
|
||||
|
@ -742,20 +754,6 @@ export const polls = router({
|
|||
message: "Failed to generate ics",
|
||||
});
|
||||
} 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<{
|
||||
name: 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", {
|
||||
to: poll.user.email,
|
||||
props: {
|
||||
|
@ -811,6 +816,13 @@ export const polls = router({
|
|||
});
|
||||
|
||||
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(
|
||||
"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_signoff": "Thank you for choosing Rallly!",
|
||||
"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 { ChangeEmailRequest } from "./templates/change-email-request";
|
||||
import { EventCanceledEmail } from "./templates/event-canceled";
|
||||
import { FinalizeHostEmail } from "./templates/finalized-host";
|
||||
import { FinalizeParticipantEmail } from "./templates/finalized-participant";
|
||||
import { LicenseKeyEmail } from "./templates/license-key";
|
||||
|
@ -23,6 +24,7 @@ const templates = {
|
|||
ChangeEmailRequest,
|
||||
AbandonedCheckoutEmail,
|
||||
LicenseKeyEmail,
|
||||
EventCanceledEmail,
|
||||
};
|
||||
|
||||
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,
|
||||
) => {
|
||||
return ctx.t("finalizeHost_subject", {
|
||||
defaultValue: "Date booked for {{title}}",
|
||||
defaultValue: "Date booked for {title}",
|
||||
title: props.title,
|
||||
ns: "emails",
|
||||
});
|
||||
|
|
|
@ -51,7 +51,7 @@ const FinalizeParticipantEmail = ({
|
|||
t={ctx.t}
|
||||
i18nKey="finalizeParticipant_content"
|
||||
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 }}
|
||||
components={{
|
||||
b: <strong />,
|
||||
|
@ -114,7 +114,7 @@ FinalizeParticipantEmail.getSubject = (
|
|||
ctx: EmailContext,
|
||||
) => {
|
||||
return ctx.t("finalizeParticipant_subject", {
|
||||
defaultValue: "Date booked for {{title}}",
|
||||
defaultValue: "Date booked for {title}",
|
||||
title: props.title,
|
||||
ns: "emails",
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue