fix: type errors and translate ee pages (#1050)

* fix: type errors and translate ee pages

* fix: translation key for composed string

* type fixes

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Mihai C 2021-10-29 01:58:26 +03:00 committed by GitHub
parent dddb494071
commit 98829d23d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 179 additions and 118 deletions

View file

@ -1,10 +1,14 @@
import { XIcon } from "@heroicons/react/outline"; import { XIcon } from "@heroicons/react/outline";
import { BadgeCheckIcon } from "@heroicons/react/solid"; import { BadgeCheckIcon } from "@heroicons/react/solid";
import { Trans } from "react-i18next";
import { useLocale } from "@lib/hooks/useLocale";
import { Dialog, DialogTrigger } from "@components/Dialog"; import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
export default function LicenseBanner() { export default function LicenseBanner() {
const { t } = useLocale();
/* /*
Set this value to 'agree' to accept our license: Set this value to 'agree' to accept our license:
LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE
@ -30,9 +34,11 @@ export default function LicenseBanner() {
</span> </span>
<p className="ml-3 font-medium text-white truncate"> <p className="ml-3 font-medium text-white truncate">
<span className="inline"> <span className="inline">
Accept our license by changing the .env variable{" "} <Trans i18nKey="accept_our_license" values={{ agree: "agree" }}>
<span className="bg-gray-50 bg-opacity-20 px-1">NEXT_PUBLIC_LICENSE_CONSENT</span> to Accept our license by changing the .env variable{" "}
&apos;agree&apos;. <span className="bg-gray-50 bg-opacity-20 px-1">NEXT_PUBLIC_LICENSE_CONSENT</span> to
&apos;agree&apos;.
</Trans>
</span> </span>
</p> </p>
</div> </div>
@ -40,7 +46,7 @@ export default function LicenseBanner() {
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<button className="rounded-sm w-full flex items-center justify-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium text-green-600 bg-white hover:bg-green-50"> <button className="rounded-sm w-full flex items-center justify-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium text-green-600 bg-white hover:bg-green-50">
Accept License {t("accept_license")}
</button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent /> <DialogContent />
@ -50,7 +56,7 @@ export default function LicenseBanner() {
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<button className="-mr-1 flex p-2 rounded-sm hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-white"> <button className="-mr-1 flex p-2 rounded-sm hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-white">
<span className="sr-only">Dismiss</span> <span className="sr-only">{t("dismiss")}</span>
<XIcon className="h-6 w-6 text-white" aria-hidden="true" /> <XIcon className="h-6 w-6 text-white" aria-hidden="true" />
</button> </button>
</DialogTrigger> </DialogTrigger>
@ -67,18 +73,22 @@ export default function LicenseBanner() {
return ( return (
<ConfirmationDialogContent <ConfirmationDialogContent
variety="success" variety="success"
title="Open .env and agree to our License" title={t("open_env")}
confirmBtnText="I've changed my .env" confirmBtnText={t("env_changed")}
cancelBtnText="Cancel"> cancelBtnText={t("cancel")}>
To remove this banner, please open your .env file and change the{" "} <Trans i18nKey="remove_banner_instructions" values={{ agree: "agree" }}>
<span className="bg-green-400 text-green-500 bg-opacity-20 p-[2px]">NEXT_PUBLIC_LICENSE_CONSENT</span>{" "} To remove this banner, please open your .env file and change the{" "}
variable to &apos;agree&apos;. <span className="bg-green-400 text-green-500 bg-opacity-20 p-[2px]">
<h2 className="mt-8 mb-2 text-black font-cal">Summary of terms:</h2> NEXT_PUBLIC_LICENSE_CONSENT
</span>{" "}
variable to &apos;agreeapos;.
</Trans>
<h2 className="mt-8 mb-2 text-black font-cal">{t("terms_summary")}:</h2>
<ul className="ml-5 list-disc"> <ul className="ml-5 list-disc">
<li>The codebase has to stay open source, whether it was modified or not</li> <li>{t("codebase_has_to_stay_opensource")}</li>
<li>You can not repackage or sell the codebase</li> <li>{t("cannot_repackage_codebase")}</li>
<li> <li>
Acquire a commercial license to remove these terms by emailing:{" "} {t("acquire_license")}:{" "}
<a className="text-blue-500 underline" href="mailto:license@cal.com"> <a className="text-blue-500 underline" href="mailto:license@cal.com">
license@cal.com license@cal.com
</a> </a>

View file

@ -1,11 +1,14 @@
import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js"; import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { StripeCardElementChangeEvent } from "@stripe/stripe-js";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { stringify } from "querystring"; import { stringify } from "querystring";
import React, { useState } from "react"; import React, { useState } from "react";
import { SyntheticEvent } from "react";
import { PaymentData } from "@ee/lib/stripe/server"; import { PaymentData } from "@ee/lib/stripe/server";
import useDarkMode from "@lib/core/browser/useDarkMode"; import useDarkMode from "@lib/core/browser/useDarkMode";
import { useLocale } from "@lib/hooks/useLocale";
import Button from "@components/ui/Button"; import Button from "@components/ui/Button";
@ -34,6 +37,7 @@ type Props = {
}; };
eventType: { id: number }; eventType: { id: number };
user: { username: string | null }; user: { username: string | null };
location: string;
}; };
type States = type States =
@ -43,6 +47,7 @@ type States =
| { status: "ok" }; | { status: "ok" };
export default function PaymentComponent(props: Props) { export default function PaymentComponent(props: Props) {
const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const { name, date } = router.query; const { name, date } = router.query;
const [state, setState] = useState<States>({ status: "idle" }); const [state, setState] = useState<States>({ status: "idle" });
@ -56,15 +61,17 @@ export default function PaymentComponent(props: Props) {
CARD_OPTIONS.style.base["::placeholder"].color = "#fff"; CARD_OPTIONS.style.base["::placeholder"].color = "#fff";
} }
const handleChange = async (event) => { const handleChange = async (event: StripeCardElementChangeEvent) => {
// Listen for changes in the CardElement // Listen for changes in the CardElement
// and display any errors as the customer types their card details // and display any errors as the customer types their card details
setState({ status: "idle" }); setState({ status: "idle" });
if (event.emtpy || event.error) if (event.error)
setState({ status: "error", error: new Error(event.error?.message || "Missing card fields") }); setState({ status: "error", error: new Error(event.error?.message || t("missing_card_fields")) });
}; };
const handleSubmit = async (ev) => {
const handleSubmit = async (ev: SyntheticEvent) => {
ev.preventDefault(); ev.preventDefault();
if (!stripe || !elements) return; if (!stripe || !elements) return;
const card = elements.getElement(CardElement); const card = elements.getElement(CardElement);
if (!card) return; if (!card) return;
@ -87,11 +94,11 @@ export default function PaymentComponent(props: Props) {
name, name,
}; };
if (payload["location"]) { if (props.location) {
if (payload["location"].includes("integration")) { if (props.location.includes("integration")) {
params.location = "Web conferencing details to follow."; params.location = t("web_conferencing_details_to_follow");
} else { } else {
params.location = payload["location"]; params.location = props.location;
} }
} }
@ -104,19 +111,19 @@ export default function PaymentComponent(props: Props) {
return ( return (
<form id="payment-form" className="mt-4" onSubmit={handleSubmit}> <form id="payment-form" className="mt-4" onSubmit={handleSubmit}>
<CardElement id="card-element" options={CARD_OPTIONS} onChange={handleChange} /> <CardElement id="card-element" options={CARD_OPTIONS} onChange={handleChange} />
<div className="flex mt-2 justify-center"> <div className="flex justify-center mt-2">
<Button <Button
type="submit" type="submit"
disabled={["processing", "error"].includes(state.status)} disabled={["processing", "error"].includes(state.status)}
loading={state.status === "processing"} loading={state.status === "processing"}
id="submit"> id="submit">
<span id="button-text"> <span id="button-text">
{state.status === "processing" ? <div className="spinner" id="spinner" /> : "Pay now"} {state.status === "processing" ? <div className="spinner" id="spinner" /> : t("pay_now")}
</span> </span>
</Button> </Button>
</div> </div>
{state.status === "error" && ( {state.status === "error" && (
<div className="mt-4 text-gray-700 dark:text-gray-300 text-center" role="alert"> <div className="mt-4 text-center text-gray-700 dark:text-gray-300" role="alert">
{state.error.message} {state.error.message}
</div> </div>
)} )}

View file

@ -12,6 +12,7 @@ import PaymentComponent from "@ee/components/stripe/Payment";
import getStripe from "@ee/lib/stripe/client"; import getStripe from "@ee/lib/stripe/client";
import { PaymentPageProps } from "@ee/pages/payment/[uid]"; import { PaymentPageProps } from "@ee/pages/payment/[uid]";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme"; import useTheme from "@lib/hooks/useTheme";
dayjs.extend(utc); dayjs.extend(utc);
@ -19,6 +20,7 @@ dayjs.extend(toArray);
dayjs.extend(timezone); dayjs.extend(timezone);
const PaymentPage: FC<PaymentPageProps> = (props) => { const PaymentPage: FC<PaymentPageProps> = (props) => {
const { t } = useLocale();
const [is24h, setIs24h] = useState(false); const [is24h, setIs24h] = useState(false);
const [date, setDate] = useState(dayjs.utc(props.booking.startTime)); const [date, setDate] = useState(dayjs.utc(props.booking.startTime));
const { isReady } = useTheme(props.profile.theme); const { isReady } = useTheme(props.profile.theme);
@ -31,43 +33,45 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
const eventName = props.booking.title; const eventName = props.booking.title;
return isReady ? ( return isReady ? (
<div className="bg-neutral-50 dark:bg-neutral-900 h-screen"> <div className="h-screen bg-neutral-50 dark:bg-neutral-900">
<Head> <Head>
<title>Payment | {eventName} | Calendso</title> <title>
{t("payment")} | {eventName} | Cal.com
</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<main className="max-w-3xl mx-auto py-24"> <main className="max-w-3xl py-24 mx-auto">
<div className="fixed z-50 inset-0 overflow-y-auto"> <div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> <div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true"> <div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203; &#8203;
</span> </span>
<div <div
className="inline-block align-bottom dark:bg-gray-800 bg-white rounded-sm px-8 pt-5 pb-4 text-left overflow-hidden border border-neutral-200 dark:border-neutral-700 transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:py-6" className="inline-block px-8 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white border rounded-sm dark:bg-gray-800 border-neutral-200 dark:border-neutral-700 sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:py-6"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="modal-headline"> aria-labelledby="modal-headline">
<div> <div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100"> <div className="flex items-center justify-center w-12 h-12 mx-auto bg-green-100 rounded-full">
<CreditCardIcon className="h-8 w-8 text-green-600" /> <CreditCardIcon className="w-8 h-8 text-green-600" />
</div> </div>
<div className="mt-3 text-center sm:mt-5"> <div className="mt-3 text-center sm:mt-5">
<h3 <h3
className="text-2xl leading-6 font-semibold dark:text-white text-neutral-900" className="text-2xl font-semibold leading-6 dark:text-white text-neutral-900"
id="modal-headline"> id="modal-headline">
Payment {t("payment")}
</h3> </h3>
<div className="mt-3"> <div className="mt-3">
<p className="text-sm text-neutral-600 dark:text-gray-300"> <p className="text-sm text-neutral-600 dark:text-gray-300">
You have also received an email with this link, if you want to pay later. {t("pay_later_instructions")}
</p> </p>
</div> </div>
<div className="mt-4 text-gray-700 dark:text-gray-300 border-t border-b dark:border-gray-900 py-4 grid grid-cols-3 text-left"> <div className="grid grid-cols-3 py-4 mt-4 text-left text-gray-700 border-t border-b dark:text-gray-300 dark:border-gray-900">
<div className="font-medium">What</div> <div className="font-medium">{t("what")}</div>
<div className="mb-6 col-span-2">{eventName}</div> <div className="col-span-2 mb-6">{eventName}</div>
<div className="font-medium">When</div> <div className="font-medium">{t("when")}</div>
<div className="mb-6 col-span-2"> <div className="col-span-2 mb-6">
{date.format("dddd, DD MMMM YYYY")} {date.format("dddd, DD MMMM YYYY")}
<br /> <br />
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "} {date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
@ -77,12 +81,12 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
</div> </div>
{props.booking.location && ( {props.booking.location && (
<> <>
<div className="font-medium">Where</div> <div className="font-medium">{t("where")}</div>
<div className="mb-6 col-span-2">{props.booking.location}</div> <div className="col-span-2 mb-6">{props.booking.location}</div>
</> </>
)} )}
<div className="font-medium">Price</div> <div className="font-medium">{t("price")}</div>
<div className="mb-6 col-span-2"> <div className="col-span-2 mb-6">
<IntlProvider locale="en"> <IntlProvider locale="en">
<FormattedNumber <FormattedNumber
value={props.eventType.price / 100.0} value={props.eventType.price / 100.0}
@ -96,7 +100,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
</div> </div>
<div> <div>
{props.payment.success && !props.payment.refunded && ( {props.payment.success && !props.payment.refunded && (
<div className="mt-4 text-gray-700 dark:text-gray-300 text-center">Paid</div> <div className="mt-4 text-center text-gray-700 dark:text-gray-300">{t("paid")}</div>
)} )}
{!props.payment.success && ( {!props.payment.success && (
<Elements stripe={getStripe(props.payment.data.stripe_publishable_key)}> <Elements stripe={getStripe(props.payment.data.stripe_publishable_key)}>
@ -104,16 +108,17 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
payment={props.payment} payment={props.payment}
eventType={props.eventType} eventType={props.eventType}
user={props.user} user={props.user}
location={props.booking.location}
/> />
</Elements> </Elements>
)} )}
{props.payment.refunded && ( {props.payment.refunded && (
<div className="mt-4 text-gray-700 dark:text-gray-300 text-center">Refunded</div> <div className="mt-4 text-center text-gray-700 dark:text-gray-300">{t("refunded")}</div>
)} )}
</div> </div>
{!props.profile.hideBranding && ( {!props.profile.hideBranding && (
<div className="mt-4 pt-4 border-t dark:border-gray-900 text-gray-400 text-center text-xs dark:text-white"> <div className="pt-4 mt-4 text-xs text-center text-gray-400 border-t dark:border-gray-900 dark:text-white">
<a href="https://cal.com/signup">Create your own booking link with Cal.com</a> <a href="https://cal.com/signup">{t("create_booking_link_with_calcom")}</a>
</div> </div>
)} )}
</div> </div>

View file

@ -2,10 +2,12 @@ import { ChatAltIcon } from "@heroicons/react/solid";
import { useIntercom } from "react-use-intercom"; import { useIntercom } from "react-use-intercom";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import { DropdownMenuItem } from "@components/ui/Dropdown"; import { DropdownMenuItem } from "@components/ui/Dropdown";
const HelpMenuItem = () => { const HelpMenuItem = () => {
const { t } = useLocale();
const { boot, show } = useIntercom(); const { boot, show } = useIntercom();
return ( return (
<DropdownMenuItem> <DropdownMenuItem>
@ -22,7 +24,7 @@ const HelpMenuItem = () => {
)} )}
aria-hidden="true" aria-hidden="true"
/> />
Help {t("help")}
</button> </button>
</DropdownMenuItem> </DropdownMenuItem>
); );

View file

@ -6,6 +6,7 @@ import { v4 as uuidv4 } from "uuid";
import { CalendarEvent } from "@lib/calendarClient"; import { CalendarEvent } from "@lib/calendarClient";
import EventOrganizerRefundFailedMail from "@lib/emails/EventOrganizerRefundFailedMail"; import EventOrganizerRefundFailedMail from "@lib/emails/EventOrganizerRefundFailedMail";
import EventPaymentMail from "@lib/emails/EventPaymentMail"; import EventPaymentMail from "@lib/emails/EventPaymentMail";
import { getErrorFromUnknown } from "@lib/errors";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { createPaymentLink } from "./client"; import { createPaymentLink } from "./client";
@ -79,8 +80,7 @@ export async function handlePayment(
name: booking.user?.name, name: booking.user?.name,
date: booking.startTime.toISOString(), date: booking.startTime.toISOString(),
}), }),
evt, evt
booking.uid
); );
await mail.sendEmail(); await mail.sendEmail();
@ -110,7 +110,6 @@ export async function refund(
if (payment.type != PaymentType.STRIPE) { if (payment.type != PaymentType.STRIPE) {
await handleRefundError({ await handleRefundError({
event: calEvent, event: calEvent,
booking: booking,
reason: "cannot refund non Stripe payment", reason: "cannot refund non Stripe payment",
paymentId: "unknown", paymentId: "unknown",
}); });
@ -127,7 +126,6 @@ export async function refund(
if (!refund || refund.status === "failed") { if (!refund || refund.status === "failed") {
await handleRefundError({ await handleRefundError({
event: calEvent, event: calEvent,
booking: booking,
reason: refund?.failure_reason || "unknown", reason: refund?.failure_reason || "unknown",
paymentId: payment.externalId, paymentId: payment.externalId,
}); });
@ -143,30 +141,20 @@ export async function refund(
}, },
}); });
} catch (e) { } catch (e) {
console.error(e, "Refund failed"); const err = getErrorFromUnknown(e);
console.error(err, "Refund failed");
await handleRefundError({ await handleRefundError({
event: calEvent, event: calEvent,
booking: booking, reason: err.message || "unknown",
reason: e.message || "unknown",
paymentId: "unknown", paymentId: "unknown",
}); });
} }
} }
async function handleRefundError(opts: { async function handleRefundError(opts: { event: CalendarEvent; reason: string; paymentId: string }) {
event: CalendarEvent; console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`);
booking: { id: number; uid: string };
reason: string;
paymentId: string;
}) {
console.error(`refund failed: ${opts.reason} for booking '${opts.booking.id}'`);
try { try {
await new EventOrganizerRefundFailedMail( await new EventOrganizerRefundFailedMail(opts.event, opts.reason, opts.paymentId).sendEmail();
opts.event,
opts.booking.uid,
opts.reason,
opts.paymentId
).sendEmail();
} catch (e) { } catch (e) {
console.error("Error while sending refund error email", e); console.error("Error while sending refund error email", e);
} }

View file

@ -55,6 +55,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
timeZone: true, timeZone: true,
email: true, email: true,
name: true, name: true,
locale: true,
}, },
}, },
}, },
@ -72,7 +73,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
if (!user) throw new Error("No user found"); if (!user) throw new Error("No user found");
const t = await getTranslation(/* FIXME handle mulitple locales here */ "en", "common"); const t = await getTranslation(user.locale ?? "en", "common");
const evt: Ensure<CalendarEvent, "language"> = { const evt: Ensure<CalendarEvent, "language"> = {
type: booking.title, type: booking.title,
@ -85,6 +86,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
uid: booking.uid, uid: booking.uid,
language: t, language: t,
}; };
if (booking.location) evt.location = booking.location; if (booking.location) evt.location = booking.location;
if (booking.confirmed) { if (booking.confirmed) {

View file

@ -16,23 +16,27 @@ export default class EventOrganizerRefundFailedMail extends EventOrganizerMail {
reason: string; reason: string;
paymentId: string; paymentId: string;
constructor(calEvent: CalendarEvent, uid: string, reason: string, paymentId: string) { constructor(calEvent: CalendarEvent, reason: string, paymentId: string) {
super(calEvent, uid, undefined); super(calEvent);
this.reason = reason; this.reason = reason;
this.paymentId = paymentId; this.paymentId = paymentId;
} }
protected getBodyHeader(): string { protected getBodyHeader(): string {
return "A refund failed"; return this.calEvent.language("a_refund_failed");
} }
protected getBodyText(): string { protected getBodyText(): string {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return `The refund for the event ${this.calEvent.type} with ${ return `${this.calEvent.language("refund_failed", {
this.calEvent.attendees[0].name eventType: this.calEvent.type,
} on ${organizerStart.format("LT dddd, LL")} failed. Please check with your payment provider and ${ userName: this.calEvent.attendees[0].name,
this.calEvent.attendees[0].name date: organizerStart.format("LT dddd, LL"),
} how to handle this.<br>The error message was: '${this.reason}'<br>PaymentId: '${this.paymentId}'`; })} ${this.calEvent.language("check_with_provider_and_user", {
userName: this.calEvent.attendees[0].name,
})}<br>${this.calEvent.language("error_message", { errorMessage: this.reason })}<br>PaymentId: '${
this.paymentId
}'`;
} }
protected getAdditionalBody(): string { protected getAdditionalBody(): string {
@ -58,8 +62,10 @@ export default class EventOrganizerRefundFailedMail extends EventOrganizerMail {
protected getSubject(): string { protected getSubject(): string {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return `Refund failed: ${this.calEvent.attendees[0].name} - ${organizerStart.format("LT dddd, LL")} - ${ return this.calEvent.language("refund_failed_subject", {
this.calEvent.type userName: this.calEvent.attendees[0].name,
}`; date: organizerStart.format("LT dddd, LL"),
eventType: this.calEvent.type,
});
} }
} }

View file

@ -13,13 +13,15 @@ dayjs.extend(localizedFormat);
export default class EventOrganizerRequestReminderMail extends EventOrganizerRequestMail { export default class EventOrganizerRequestReminderMail extends EventOrganizerRequestMail {
protected getBodyHeader(): string { protected getBodyHeader(): string {
return "An event is still waiting for your approval."; return this.calEvent.language("still_waiting_for_approval");
} }
protected getSubject(): string { protected getSubject(): string {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return `Event request is still waiting: ${this.calEvent.attendees[0].name} - ${organizerStart.format( return this.calEvent.language("event_is_still_waiting", {
"LT dddd, LL" attendeeName: this.calEvent.attendees[0].name,
)} - ${this.calEvent.type}`; date: organizerStart.format("LT dddd, LL"),
eventType: this.calEvent.type,
});
} }
} }

View file

@ -5,7 +5,7 @@ import utc from "dayjs/plugin/utc";
import { CalendarEvent } from "@lib/calendarClient"; import { CalendarEvent } from "@lib/calendarClient";
import EventMail, { AdditionInformation } from "./EventMail"; import EventMail from "./EventMail";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@ -14,13 +14,8 @@ dayjs.extend(localizedFormat);
export default class EventPaymentMail extends EventMail { export default class EventPaymentMail extends EventMail {
paymentLink: string; paymentLink: string;
constructor( constructor(paymentLink: string, calEvent: CalendarEvent) {
paymentLink: string, super(calEvent);
calEvent: CalendarEvent,
uid: string,
additionInformation: AdditionInformation = null
) {
super(calEvent, uid, additionInformation);
this.paymentLink = paymentLink; this.paymentLink = paymentLink;
} }
@ -59,8 +54,10 @@ export default class EventPaymentMail extends EventMail {
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
<h1 style="font-weight: 500; color: #161e2e;">Your meeting is awaiting payment</h1> <h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language("meeting_awaiting_payment")}</h1>
<p style="color: #4b5563; margin-bottom: 30px;">You and any other attendees have been emailed with this information.</p> <p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language(
"emailed_you_and_any_other_attendees"
)}</p>
<hr /> <hr />
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;"> <table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
<colgroup> <colgroup>
@ -68,25 +65,25 @@ export default class EventPaymentMail extends EventMail {
<col span="1" style="width: 60%;"> <col span="1" style="width: 60%;">
</colgroup> </colgroup>
<tr> <tr>
<td>What</td> <td>${this.calEvent.language("what")}</td>
<td>${this.calEvent.type}</td> <td>${this.calEvent.type}</td>
</tr> </tr>
<tr> <tr>
<td>When</td> <td>${this.calEvent.language("when")}</td>
<td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${ <td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${
this.calEvent.attendees[0].timeZone this.calEvent.attendees[0].timeZone
})</td> })</td>
</tr> </tr>
<tr> <tr>
<td>Who</td> <td>${this.calEvent.language("who")}</td>
<td>${this.calEvent.organizer.name}<br /><small>${this.calEvent.organizer.email}</small></td> <td>${this.calEvent.organizer.name}<br /><small>${this.calEvent.organizer.email}</small></td>
</tr> </tr>
<tr> <tr>
<td>Where</td> <td>${this.calEvent.language("where")}</td>
<td>${this.getLocation()}</td> <td>${this.getLocation()}</td>
</tr> </tr>
<tr> <tr>
<td>Notes</td> <td>${this.calEvent.language("notes")}Notes</td>
<td>${this.calEvent.description}</td> <td>${this.calEvent.description}</td>
</tr> </tr>
</table> </table>
@ -109,15 +106,18 @@ export default class EventPaymentMail extends EventMail {
* @protected * @protected
*/ */
protected getLocation(): string { protected getLocation(): string {
if (this.additionInformation?.hangoutLink) { if (this.calEvent.additionInformation?.hangoutLink) {
return `<a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`; return `<a href="${this.calEvent.additionInformation?.hangoutLink}">${this.calEvent.additionInformation?.hangoutLink}</a><br />`;
} }
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) { if (
const locations = this.additionInformation?.entryPoints this.calEvent.additionInformation?.entryPoints &&
this.calEvent.additionInformation?.entryPoints.length > 0
) {
const locations = this.calEvent.additionInformation?.entryPoints
.map((entryPoint) => { .map((entryPoint) => {
return ` return `
Join by ${entryPoint.entryPointType}: <br /> ${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br /> <a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`; `;
}) })
@ -130,7 +130,7 @@ export default class EventPaymentMail extends EventMail {
} }
protected getAdditionalBody(): string { protected getAdditionalBody(): string {
return `<a href="${this.paymentLink}">Pay now</a>`; return `<a href="${this.paymentLink}">${this.calEvent.language("pay_now")}</a>`;
} }
/** /**
@ -143,15 +143,17 @@ export default class EventPaymentMail extends EventMail {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email, replyTo: this.calEvent.organizer.email,
subject: `Awaiting Payment: ${this.calEvent.type} with ${ subject: this.calEvent.language("awaiting_payment", {
this.calEvent.organizer.name eventType: this.calEvent.type,
} on ${this.getInviteeStart().format("dddd, LL")}`, organizerName: this.calEvent.organizer.name,
date: this.getInviteeStart().format("dddd, LL"),
}),
html: this.getHtmlRepresentation(), html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(), text: this.getPlainTextRepresentation(),
}; };
} }
protected printNodeMailerError(error: string): void { protected printNodeMailerError(error: Error): void {
console.error("SEND_BOOKING_PAYMENT_ERROR", this.calEvent.attendees[0].email, error); console.error("SEND_BOOKING_PAYMENT_ERROR", this.calEvent.attendees[0].email, error);
} }

View file

@ -1,8 +1,9 @@
import nodemailer, { SentMessageInfo } from "nodemailer"; import nodemailer, { SentMessageInfo } from "nodemailer";
import { SendMailOptions } from "nodemailer";
import { serverConfig } from "../serverConfig"; import { serverConfig } from "../serverConfig";
const sendEmail = ({ to, subject, text, html = null }): Promise<string | SentMessageInfo> => const sendEmail = ({ to, subject, text, html }: SendMailOptions): Promise<string | SentMessageInfo> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const { transport, from } = serverConfig; const { transport, from } = serverConfig;

View file

@ -37,7 +37,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
startTime: true, startTime: true,
endTime: true, endTime: true,
attendees: true, attendees: true,
user: true, user: {
select: {
email: true,
name: true,
username: true,
locale: true,
timeZone: true,
},
},
id: true, id: true,
uid: true, uid: true,
}, },
@ -62,7 +70,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
console.error(`Booking ${booking.id} is missing required properties for booking reminder`, { user }); console.error(`Booking ${booking.id} is missing required properties for booking reminder`, { user });
continue; continue;
} }
const t = await getTranslation(req.body.language ?? "en", "common");
const t = await getTranslation(user.locale ?? "en", "common");
const evt: CalendarEvent = { const evt: CalendarEvent = {
type: booking.title, type: booking.title,
title: booking.title, title: booking.title,

View file

@ -1,4 +1,30 @@
{ {
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
"remove_banner_instructions": "To remove this banner, please open your .env file and change the <1>NEXT_PUBLIC_LICENSE_CONSENT</1> variable to '{{agree}}'.",
"error_message": "The error message was: '{{errorMessage}}'",
"refund_failed_subject": "Refund failed: {{userName}} - {{date}} - {{eventType}}",
"refund_failed": "The refund for the event {{eventType}} with {{userName}} on {{date}} failed.",
"check_with_provider_and_user": "Please check with your payment provider and {{userName}} how to handle this.",
"a_refund_failed": "A refund failed",
"awaiting_payment": "Awaiting Payment: {{eventType}} with {{organizerName}} on {{date}}",
"meeting_awaiting_payment": "Your meeting is awaiting payment",
"help": "Help",
"price": "Price",
"paid": "Paid",
"refunded": "Refunded",
"pay_later_instructions": "You have also received an email with this link, if you want to pay later.",
"payment": "Payment",
"missing_card_fields": "Missing card fields",
"pay_now": "Pay now",
"codebase_has_to_stay_opensource": "The codebase has to stay open source, whether it was modified or not",
"cannot_repackage_codebase": "You can not repackage or sell the codebase",
"acquire_license": "Acquire a commercial license to remove these terms by emailing",
"terms_summary": "Summary of terms",
"open_env": "Open .env and agree to our License",
"env_changed": "I've changed my .env",
"accept_license": "Accept License",
"still_waiting_for_approval": "An event is still waiting for approval",
"event_is_still_waiting": "Event request is still waiting: {{attendeeName}} - {{date}} - {{eventType}}",
"no_more_results": "No more results", "no_more_results": "No more results",
"load_more_results": "Load more results", "load_more_results": "Load more results",
"integration_meeting_id": "{{integrationName}} meeting ID: {{meetingId}}", "integration_meeting_id": "{{integrationName}} meeting ID: {{meetingId}}",
@ -159,6 +185,7 @@
"add_to_calendar": "Add to calendar", "add_to_calendar": "Add to calendar",
"other": "Other", "other": "Other",
"emailed_you_and_attendees": "We emailed you and the other attendees a calendar invitation with all the details.", "emailed_you_and_attendees": "We emailed you and the other attendees a calendar invitation with all the details.",
"emailed_you_and_any_other_attendees": "You and any other attendees have been emailed with this information.",
"needs_to_be_confirmed_or_rejected": "Your booking still needs to be confirmed or rejected.", "needs_to_be_confirmed_or_rejected": "Your booking still needs to be confirmed or rejected.",
"user_needs_to_confirm_or_reject_booking": "{{user}} still needs to confirm or reject the booking.", "user_needs_to_confirm_or_reject_booking": "{{user}} still needs to confirm or reject the booking.",
"meeting_is_scheduled": "This meeting is scheduled", "meeting_is_scheduled": "This meeting is scheduled",
@ -459,7 +486,6 @@
"date_range": "Date Range", "date_range": "Date Range",
"calendar_days": "calendar days", "calendar_days": "calendar days",
"business_days": "business days", "business_days": "business days",
"payment": "Payment",
"set_address_place": "Set an address or place", "set_address_place": "Set an address or place",
"cal_invitee_phone_number_scheduling": "Cal will ask your invitee to enter a phone number before scheduling.", "cal_invitee_phone_number_scheduling": "Cal will ask your invitee to enter a phone number before scheduling.",
"cal_provide_google_meet_location": "Cal will provide a Google Meet location.", "cal_provide_google_meet_location": "Cal will provide a Google Meet location.",