Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add translations for emails and type error fixes overall #994

Merged
merged 19 commits into from Oct 25, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
23 changes: 22 additions & 1 deletion lib/core/i18n/i18n.utils.ts
@@ -1,5 +1,8 @@
import parser from "accept-language-parser";
import { IncomingMessage } from "http";
import i18next from "i18next";
import { i18n as nexti18next } from "next-i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";

import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
Expand All @@ -9,7 +12,10 @@ import { Maybe } from "@trpc/server";
import { i18n } from "../../../next-i18next.config";

export function getLocaleFromHeaders(req: IncomingMessage): string {
const preferredLocale = parser.pick(i18n.locales, req.headers["accept-language"]) as Maybe<string>;
const preferredLocale = parser.pick(
i18n.locales,
req.headers["accept-language"] as string
) as Maybe<string>;
zomars marked this conversation as resolved.
Show resolved Hide resolved

return preferredLocale ?? i18n.defaultLocale;
}
Expand Down Expand Up @@ -44,3 +50,18 @@ export const getOrSetUserLocaleFromHeaders = async (req: IncomingMessage): Promi

return preferredLocale;
};

export const getT = async (locale: string, ns: string) => {
mihaic195 marked this conversation as resolved.
Show resolved Hide resolved
const create = async () => {
const { _nextI18Next } = await serverSideTranslations(locale, [ns]);
const _i18n = i18next.createInstance();
_i18n.init({
lng: locale,
resources: _nextI18Next.initialI18nStore,
fallbackLng: _nextI18Next.userConfig?.i18n.defaultLocale,
});
return _i18n;
};
const _i18n = nexti18next != null ? nexti18next : await create();
return _i18n.getFixedT(locale, ns);
};
mihaic195 marked this conversation as resolved.
Show resolved Hide resolved
23 changes: 22 additions & 1 deletion lib/emails/buildMessageTemplate.ts
@@ -1,15 +1,36 @@
import Handlebars from "handlebars";
import { TFunction } from "next-i18next";

export type VarType = {
language: TFunction;
user: {
name: string | null;
};
link: string;
};

export type MessageTemplateTypes = {
messageTemplate: string;
subjectTemplate: string;
vars: VarType;
};

export type BuildTemplateResult = {
subject: string;
message: string;
};

export const buildMessageTemplate = ({
messageTemplate,
subjectTemplate,
vars,
}): { subject: string; message: string } => {
}: MessageTemplateTypes): BuildTemplateResult => {
const buildMessage = Handlebars.compile(messageTemplate);
const message = buildMessage(vars);

const buildSubject = Handlebars.compile(subjectTemplate);
const subject = buildSubject(vars);

return {
subject,
message,
Expand Down
28 changes: 18 additions & 10 deletions lib/forgot-password/messaging/forgot-password.ts
@@ -1,20 +1,28 @@
import buildMessageTemplate from "../../emails/buildMessageTemplate";
import { TFunction } from "next-i18next";

export const forgotPasswordSubjectTemplate = "Forgot your password? - Cal.com";
import { buildMessageTemplate, VarType } from "../../emails/buildMessageTemplate";

export const forgotPasswordMessageTemplate = `Hey there,
export const forgotPasswordSubjectTemplate = (t: TFunction): string => {
const text = t("forgot_your_password_calcom");
return text;
};

export const forgotPasswordMessageTemplate = (t: TFunction): string => {
const text = `${t("hey_there")}

Use the link below to reset your password.
{{link}}
${t("use_link_to_reset_password")}
{{link}}

p.s. It expires in 6 hours.
${t("link_expires", { expiresIn: 6 })}

- Cal.com`;
- Cal.com`;
return text;
};

export const buildForgotPasswordMessage = (vars) => {
export const buildForgotPasswordMessage = (vars: VarType) => {
return buildMessageTemplate({
subjectTemplate: forgotPasswordSubjectTemplate,
messageTemplate: forgotPasswordMessageTemplate,
subjectTemplate: forgotPasswordSubjectTemplate(vars.language),
messageTemplate: forgotPasswordMessageTemplate(vars.language),
vars,
});
};
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -99,6 +99,7 @@
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "2.0.4",
"@types/accept-language-parser": "^1.5.2",
"@types/async": "^3.2.7",
"@types/bcryptjs": "^2.4.2",
"@types/jest": "^27.0.1",
Expand Down
14 changes: 9 additions & 5 deletions pages/api/auth/forgot-password.ts
@@ -1,25 +1,28 @@
import { User, ResetPasswordRequest } from "@prisma/client";
import { ResetPasswordRequest } from "@prisma/client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { NextApiRequest, NextApiResponse } from "next";

import sendEmail from "../../../lib/emails/sendMail";
import { buildForgotPasswordMessage } from "../../../lib/forgot-password/messaging/forgot-password";
import prisma from "../../../lib/prisma";
import { getT } from "@lib/core/i18n/i18n.utils";
import sendEmail from "@lib/emails/sendMail";
import { buildForgotPasswordMessage } from "@lib/forgot-password/messaging/forgot-password";
import prisma from "@lib/prisma";

dayjs.extend(utc);
dayjs.extend(timezone);

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const t = await getT(req.body.language ?? "en", "common");

if (req.method !== "POST") {
return res.status(405).json({ message: "" });
}

try {
const rawEmail = req.body?.email;

const maybeUser: User = await prisma.user.findUnique({
const maybeUser = await prisma.user.findUnique({
where: {
email: rawEmail,
},
Expand Down Expand Up @@ -59,6 +62,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)

const passwordResetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`;
const { subject, message } = buildForgotPasswordMessage({
language: t,
user: {
name: maybeUser.name,
},
Expand Down
22 changes: 12 additions & 10 deletions pages/auth/forgot-password/index.tsx
@@ -1,29 +1,31 @@
import debounce from "lodash/debounce";
import { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/client";
import Link from "next/link";
import React from "react";
import React, { SyntheticEvent } from "react";

import { getSession } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";

import { HeadSeo } from "@components/seo/head-seo";

export default function ForgotPassword({ csrfToken }) {
const { t } = useLocale();
export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
const { t, i18n } = useLocale();
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
const [error, setError] = React.useState<{ message: string } | null>(null);
const [success, setSuccess] = React.useState(false);
const [email, setEmail] = React.useState("");

const handleChange = (e) => {
setEmail(e.target.value);
const handleChange = (e: SyntheticEvent) => {
const target = e.target as typeof e.target & { value: string };
setEmail(target.value);
};

const submitForgotPasswordRequest = async ({ email }) => {
const submitForgotPasswordRequest = async ({ email }: { email: string }) => {
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email: email }),
body: JSON.stringify({ email: email, language: i18n.language }),
headers: {
"Content-Type": "application/json",
},
Expand All @@ -46,7 +48,7 @@ export default function ForgotPassword({ csrfToken }) {

const debouncedHandleSubmitPasswordRequest = debounce(submitForgotPasswordRequest, 250);

const handleSubmit = async (e) => {
const handleSubmit = async (e: SyntheticEvent) => {
e.preventDefault();

if (!email) {
Expand Down Expand Up @@ -157,7 +159,7 @@ export default function ForgotPassword({ csrfToken }) {
);
}

ForgotPassword.getInitialProps = async (context) => {
ForgotPassword.getInitialProps = async (context: GetServerSidePropsContext) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't getInitialProps deprecated?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed it should be changed!

const { req, res } = context;
const session = await getSession({ req });

Expand Down
60 changes: 30 additions & 30 deletions pages/event-types/[type].tsx
Expand Up @@ -63,32 +63,35 @@ import * as RadioArea from "@components/ui/form/radio-area";
dayjs.extend(utc);
dayjs.extend(timezone);

const PERIOD_TYPES = [
{
type: "rolling",
suffix: "into the future",
},
{
type: "range",
prefix: "Within a date range",
},
{
type: "unlimited",
prefix: "Indefinitely into the future",
},
];

const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const { t } = useLocale();
const PERIOD_TYPES = [
{
type: "rolling",
suffix: t("into_the_future"),
},
{
type: "range",
prefix: t("within_date_range"),
},
{
type: "unlimited",
prefix: t("indefinitely_into_future"),
},
];
const { eventType, locationOptions, availability, team, teamMembers, hasPaymentIntegration, currency } =
props;
locationOptions.push(
{ value: LocationType.InPerson, label: t("in_person_meeting") },
{ value: LocationType.Phone, label: t("phone_call") }
);

const { t } = useLocale();
const router = useRouter();

const updateMutation = useMutation(updateEventType, {
onSuccess: async ({ eventType }) => {
await router.push("/event-types");
showToast(`${eventType.title} event type updated successfully`, "success");
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
},
onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`;
Expand All @@ -99,7 +102,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const deleteMutation = useMutation(deleteEventType, {
onSuccess: async () => {
await router.push("/event-types");
showToast("Event type deleted successfully", "success");
showToast(t("event_type_deleted_successfully"), "success");
},
onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`;
Expand Down Expand Up @@ -274,13 +277,13 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const schedulingTypeOptions: { value: SchedulingType; label: string; description: string }[] = [
{
value: SchedulingType.COLLECTIVE,
label: "Collective",
description: "Schedule meetings when all selected team members are available.",
label: t("collective"),
description: t("collective_description"),
},
{
value: SchedulingType.ROUND_ROBIN,
label: "Round Robin",
description: "Cycle meetings between multiple team members.",
label: t("round_robin"),
description: t("round_robin_description"),
},
];

Expand Down Expand Up @@ -311,7 +314,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div>
<Shell
centered
title={`${eventType.title} | Event Type`}
title={t("event_type_title", { eventTypeTitle: eventType.title })}
heading={
<div className="relative -mb-2 group" onClick={() => setEditIcon(false)}>
<input
Expand All @@ -321,7 +324,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
id="title"
required
className="w-full pl-0 text-xl font-bold text-gray-900 bg-transparent border-none cursor-pointer focus:text-black hover:text-gray-700 focus:ring-0 focus:outline-none"
placeholder="Quick Chat"
placeholder={t("quick_chat")}
defaultValue={eventType.title}
/>
{editIcon && (
Expand Down Expand Up @@ -491,7 +494,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
fillRule="evenodd"></path>
</g>
</svg>
<span className="ml-2 text-sm"> Daily.co Video</span>
<span className="ml-2 text-sm">Daily.co Video</span>
</div>
)}
{location.type === LocationType.Zoom && (
Expand Down Expand Up @@ -682,7 +685,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div>
<div>
<span className="ml-2 text-sm">
{customInput.required ? "Required" : "Optional"}
{customInput.required ? t("required") : t("optional")}
</span>
</div>
</div>
Expand Down Expand Up @@ -1228,10 +1231,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>

const integrations = getIntegrations(credentials);

const locationOptions: OptionTypeBase[] = [
{ value: LocationType.InPerson, label: "Link or In-person meeting" },
{ value: LocationType.Phone, label: "Phone call" },
];
const locationOptions: OptionTypeBase[] = [];

if (hasIntegration(integrations, "zoom_video")) {
locationOptions.push({ value: LocationType.Zoom, label: "Zoom Video", disabled: true });
Expand Down
3 changes: 2 additions & 1 deletion pages/event-types/index.tsx
Expand Up @@ -40,6 +40,7 @@ import * as RadioArea from "@components/ui/form/radio-area";
import UserCalendarIllustration from "@components/ui/svg/UserCalendarIllustration";

type Profiles = inferQueryOutput<"viewer.eventTypes">["profiles"];
type Profile = inferQueryOutput<"viewer.eventTypes">["profile"];

interface CreateEventTypeProps {
canAddEvents: boolean;
Expand Down Expand Up @@ -342,7 +343,7 @@ const CreateNewEventButton = ({ profiles, canAddEvents }: CreateEventTypeProps)
const createMutation = useMutation(createEventType, {
onSuccess: async ({ eventType }) => {
await router.push("/event-types/" + eventType.id);
showToast(`${eventType.title} event type created successfully`, "success");
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
},
onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`;
Expand Down