Setup i18n and locale detection (#712)

* feat: setup translations

* feat: i18n setup

* Update pages/settings/profile.tsx

Co-authored-by: Alex Johansson <alexander@n1s.se>

* fix: abstract locale hook

* fix: set default locale if preferred locale is not supported

* Revert "fix: set default locale if preferred locale is not supported"

This reverts commit e2a3d81371ee02a033520058a1d7d61cffeffc94.

* fix: set default locale if preferred locale is not supported

* fix: use 1 namespace and remove unnecessary logs

* fix: yarn.lock

* fix: linting errors

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex Johansson <alexander@n1s.se>
This commit is contained in:
Mihai C 2021-09-23 11:49:17 +03:00 committed by GitHub
parent 3764a9d462
commit 82e7e51fca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 801 additions and 1685 deletions

View file

@ -1,3 +1,3 @@
{ {
"presets": ["next/babel"] "presets": ["next/babel"]
} }

View file

@ -1,18 +1,19 @@
--- ---
name: Bug report name: Bug report
about: Report any issues with the platform about: Report any issues with the platform
title: '' title: ""
labels: bug labels: bug
assignees: '' assignees: ""
--- ---
Found a bug? Please fill out the sections below. 👍 Found a bug? Please fill out the sections below. 👍
### Issue Summary ### Issue Summary
A summary of the issue. This needs to be a clear detailed-rich summary.
A summary of the issue. This needs to be a clear detailed-rich summary.
### Steps to Reproduce ### Steps to Reproduce
1. (for example) Went to ... 1. (for example) Went to ...
2. Clicked on... 2. Clicked on...
3. ... 3. ...
@ -20,6 +21,7 @@ A summary of the issue. This needs to be a clear detailed-rich summary.
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead? Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
### Technical details ### Technical details
* Browser version: You can use https://www.whatsmybrowser.org/ to find this out.
* Node.js version - Browser version: You can use https://www.whatsmybrowser.org/ to find this out.
* Anything else that you think could be an issue. - Node.js version
- Anything else that you think could be an issue.

View file

@ -1,36 +1,43 @@
--- ---
name: Feature request name: Feature request
about: Suggest a feature or idea about: Suggest a feature or idea
title: '' title: ""
labels: enhancement labels: enhancement
assignees: '' assignees: ""
--- ---
> Please check if your Feature Request has not been already raised in the [Discussions Tab](https://github.com/calendso/calendso/discussions), as we would like to reduce duplicates. If it has been already raised, simply upvote it 🔼. > Please check if your Feature Request has not been already raised in the [Discussions Tab](https://github.com/calendso/calendso/discussions), as we would like to reduce duplicates. If it has been already raised, simply upvote it 🔼.
### Is your proposal related to a problem? ### Is your proposal related to a problem?
<!-- <!--
Provide a clear and concise description of what the problem is. Provide a clear and concise description of what the problem is.
For example, "I'm always frustrated when..." For example, "I'm always frustrated when..."
--> -->
(Write your answer here.) (Write your answer here.)
### Describe the solution you'd like ### Describe the solution you'd like
<!-- <!--
Provide a clear and concise description of what you want to happen. Provide a clear and concise description of what you want to happen.
--> -->
(Describe your proposed solution here.) (Describe your proposed solution here.)
### Describe alternatives you've considered ### Describe alternatives you've considered
<!-- <!--
Let us know about other solutions you've tried or researched. Let us know about other solutions you've tried or researched.
--> -->
(Write your answer here.) (Write your answer here.)
### Additional context ### Additional context
<!-- <!--
Is there anything else you can add about the proposal? Is there anything else you can add about the proposal?
You might want to link to related issues here, if you haven't already. You might want to link to related issues here, if you haven't already.
--> -->
(Write your answer here.) (Write your answer here.)

View file

@ -7,20 +7,20 @@ info:
email: support@cal.com email: support@cal.com
license: license:
name: MIT License name: MIT License
url: 'https://opensource.org/licenses/MIT' url: "https://opensource.org/licenses/MIT"
version: 1.0.0 version: 1.0.0
termsOfService: 'https://cal.com/terms' termsOfService: "https://cal.com/terms"
server: server:
url: 'http://localhost:{port}' url: "http://localhost:{port}"
description: Local Development Server description: Local Development Server
variables: variables:
port: port:
default: '3000' default: "3000"
tags: tags:
- name: Authentication - name: Authentication
description: 'Auth routes, powered by Next-Auth.js' description: "Auth routes, powered by Next-Auth.js"
externalDocs: externalDocs:
url: 'http://next-auth.js.org/' url: "http://next-auth.js.org/"
- name: Availability - name: Availability
description: Checking and setting user availability description: Checking and setting user availability
- name: Booking - name: Booking
@ -38,15 +38,15 @@ paths:
summary: Displays the sign in page summary: Displays the sign in page
tags: tags:
- Authentication - Authentication
'/api/auth/signin/:provider': "/api/auth/signin/:provider":
post: post:
description: Starts an OAuth signin flow for the specified provider. The POST submission requires CSRF token from /api/auth/csrf. description: Starts an OAuth signin flow for the specified provider. The POST submission requires CSRF token from /api/auth/csrf.
summary: Starts an OAuth signin flow for the specified provider summary: Starts an OAuth signin flow for the specified provider
tags: tags:
- Authentication - Authentication
'/api/auth/callback/:provider': "/api/auth/callback/:provider":
get: get:
description: 'Handles returning requests from OAuth services during sign in. For OAuth 2.0 providers that support the state option, the value of the state parameter is checked against the one that was generated when the sign in flow was started - this uses a hash of the CSRF token which MUST match for both the POST and GET calls during sign in.' description: "Handles returning requests from OAuth services during sign in. For OAuth 2.0 providers that support the state option, the value of the state parameter is checked against the one that was generated when the sign in flow was started - this uses a hash of the CSRF token which MUST match for both the POST and GET calls during sign in."
summary: Handles returning requests from OAuth services summary: Handles returning requests from OAuth services
tags: tags:
- Authentication - Authentication
@ -103,26 +103,26 @@ paths:
summary: Reset a user's password summary: Reset a user's password
tags: tags:
- Authentication - Authentication
'/api/availability/{user}?dateFrom={dateFrom}&dateTo={dateTo}': "/api/availability/{user}?dateFrom={dateFrom}&dateTo={dateTo}":
get: get:
description: 'Gets the busy times for a particular user, by username.' description: "Gets the busy times for a particular user, by username."
summary: Gets the busy times for a user summary: Gets the busy times for a user
tags: tags:
- Availability - Availability
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
schema: schema:
type: array type: array
description: '' description: ""
minItems: 1 minItems: 1
uniqueItems: true uniqueItems: true
x-examples: x-examples:
example-1: example-1:
- start: 'Fri, 03 Sep 2021 17:00:00 GMT' - start: "Fri, 03 Sep 2021 17:00:00 GMT"
end: 'Fri, 03 Sep 2021 17:40:00 GMT' end: "Fri, 03 Sep 2021 17:40:00 GMT"
items: items:
type: object type: object
properties: properties:
@ -135,7 +135,7 @@ paths:
required: required:
- start - start
- end - end
'500': "500":
description: Internal Server Error description: Internal Server Error
parameters: parameters:
- schema: - schema:
@ -163,13 +163,13 @@ paths:
tags: tags:
- Availability - Availability
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
schema: schema:
type: array type: array
description: '' description: ""
minItems: 1 minItems: 1
uniqueItems: true uniqueItems: true
items: items:
@ -221,7 +221,7 @@ paths:
externalId: c_feunmui1m8el5o1oo885fu48k8@group.calendar.google.com externalId: c_feunmui1m8el5o1oo885fu48k8@group.calendar.google.com
integration: google_calendar integration: google_calendar
name: 1.0 Launch name: 1.0 Launch
'500': "500":
description: Internal Server Error description: Internal Server Error
post: post:
description: Adds a selected calendar for the user. description: Adds a selected calendar for the user.
@ -229,7 +229,7 @@ paths:
tags: tags:
- Availability - Availability
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
@ -238,7 +238,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'500': "500":
description: Internal Server Error description: Internal Server Error
requestBody: requestBody:
content: content:
@ -256,7 +256,7 @@ paths:
tags: tags:
- Availability - Availability
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
@ -265,7 +265,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'500': "500":
description: Internal Server Error description: Internal Server Error
requestBody: requestBody:
content: content:
@ -284,7 +284,7 @@ paths:
tags: tags:
- Availability - Availability
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
@ -305,7 +305,7 @@ paths:
type: string type: string
bufferMins: bufferMins:
type: string type: string
description: '' description: ""
/api/availability/eventtype: /api/availability/eventtype:
post: post:
description: Adds a new event type for the user. description: Adds a new event type for the user.
@ -339,7 +339,7 @@ paths:
type: array type: array
items: {} items: {}
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
@ -369,7 +369,7 @@ paths:
customInputs: customInputs:
type: array type: array
items: {} items: {}
'500': "500":
description: Internal Server Error description: Internal Server Error
patch: patch:
description: Updates an event type for the user. description: Updates an event type for the user.
@ -403,7 +403,7 @@ paths:
type: array type: array
items: {} items: {}
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
@ -433,7 +433,7 @@ paths:
customInputs: customInputs:
type: array type: array
items: {} items: {}
'500': "500":
description: Internal Server Error description: Internal Server Error
delete: delete:
description: Deletes an event type for the user. description: Deletes an event type for the user.
@ -441,16 +441,16 @@ paths:
tags: tags:
- Availability - Availability
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
schema: schema:
type: object type: object
properties: {} properties: {}
'500': "500":
description: Internal Server Error description: Internal Server Error
'/api/book/event': "/api/book/event":
post: post:
description: Creates a booking in the user's calendar. description: Creates a booking in the user's calendar.
summary: Creates a booking for a user summary: Creates a booking for a user
@ -492,7 +492,7 @@ paths:
paymentUid: paymentUid:
type: string type: string
responses: responses:
'204': "204":
description: No Content description: No Content
content: content:
application/json: application/json:
@ -501,7 +501,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'500': "500":
description: Internal Server Error description: Internal Server Error
content: content:
application/json: application/json:
@ -535,7 +535,7 @@ paths:
confirmed: confirmed:
type: string type: string
responses: responses:
'204': "204":
description: No Content description: No Content
content: content:
application/json: application/json:
@ -544,7 +544,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'500': "500":
description: Internal Server Error description: Internal Server Error
/api/integrations: /api/integrations:
get: get:
@ -553,12 +553,12 @@ paths:
tags: tags:
- Integrations - Integrations
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
schema: schema:
description: '' description: ""
type: object type: object
x-examples: x-examples:
example-1: example-1:
@ -569,7 +569,7 @@ paths:
id: 83 id: 83
type: google_calendar type: google_calendar
key: key:
scope: 'https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events' scope: "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events"
token_type: Bearer token_type: Bearer
expiry_date: 1630838974808 expiry_date: 1630838974808
access_token: ya29.a0ARrdaM89R686rUyBBluTuD69oQ6WIIjjMa2xjJ0qe_5u-9ShDL09KNN1mCYoks3NP54FUMzYKmqTzb8nzCJX9jlNKP7X7-gukO4--HUyfOUbFHlHbfQ2Ei05F8AQn_xS0E_awhDgyn2anvrvEw72U3_65Zi4v6Y access_token: ya29.a0ARrdaM89R686rUyBBluTuD69oQ6WIIjjMa2xjJ0qe_5u-9ShDL09KNN1mCYoks3NP54FUMzYKmqTzb8nzCJX9jlNKP7X7-gukO4--HUyfOUbFHlHbfQ2Ei05F8AQn_xS0E_awhDgyn2anvrvEw72U3_65Zi4v6Y
@ -667,7 +667,7 @@ paths:
- description - description
required: required:
- pageProps - pageProps
'500': "500":
description: Internal Server Error description: Internal Server Error
content: content:
application/json: application/json:
@ -690,7 +690,7 @@ paths:
id: id:
type: number type: number
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
@ -699,7 +699,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'401': "401":
description: Unauthorized description: Unauthorized
content: content:
application/json: application/json:
@ -708,7 +708,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'500': "500":
description: Internal Server Error description: Internal Server Error
content: content:
application/json: application/json:
@ -780,7 +780,7 @@ paths:
theme: theme:
type: string type: string
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
@ -789,7 +789,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'401': "401":
description: Unauthorized description: Unauthorized
content: content:
application/json: application/json:
@ -798,7 +798,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'500': "500":
description: Internal Server Error description: Internal Server Error
content: content:
application/json: application/json:
@ -819,7 +819,7 @@ paths:
schema: schema:
type: object type: object
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
@ -828,7 +828,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'401': "401":
description: Unauthorized description: Unauthorized
content: content:
application/json: application/json:
@ -837,7 +837,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'500': "500":
description: Internal Server Error description: Internal Server Error
content: content:
application/json: application/json:
@ -872,7 +872,7 @@ paths:
properties: properties:
teamId: teamId:
type: string type: string
'/api/{team}': "/api/{team}":
delete: delete:
description: Deletes a team description: Deletes a team
summary: Deletes a team summary: Deletes a team
@ -880,9 +880,9 @@ paths:
- Teams - Teams
parameters: [] parameters: []
responses: responses:
'204': "204":
description: No Content description: No Content
'401': "401":
description: Unauthorized description: Unauthorized
content: content:
application/json: application/json:
@ -891,7 +891,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'500': "500":
description: Internal Server Error description: Internal Server Error
content: content:
application/json: application/json:
@ -907,7 +907,7 @@ paths:
in: path in: path
required: true required: true
description: The team which you wish to modify description: The team which you wish to modify
'/api/{team}/invite': "/api/{team}/invite":
post: post:
description: Invites someone to a team. description: Invites someone to a team.
summary: Invites someone to a team summary: Invites someone to a team
@ -933,7 +933,7 @@ paths:
in: path in: path
required: true required: true
description: The team which you wish to send the invite for description: The team which you wish to send the invite for
'/api/{team}/membership': "/api/{team}/membership":
get: get:
description: Lists the members of a team. description: Lists the members of a team.
summary: Lists members of a team summary: Lists members of a team
@ -941,7 +941,7 @@ paths:
- Teams - Teams
parameters: [] parameters: []
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
@ -951,7 +951,7 @@ paths:
members: members:
type: array type: array
items: {} items: {}
'401': "401":
description: Unauthorized description: Unauthorized
content: content:
application/json: application/json:
@ -960,7 +960,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'500': "500":
description: Internal Server Error description: Internal Server Error
content: content:
application/json: application/json:
@ -983,14 +983,14 @@ paths:
userId: userId:
type: number type: number
responses: responses:
'200': "200":
description: OK description: OK
content: content:
application/json: application/json:
schema: schema:
type: object type: object
properties: {} properties: {}
'401': "401":
description: Unauthorized description: Unauthorized
content: content:
application/json: application/json:
@ -999,7 +999,7 @@ paths:
properties: properties:
message: message:
type: string type: string
'500': "500":
description: Internal Server Error description: Internal Server Error
content: content:
application/json: application/json:
@ -1016,7 +1016,7 @@ paths:
required: true required: true
description: The team which you wish to list members of description: The team which you wish to list members of
servers: servers:
- url: 'https://app.cal.com' - url: "https://app.cal.com"
description: Production description: Production
components: components:
securitySchemes: {} securitySchemes: {}

View file

@ -1,9 +1,11 @@
import short from "short-uuid"; import short from "short-uuid";
import { v5 as uuidv5 } from "uuid"; import { v5 as uuidv5 } from "uuid";
import { getIntegrationName } from "@lib/integrations";
import { VideoCallData } from "@lib/videoClient";
import { CalendarEvent } from "./calendarClient"; import { CalendarEvent } from "./calendarClient";
import { stripHtml } from "./emails/helpers"; import { stripHtml } from "./emails/helpers";
import { VideoCallData } from "@lib/videoClient";
import { getIntegrationName } from "@lib/integrations";
const translator = short(); const translator = short();

View file

@ -1,13 +1,15 @@
import { Prisma, Credential } from "@prisma/client"; import { Prisma, Credential } from "@prisma/client";
import { EventResult } from "@lib/events/EventManager"; import { EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger"; import logger from "@lib/logger";
import { VideoCallData } from "@lib/videoClient";
import CalEventParser from "./CalEventParser"; import CalEventParser from "./CalEventParser";
import EventOrganizerMail from "./emails/EventOrganizerMail"; import EventOrganizerMail from "./emails/EventOrganizerMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import { AppleCalendar } from "./integrations/Apple/AppleCalendarAdapter"; import { AppleCalendar } from "./integrations/Apple/AppleCalendarAdapter";
import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter"; import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter";
import prisma from "./prisma"; import prisma from "./prisma";
import { VideoCallData } from "@lib/videoClient";
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] }); const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });

View file

@ -0,0 +1,71 @@
import parser from "accept-language-parser";
import { IncomingMessage } from "http";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
import { i18n } from "../../../next-i18next.config";
export const extractLocaleInfo = async (req: IncomingMessage) => {
const session = await getSession({ req: req });
const preferredLocale = parser.pick(i18n.locales, req.headers["accept-language"]);
if (session?.user?.id) {
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
locale: true,
},
});
if (user?.locale) {
return user.locale;
}
if (preferredLocale) {
await prisma.user.update({
where: {
id: session.user.id,
},
data: {
locale: preferredLocale,
},
});
} else {
await prisma.user.update({
where: {
id: session.user.id,
},
data: {
locale: i18n.defaultLocale,
},
});
}
}
if (preferredLocale) {
return preferredLocale;
}
return i18n.defaultLocale;
};
interface localeType {
[locale: string]: string;
}
export const localeLabels: localeType = {
en: "English",
ro: "Romanian",
};
export type OptionType = {
value: string;
label: string;
};
export const localeOptions: OptionType[] = i18n.locales.map((locale) => {
return { value: locale, label: localeLabels[locale] };
});

20
lib/hooks/useLocale.ts Normal file
View file

@ -0,0 +1,20 @@
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
type LocaleProps = {
localeProp: string;
};
export const useLocale = (props: LocaleProps) => {
const { i18n, t } = useTranslation("common");
useEffect(() => {
(async () => await i18n.changeLanguage(props.localeProp))();
}, [i18n, props.localeProp]);
return {
i18n,
locale: props.localeProp,
t,
};
};

10
next-i18next.config.js Normal file
View file

@ -0,0 +1,10 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require("path");
module.exports = {
i18n: {
defaultLocale: "en",
locales: ["en", "ro"],
},
localePath: path.resolve("./public/static/locales"),
};

View file

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const withTM = require("next-transpile-modules")(["react-timezone-select"]); const withTM = require("next-transpile-modules")(["react-timezone-select"]);
const { i18n } = require("./next-i18next.config");
// So we can test deploy previews preview // So we can test deploy previews preview
if (process.env.VERCEL_URL && !process.env.BASE_URL) { if (process.env.VERCEL_URL && !process.env.BASE_URL) {
@ -41,10 +42,7 @@ if (process.env.GOOGLE_API_CREDENTIALS && !validJson(process.env.GOOGLE_API_CRED
} }
module.exports = withTM({ module.exports = withTM({
i18n: { i18n,
locales: ["en"],
defaultLocale: "en",
},
eslint: { eslint: {
// This allows production builds to successfully complete even if the project has ESLint errors. // This allows production builds to successfully complete even if the project has ESLint errors.
ignoreDuringBuilds: true, ignoreDuringBuilds: true,

View file

@ -37,6 +37,7 @@
"@stripe/stripe-js": "^1.16.0", "@stripe/stripe-js": "^1.16.0",
"@tailwindcss/forms": "^0.3.3", "@tailwindcss/forms": "^0.3.3",
"@types/stripe": "^8.0.417", "@types/stripe": "^8.0.417",
"accept-language-parser": "^1.5.0",
"async": "^3.2.1", "async": "^3.2.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"classnames": "^2.3.1", "classnames": "^2.3.1",
@ -52,6 +53,7 @@
"micro": "^9.3.4", "micro": "^9.3.4",
"next": "^11.1.1", "next": "^11.1.1",
"next-auth": "^3.28.0", "next-auth": "^3.28.0",
"next-i18next": "^8.8.0",
"next-seo": "^4.26.0", "next-seo": "^4.26.0",
"next-transpile-modules": "^8.0.0", "next-transpile-modules": "^8.0.0",
"nodemailer": "^6.6.3", "nodemailer": "^6.6.3",

View file

@ -1,7 +1,9 @@
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { asStringOrNull } from "@lib/asStringOrNull"; import { asStringOrNull } from "@lib/asStringOrNull";
import { extractLocaleInfo } from "@lib/core/i18n/i18n.utils";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -12,6 +14,7 @@ export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
} }
export const getServerSideProps = async (context: GetServerSidePropsContext) => { export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const locale = await extractLocaleInfo(context.req);
// get query params and typecast them to string // get query params and typecast them to string
// (would be even better to assert them instead of typecasting) // (would be even better to assert them instead of typecasting)
const userParam = asStringOrNull(context.query.user); const userParam = asStringOrNull(context.query.user);
@ -177,6 +180,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return { return {
props: { props: {
localeProp: locale,
profile: { profile: {
name: user.name, name: user.name,
image: user.avatar, image: user.avatar,
@ -186,6 +190,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
date: dateParam, date: dateParam,
eventType: eventTypeObject, eventType: eventTypeObject,
workingHours, workingHours,
...(await serverSideTranslations(locale, ["common"])),
}, },
}; };
}; };

View file

@ -2,8 +2,10 @@ import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { asStringOrThrow } from "@lib/asStringOrNull"; import { asStringOrThrow } from "@lib/asStringOrNull";
import { extractLocaleInfo } from "@lib/core/i18n/i18n.utils";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -19,6 +21,8 @@ export default function Book(props: BookPageProps) {
} }
export async function getServerSideProps(context: GetServerSidePropsContext) { export async function getServerSideProps(context: GetServerSidePropsContext) {
const locale = await extractLocaleInfo(context.req);
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
username: asStringOrThrow(context.query.user), username: asStringOrThrow(context.query.user),
@ -99,6 +103,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return { return {
props: { props: {
localeProp: locale,
profile: { profile: {
slug: user.username, slug: user.username,
name: user.name, name: user.name,
@ -107,6 +112,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}, },
eventType: eventTypeObject, eventType: eventTypeObject,
booking, booking,
...(await serverSideTranslations(locale, ["common"])),
}, },
}; };
} }

View file

@ -1,3 +1,4 @@
import { appWithTranslation } from "next-i18next";
import { DefaultSeo } from "next-seo"; import { DefaultSeo } from "next-seo";
import type { AppProps as NextAppProps } from "next/app"; import type { AppProps as NextAppProps } from "next/app";
@ -21,4 +22,4 @@ function MyApp({ Component, pageProps, err }: AppProps) {
); );
} }
export default MyApp; export default appWithTranslation(MyApp);

View file

@ -27,6 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
"hideBranding", "hideBranding",
"theme", "theme",
"completedOnboarding", "completedOnboarding",
"locale",
]), ]),
bio: req.body.description, bio: req.body.description,
}, },

View file

@ -19,6 +19,7 @@ import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { DateRangePicker, OrientationShape, toMomentObject } from "react-dates"; import { DateRangePicker, OrientationShape, toMomentObject } from "react-dates";
@ -33,6 +34,7 @@ import { asStringOrThrow } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth"; import { getSession } from "@lib/auth";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error"; import { HttpError } from "@lib/core/http/error";
import { extractLocaleInfo } from "@lib/core/i18n/i18n.utils";
import getIntegrations, { hasIntegration } from "@lib/integrations/getIntegrations"; import getIntegrations, { hasIntegration } from "@lib/integrations/getIntegrations";
import { LocationType } from "@lib/location"; import { LocationType } from "@lib/location";
import deleteEventType from "@lib/mutations/event-types/delete-event-type"; import deleteEventType from "@lib/mutations/event-types/delete-event-type";
@ -1185,6 +1187,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
export const getServerSideProps = async (context: GetServerSidePropsContext) => { export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req, query } = context; const { req, query } = context;
const session = await getSession({ req }); const session = await getSession({ req });
const locale = await extractLocaleInfo(context.req);
const typeParam = parseInt(asStringOrThrow(query.type)); const typeParam = parseInt(asStringOrThrow(query.type));
if (!session?.user?.id) { if (!session?.user?.id) {
@ -1345,6 +1349,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return { return {
props: { props: {
localeProp: locale,
eventType: eventTypeObject, eventType: eventTypeObject,
locationOptions, locationOptions,
availability, availability,
@ -1352,6 +1357,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
teamMembers, teamMembers,
hasPaymentIntegration, hasPaymentIntegration,
currency, currency,
...(await serverSideTranslations(locale, ["common"])),
}, },
}; };
}; };

View file

@ -11,6 +11,7 @@ import {
import { SchedulingType } from "@prisma/client"; import { SchedulingType } from "@prisma/client";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -21,7 +22,9 @@ import { asStringOrNull } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth"; import { getSession } from "@lib/auth";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error"; import { HttpError } from "@lib/core/http/error";
import { extractLocaleInfo } from "@lib/core/i18n/i18n.utils";
import { ONBOARDING_INTRODUCED_AT } from "@lib/getting-started"; import { ONBOARDING_INTRODUCED_AT } from "@lib/getting-started";
import { useLocale } from "@lib/hooks/useLocale";
import { useToggleQuery } from "@lib/hooks/useToggleQuery"; import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import createEventType from "@lib/mutations/event-types/create-event-type"; import createEventType from "@lib/mutations/event-types/create-event-type";
import showToast from "@lib/notification"; import showToast from "@lib/notification";
@ -53,6 +56,11 @@ type Profile = PageProps["profiles"][number];
type MembershipCount = EventType["metadata"]["membershipCount"]; type MembershipCount = EventType["metadata"]["membershipCount"];
const EventTypesPage = (props: PageProps) => { const EventTypesPage = (props: PageProps) => {
const { locale } = useLocale({
localeProp: props.localeProp,
namespaces: "event-types-page",
});
const CreateFirstEventTypeView = () => ( const CreateFirstEventTypeView = () => (
<div className="md:py-20"> <div className="md:py-20">
<UserCalendarIllustration /> <UserCalendarIllustration />
@ -62,7 +70,11 @@ const EventTypesPage = (props: PageProps) => {
Event types enable you to share links that show available times on your calendar and allow people to Event types enable you to share links that show available times on your calendar and allow people to
make bookings with you. make bookings with you.
</p> </p>
<CreateNewEventDialog canAddEvents={props.canAddEvents} profiles={props.profiles} /> <CreateNewEventDialog
localeProp={locale}
canAddEvents={props.canAddEvents}
profiles={props.profiles}
/>
</div> </div>
</div> </div>
); );
@ -316,10 +328,19 @@ const EventTypesPage = (props: PageProps) => {
); );
}; };
const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[]; canAddEvents: boolean }) => { const CreateNewEventDialog = ({
profiles,
canAddEvents,
localeProp,
}: {
profiles: Profile[];
canAddEvents: boolean;
localeProp: string;
}) => {
const router = useRouter(); const router = useRouter();
const teamId: number | null = Number(router.query.teamId) || null; const teamId: number | null = Number(router.query.teamId) || null;
const modalOpen = useToggleQuery("new"); const modalOpen = useToggleQuery("new");
const { t } = useLocale({ localeProp });
const createMutation = useMutation(createEventType, { const createMutation = useMutation(createEventType, {
onSuccess: async ({ eventType }) => { onSuccess: async ({ eventType }) => {
@ -351,13 +372,13 @@ const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[];
disabled: true, disabled: true,
})} })}
StartIcon={PlusIcon}> StartIcon={PlusIcon}>
New event type {t("new-event-type-btn")}
</Button> </Button>
)} )}
{profiles.filter((profile) => profile.teamId).length > 0 && ( {profiles.filter((profile) => profile.teamId).length > 0 && (
<Dropdown> <Dropdown>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button EndIcon={ChevronDownIcon}>New event type</Button> <Button EndIcon={ChevronDownIcon}>{t("new-event-type-btn")}</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Create an event type under your name or a team.</DropdownMenuLabel> <DropdownMenuLabel>Create an event type under your name or a team.</DropdownMenuLabel>
@ -534,6 +555,8 @@ const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[];
export async function getServerSideProps(context) { export async function getServerSideProps(context) {
const session = await getSession(context); const session = await getSession(context);
const locale = await extractLocaleInfo(context.req);
if (!session?.user?.id) { if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } }; return { redirect: { permanent: false, destination: "/auth/login" } };
} }
@ -700,6 +723,7 @@ export async function getServerSideProps(context) {
return { return {
props: { props: {
localeProp: locale,
canAddEvents, canAddEvents,
user: userObj, user: userObj,
// don't display event teams without event types, // don't display event teams without event types,
@ -710,6 +734,7 @@ export async function getServerSideProps(context) {
...group.profile, ...group.profile,
...group.metadata, ...group.metadata,
})), })),
...(await serverSideTranslations(locale, ["common"])),
}, },
}; };
} }

View file

@ -1,22 +1,16 @@
import { useRouter } from "next/router"; import { getSession } from "@lib/auth";
import Loader from "@components/Loader";
function RedirectPage() { function RedirectPage() {
const router = useRouter(); return;
if (typeof window !== "undefined") {
router.push("/event-types");
return;
}
return <Loader />;
} }
RedirectPage.getInitialProps = (ctx) => { export async function getServerSideProps(context) {
if (ctx.res) { const session = await getSession(context);
ctx.res.writeHead(302, { Location: "/event-types" }); if (!session?.user?.id) {
ctx.res.end(); return { redirect: { permanent: false, destination: "/auth/login" } };
} }
return {};
}; return { redirect: { permanent: false, destination: "/event-types" } };
}
export default RedirectPage; export default RedirectPage;

View file

@ -1,10 +1,13 @@
import crypto from "crypto"; import crypto from "crypto";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { RefObject, useEffect, useRef, useState } from "react"; import { RefObject, useEffect, useRef, useState } from "react";
import Select from "react-select"; import Select from "react-select";
import TimezoneSelect from "react-timezone-select"; import TimezoneSelect from "react-timezone-select";
import { getSession } from "@lib/auth"; import { getSession } from "@lib/auth";
import { extractLocaleInfo, localeLabels, localeOptions, OptionType } from "@lib/core/i18n/i18n.utils";
import { useLocale } from "@lib/hooks/useLocale";
import { isBrandingHidden } from "@lib/isBrandingHidden"; import { isBrandingHidden } from "@lib/isBrandingHidden";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -82,6 +85,8 @@ function HideBrandingInput(props: {
} }
export default function Settings(props: Props) { export default function Settings(props: Props) {
const { locale } = useLocale({ localeProp: props.localeProp });
const [successModalOpen, setSuccessModalOpen] = useState(false); const [successModalOpen, setSuccessModalOpen] = useState(false);
const usernameRef = useRef<HTMLInputElement>(null); const usernameRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null); const nameRef = useRef<HTMLInputElement>(null);
@ -91,8 +96,11 @@ export default function Settings(props: Props) {
const [selectedTheme, setSelectedTheme] = useState({ value: props.user.theme }); const [selectedTheme, setSelectedTheme] = useState({ value: props.user.theme });
const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone }); const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone });
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({ value: props.user.weekStart }); const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({ value: props.user.weekStart });
const [selectedLanguage, setSelectedLanguage] = useState<OptionType>({
value: locale,
label: props.localeLabels[locale],
});
const [imageSrc, setImageSrc] = useState<string>(props.user.avatar); const [imageSrc, setImageSrc] = useState<string>(props.user.avatar);
const [hasErrors, setHasErrors] = useState(false); const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
@ -101,6 +109,7 @@ export default function Settings(props: Props) {
props.user.theme ? themeOptions.find((theme) => theme.value === props.user.theme) : null props.user.theme ? themeOptions.find((theme) => theme.value === props.user.theme) : null
); );
setSelectedWeekStartDay({ value: props.user.weekStart, label: props.user.weekStart }); setSelectedWeekStartDay({ value: props.user.weekStart, label: props.user.weekStart });
setSelectedLanguage({ value: locale, label: props.localeLabels[locale] });
}, []); }, []);
const closeSuccessModal = () => { const closeSuccessModal = () => {
@ -137,6 +146,7 @@ export default function Settings(props: Props) {
const enteredTimeZone = selectedTimeZone.value; const enteredTimeZone = selectedTimeZone.value;
const enteredWeekStartDay = selectedWeekStartDay.value; const enteredWeekStartDay = selectedWeekStartDay.value;
const enteredHideBranding = hideBrandingRef.current.checked; const enteredHideBranding = hideBrandingRef.current.checked;
const enteredLanguage = selectedLanguage.value;
// TODO: Add validation // TODO: Add validation
@ -151,6 +161,7 @@ export default function Settings(props: Props) {
weekStart: enteredWeekStartDay, weekStart: enteredWeekStartDay,
hideBranding: enteredHideBranding, hideBranding: enteredHideBranding,
theme: selectedTheme ? selectedTheme.value : null, theme: selectedTheme ? selectedTheme.value : null,
locale: enteredLanguage,
}), }),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -239,6 +250,21 @@ export default function Settings(props: Props) {
</div> </div>
<hr className="mt-6" /> <hr className="mt-6" />
</div> </div>
<div>
<label htmlFor="language" className="block text-sm font-medium text-gray-700">
Language
</label>
<div className="mt-1">
<Select
id="languageSelect"
value={selectedLanguage || locale}
onChange={setSelectedLanguage}
classNamePrefix="react-select"
className="react-select-container border border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm"
options={props.localeOptions}
/>
</div>
</div>
<div> <div>
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700"> <label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
Timezone Timezone
@ -376,6 +402,8 @@ export default function Settings(props: Props) {
export const getServerSideProps = async (context: GetServerSidePropsContext) => { export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getSession(context); const session = await getSession(context);
const locale = await extractLocaleInfo(context.req);
if (!session?.user?.id) { if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } }; return { redirect: { permanent: false, destination: "/auth/login" } };
} }
@ -402,12 +430,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (!user) { if (!user) {
throw new Error("User seems logged in but cannot be found in the db"); throw new Error("User seems logged in but cannot be found in the db");
} }
return { return {
props: { props: {
localeProp: locale,
localeOptions,
localeLabels,
user: { user: {
...user, ...user,
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
}, },
...(await serverSideTranslations(locale, ["common"])),
}, },
}; };
}; };

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "locale" TEXT;

View file

@ -86,6 +86,7 @@ model User {
availability Availability[] availability Availability[]
selectedCalendars SelectedCalendar[] selectedCalendars SelectedCalendar[]
completedOnboarding Boolean? @default(false) completedOnboarding Boolean? @default(false)
locale String?
twoFactorSecret String? twoFactorSecret String?
twoFactorEnabled Boolean @default(false) twoFactorEnabled Boolean @default(false)

5
public/robots.txt Normal file
View file

@ -0,0 +1,5 @@
User-agent: *
Disallow: /
Disallow: /sandbox
Disallow: /api
Disallow: /static/locales

View file

@ -0,0 +1,3 @@
{
"new-event-type-btn": "New event type"
}

View file

@ -0,0 +1,3 @@
{
"new-event-type-btn": "Nou tip de eveniment"
}

View file

@ -1,22 +1,12 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@components/*": [ "@components/*": ["components/*"],
"components/*" "@lib/*": ["lib/*"],
], "@ee/*": ["ee/*"]
"@lib/*": [
"lib/*"
],
"@ee/*": [
"ee/*"
]
}, },
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
@ -30,13 +20,6 @@
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve" "jsx": "preserve"
}, },
"include": [ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "lib/*.js"],
"next-env.d.ts", "exclude": ["node_modules"]
"**/*.ts",
"**/*.tsx",
"lib/*.js"
],
"exclude": [
"node_modules"
]
} }

2056
yarn.lock

File diff suppressed because it is too large Load diff