Feat/i18n crowdin (#752)

* feat: add crowdin and supported languages

* fix: main branch name

* feat: test crowdin integration

* feat: add crowdin config skeleton

* feat: update crowdin.yml

* fix: remove ro translation

* test: en translation

* test: en translation

* New Crowdin translations by Github Action (#735)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* test: en translation

* fix: separate upload/download workflows

* wip

* New Crowdin translations by Github Action (#738)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* wip

* wip

* wip

* wip

* wip

* typo

* wip

* wip

* update crowdin config

* update

* chore: support i18n de,es,fr,it,pt,ru,ro,en

* chore: extract i18n strings

* chore: extract booking components strings for i18n

* wip

* extract more strings

* wip

* fallback to getServerSideProps for now

* New Crowdin translations by Github Action (#874)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* fix: minor fixes on the datepicker

* fix: add dutch lang

* fix: linting issues

* fix: string

* fix: update GHA

* cleanup trpc

* fix linting

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
This commit is contained in:
Mihai C 2021-10-08 14:43:48 +03:00 committed by GitHub
parent 33a683d4b0
commit 2c9b301b77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 2805 additions and 1876 deletions

25
.github/workflows/crowdin.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Crowdin Action
on:
push:
branches:
- main
jobs:
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: crowdin action
uses: crowdin/github-action@1.4.0
with:
upload_translations: true
download_translations: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@ -5,11 +5,13 @@ import Link from "next/link";
import { useRouter } from "next/router";
import React, { FC } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { useSlots } from "@lib/hooks/useSlots";
import Loader from "@components/Loader";
type AvailableTimesProps = {
localeProp: string;
workingHours: {
days: number[];
startTime: number;
@ -27,6 +29,7 @@ type AvailableTimesProps = {
};
const AvailableTimes: FC<AvailableTimesProps> = ({
localeProp,
date,
eventLength,
eventTypeId,
@ -36,6 +39,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
users,
schedulingType,
}) => {
const { t } = useLocale({ localeProp: localeProp });
const router = useRouter();
const { rescheduleUid } = router.query;
@ -53,8 +57,11 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:-mb-5">
<div className="text-gray-600 font-light text-lg mb-4 text-left">
<span className="w-1/2 dark:text-white text-gray-600">
<strong>{date.format("dddd")}</strong>
<span className="text-gray-500">{date.format(", DD MMMM")}</span>
<strong>{t(date.format("dddd").toLowerCase())}</strong>
<span className="text-gray-500">
{date.format(", DD ")}
{t(date.format("MMMM").toLowerCase())}
</span>
</span>
</div>
<div className="md:max-h-[364px] overflow-y-auto">
@ -90,7 +97,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
})}
{!loading && !error && !slots.length && (
<div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4">
<h1 className="my-6 text-xl text-black dark:text-white">All booked today.</h1>
<h1 className="my-6 text-xl text-black dark:text-white">{t("all_booked_today")}</h1>
</div>
)}
@ -103,7 +110,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">Could not load the available time slots.</p>
<p className="text-sm text-yellow-700">{t("slots_load_fail")}</p>
</div>
</div>
</div>

View File

@ -6,6 +6,7 @@ import utc from "dayjs/plugin/utc";
import { useEffect, useState } from "react";
import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import getSlots from "@lib/slots";
dayjs.extend(dayjsBusinessDays);
@ -13,6 +14,7 @@ dayjs.extend(utc);
dayjs.extend(timezone);
const DatePicker = ({
localeProp,
weekStart,
onDatePicked,
workingHours,
@ -26,6 +28,7 @@ const DatePicker = ({
periodCountCalendarDays,
minimumBookingNotice,
}) => {
const { t } = useLocale({ localeProp: localeProp });
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
const [selectedMonth, setSelectedMonth] = useState<number | null>(
@ -135,8 +138,10 @@ const DatePicker = ({
}>
<div className="flex text-gray-600 font-light text-xl mb-4">
<span className="w-1/2 text-gray-600 dark:text-white">
<strong className="text-gray-900 dark:text-white">{inviteeDate().format("MMMM")}</strong>
<span className="text-gray-500"> {inviteeDate().format("YYYY")}</span>
<strong className="text-gray-900 dark:text-white">
{t(inviteeDate().format("MMMM").toLowerCase())}
</strong>{" "}
<span className="text-gray-500">{inviteeDate().format("YYYY")}</span>
</span>
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
<button
@ -153,11 +158,11 @@ const DatePicker = ({
</div>
</div>
<div className="grid grid-cols-7 gap-4 text-center border-b border-t dark:border-gray-800 sm:border-0">
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
{["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
.map((weekDay) => (
<div key={weekDay} className="uppercase text-gray-500 text-xs tracking-widest my-4">
{weekDay}
{t(weekDay.toLowerCase()).substring(0, 3)}
</div>
))}
</div>

View File

@ -4,10 +4,12 @@ import { FC, useEffect, useState } from "react";
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import { is24h, timeZone } from "../../lib/clock";
type Props = {
localeProp: string;
onSelectTimeZone: (selectedTimeZone: string) => void;
onToggle24hClock: (is24hClock: boolean) => void;
};
@ -15,6 +17,7 @@ type Props = {
const TimeOptions: FC<Props> = (props) => {
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const [is24hClock, setIs24hClock] = useState(false);
const { t } = useLocale({ localeProp: props.localeProp });
useEffect(() => {
setIs24hClock(is24h());
@ -35,11 +38,11 @@ const TimeOptions: FC<Props> = (props) => {
return selectedTimeZone !== "" ? (
<div className="absolute z-10 w-full max-w-80 rounded-sm border border-gray-200 dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
<div className="flex mb-4">
<div className="w-1/2 dark:text-white text-gray-600 font-medium">Time Options</div>
<div className="w-1/2 dark:text-white text-gray-600 font-medium">{t("time_options")}</div>
<div className="w-1/2">
<Switch.Group as="div" className="flex items-center justify-end">
<Switch.Label as="span" className="mr-3">
<span className="text-sm dark:text-white text-gray-500">am/pm</span>
<span className="text-sm dark:text-white text-gray-500">{t("am_pm")}</span>
</Switch.Label>
<Switch
checked={is24hClock}
@ -48,7 +51,7 @@ const TimeOptions: FC<Props> = (props) => {
is24hClock ? "bg-black" : "dark:bg-gray-600 bg-gray-200",
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"
)}>
<span className="sr-only">Use setting</span>
<span className="sr-only">{t("use_setting")}</span>
<span
aria-hidden="true"
className={classNames(
@ -58,7 +61,7 @@ const TimeOptions: FC<Props> = (props) => {
/>
</Switch>
<Switch.Label as="span" className="ml-3">
<span className="text-sm dark:text-white text-gray-500">24h</span>
<span className="text-sm dark:text-white text-gray-500">{t("24_h")}</span>
</Switch.Label>
</Switch.Group>
</div>

View File

@ -10,6 +10,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
@ -29,10 +30,11 @@ dayjs.extend(customParseFormat);
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
const AvailabilityPage = ({ profile, eventType, workingHours, localeProp }: Props) => {
const router = useRouter();
const { rescheduleUid } = router.query;
const { isReady } = useTheme(profile.theme);
const { t, locale } = useLocale({ localeProp });
const selectedDate = useMemo(() => {
const dateString = asStringOrNull(router.query.date);
@ -88,8 +90,8 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
return (
<>
<HeadSeo
title={`${rescheduleUid ? "Reschedule" : ""} ${eventType.title} | ${profile.name}`}
description={`${rescheduleUid ? "Reschedule" : ""} ${eventType.title}`}
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
name={profile.name}
avatar={profile.image}
/>
@ -122,7 +124,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
{eventType.title}
<div>
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{eventType.length} minutes
{eventType.length} {t("minutes")}
</div>
{eventType.price > 0 && (
<div>
@ -166,7 +168,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
</h1>
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{eventType.length} minutes
{eventType.length} {t("minutes")}
</p>
{eventType.price > 0 && (
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
@ -186,6 +188,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
<p className="mt-3 mb-8 text-gray-600 dark:text-gray-200">{eventType.description}</p>
</div>
<DatePicker
localeProp={locale}
date={selectedDate}
periodType={eventType?.periodType}
periodStartDate={eventType?.periodStartDate}
@ -205,6 +208,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
{selectedDate && (
<AvailableTimes
localeProp={locale}
workingHours={workingHours}
timeFormat={timeFormat}
minimumBookingNotice={eventType.minimumBookingNotice}
@ -237,7 +241,11 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
)}
</Collapsible.Trigger>
<Collapsible.Content>
<TimeOptions onSelectTimeZone={handleSelectTimeZone} onToggle24hClock={handleToggle24hClock} />
<TimeOptions
localeProp={locale}
onSelectTimeZone={handleSelectTimeZone}
onToggle24hClock={handleToggle24hClock}
/>
</Collapsible.Content>
</Collapsible.Root>
);

View File

@ -20,6 +20,7 @@ import { createPaymentLink } from "@ee/lib/stripe/client";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking";
@ -36,6 +37,7 @@ import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
type BookingPageProps = BookPageProps | TeamBookingPageProps;
const BookingPage = (props: BookingPageProps) => {
const { t } = useLocale({ localeProp: props.localeProp });
const router = useRouter();
const { rescheduleUid } = router.query;
const { isReady } = useTheme(props.profile.theme);
@ -67,8 +69,8 @@ const BookingPage = (props: BookingPageProps) => {
// TODO: Move to translations
const locationLabels = {
[LocationType.InPerson]: "Link or In-person meeting",
[LocationType.Phone]: "Phone call",
[LocationType.InPerson]: t("in_person_meeting"),
[LocationType.Phone]: t("phone_call"),
[LocationType.GoogleMeet]: "Google Meet",
[LocationType.Zoom]: "Zoom Video",
[LocationType.Daily]: "Daily.co Video",
@ -85,7 +87,7 @@ const BookingPage = (props: BookingPageProps) => {
const data = event.target["custom_" + input.id];
if (data) {
if (input.type === EventTypeCustomInputType.BOOL) {
return input.label + "\n" + (data.checked ? "Yes" : "No");
return input.label + "\n" + (data.checked ? t("yes") : t("no"));
} else {
return input.label + "\n" + data.value;
}
@ -94,7 +96,7 @@ const BookingPage = (props: BookingPageProps) => {
.join("\n\n");
}
if (!!notes && !!event.target.notes.value) {
notes += "\n\nAdditional notes:\n" + event.target.notes.value;
notes += `\n\n${t("additional_notes")}:\n` + event.target.notes.value;
} else {
notes += event.target.notes.value;
}
@ -185,8 +187,16 @@ const BookingPage = (props: BookingPageProps) => {
<div>
<Head>
<title>
{rescheduleUid ? "Reschedule" : "Confirm"} your {props.eventType.title} with {props.profile.name} |
Cal.com
{rescheduleUid
? t("booking_reschedule_confirmation", {
eventTypeTitle: props.eventType.title,
profileName: props.profile.name,
})
: t("booking_confirmation", {
eventTypeTitle: props.eventType.title,
profileName: props.profile.name,
})}{" "}
| Cal.com
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
@ -215,7 +225,7 @@ const BookingPage = (props: BookingPageProps) => {
</h1>
<p className="mb-2 text-gray-500">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
{props.eventType.length} {t("minutes")}
</p>
{props.eventType.price > 0 && (
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
@ -244,8 +254,8 @@ const BookingPage = (props: BookingPageProps) => {
<div className="sm:w-1/2 sm:pl-8 sm:pr-4">
<form onSubmit={bookingHandler}>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
Your name
<label htmlFor="name" className="block text-sm font-medium dark:text-white text-gray-700">
{t("your_name")}
</label>
<div className="mt-1">
<input
@ -262,8 +272,8 @@ const BookingPage = (props: BookingPageProps) => {
<div className="mb-4">
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-white">
Email address
className="block text-sm font-medium dark:text-white text-gray-700">
{t("email_address")}
</label>
<div className="mt-1">
<input
@ -281,7 +291,7 @@ const BookingPage = (props: BookingPageProps) => {
{locations.length > 1 && (
<div className="mb-4">
<span className="block text-sm font-medium text-gray-700 dark:text-white">
Location
{t("location")}
</span>
{locations.map((location) => (
<label key={location.type} className="block">
@ -306,12 +316,12 @@ const BookingPage = (props: BookingPageProps) => {
<label
htmlFor="phone"
className="block text-sm font-medium text-gray-700 dark:text-white">
Phone Number
{t("phone_number")}
</label>
<div className="mt-1">
<PhoneInput
name="phone"
placeholder="Enter phone number"
placeholder={t("enter_phone_number")}
id="phone"
required
className="block w-full border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black sm:text-sm"
@ -390,7 +400,7 @@ const BookingPage = (props: BookingPageProps) => {
onClick={toggleGuestEmailInput}
htmlFor="guests"
className="block mb-1 text-sm font-medium text-blue-500 dark:text-white hover:cursor-pointer">
+ Additional Guests
{t("additional_guests")}
</label>
)}
{guestToggle && (
@ -430,24 +440,24 @@ const BookingPage = (props: BookingPageProps) => {
<label
htmlFor="notes"
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
Additional notes
{t("additional_notes")}
</label>
<textarea
name="notes"
id="notes"
rows={3}
className="block w-full border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black sm:text-sm"
placeholder="Please share anything that will help prepare for our meeting."
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
placeholder={t("share_additional_notes")}
defaultValue={props.booking ? props.booking.description : ""}
/>
</div>
<div className="flex items-start space-x-2">
{/* TODO: add styling props to <Button variant="" color="" /> and get rid of btn-primary */}
<Button type="submit" loading={loading}>
{rescheduleUid ? "Reschedule" : "Confirm"}
{rescheduleUid ? t("reschedule") : t("confirm")}
</Button>
<Button color="secondary" type="button" onClick={() => router.back()}>
Cancel
{t("cancel")}
</Button>
</div>
</form>
@ -459,7 +469,7 @@ const BookingPage = (props: BookingPageProps) => {
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
Could not {rescheduleUid ? "reschedule" : "book"} the meeting.
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}
</p>
</div>
</div>

View File

@ -3,10 +3,13 @@ import { CheckIcon } from "@heroicons/react/solid";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import React, { PropsWithChildren } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { DialogClose, DialogContent } from "@components/Dialog";
import { Button } from "@components/ui/Button";
export type ConfirmationDialogContentProps = {
localeProp: string;
confirmBtnText?: string;
cancelBtnText?: string;
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
@ -15,7 +18,15 @@ export type ConfirmationDialogContentProps = {
};
export default function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationDialogContentProps>) {
const { title, variety, confirmBtnText = "Confirm", cancelBtnText = "Cancel", onConfirm, children } = props;
const { t } = useLocale({ localeProp: props.localeProp });
const {
title,
variety,
confirmBtnText = t("confirm"),
cancelBtnText = t("cancel"),
onConfirm,
children,
} = props;
return (
<DialogContent>

View File

@ -5,6 +5,7 @@ import React from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
select: {
@ -20,11 +21,14 @@ const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
export type EventTypeDescriptionProps = {
localeProp: string;
eventType: EventType;
className?: string;
};
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
export const EventTypeDescription = ({ localeProp, eventType, className }: EventTypeDescriptionProps) => {
const { t } = useLocale({ localeProp });
return (
<>
<div className={classNames("text-neutral-500 dark:text-white", className)}>
@ -41,13 +45,13 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
{eventType.schedulingType ? (
<li className="flex whitespace-nowrap">
<UsersIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && "Round Robin"}
{eventType.schedulingType === SchedulingType.COLLECTIVE && "Collective"}
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
{eventType.schedulingType === SchedulingType.COLLECTIVE && t("collective")}
</li>
) : (
<li className="flex whitespace-nowrap">
<UserIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
1-on-1
{t("1_on_1")}
</li>
)}
{eventType.price > 0 && (

View File

@ -1,21 +1,22 @@
import React, { SyntheticEvent, useState } from "react";
import { ErrorCode } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
import Modal from "@components/Modal";
const errorMessages: { [key: string]: string } = {
[ErrorCode.IncorrectPassword]: "Current password is incorrect",
[ErrorCode.NewPasswordMatchesOld]:
"New password matches your old password. Please choose a different password.",
};
const ChangePasswordSection = () => {
const ChangePasswordSection = ({ localeProp }: { localeProp: string }) => {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [successModalOpen, setSuccessModalOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { t } = useLocale({ localeProp });
const errorMessages: { [key: string]: string } = {
[ErrorCode.IncorrectPassword]: t("current_incorrect_password"),
[ErrorCode.NewPasswordMatchesOld]: t("new_password_matches_old_password"),
};
const closeSuccessModal = () => {
setSuccessModalOpen(false);
@ -48,10 +49,10 @@ const ChangePasswordSection = () => {
}
const body = await response.json();
setErrorMessage(errorMessages[body.error] || "Something went wrong. Please try again");
setErrorMessage(errorMessages[body.error] || `${t("something_went_wrong")}${t("please_try_again")}`);
} catch (err) {
console.error("Error changing password", err);
setErrorMessage("Something went wrong. Please try again");
console.error(t("error_changing_password"), err);
setErrorMessage(`${t("something_went_wrong")}${t("please_try_again")}`);
} finally {
setIsSubmitting(false);
}
@ -60,14 +61,14 @@ const ChangePasswordSection = () => {
return (
<>
<div className="mt-6">
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">Change Password</h2>
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">{t("change_password")}</h2>
</div>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={changePasswordHandler}>
<div className="py-6 lg:pb-8">
<div className="flex">
<div className="w-1/2 mr-2">
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
Current Password
{t("current_password")}
</label>
<div className="mt-1">
<input
@ -78,13 +79,13 @@ const ChangePasswordSection = () => {
id="current_password"
required
className="shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="Your old password"
placeholder={t("your_old_password")}
/>
</div>
</div>
<div className="w-1/2 ml-2">
<label htmlFor="new_password" className="block text-sm font-medium text-gray-700">
New Password
{t("new_password")}
</label>
<div className="mt-1">
<input
@ -95,7 +96,7 @@ const ChangePasswordSection = () => {
required
onInput={(e) => setNewPassword(e.currentTarget.value)}
className="shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder="Your super secure new password"
placeholder={t("super_secure_new_password")}
/>
</div>
</div>
@ -105,15 +106,15 @@ const ChangePasswordSection = () => {
<button
type="submit"
className="ml-2 bg-neutral-900 border border-transparent rounded-sm shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
Save
{t("save")}
</button>
</div>
<hr className="mt-4" />
</div>
</form>
<Modal
heading="Password updated successfully"
description="Your password has been successfully changed."
heading={t("password_updated_successfully")}
description={t("password_has_been_changed")}
open={successModalOpen}
handleClose={closeSuccessModal}
/>

View File

@ -1,6 +1,7 @@
import { SyntheticEvent, useState } from "react";
import { ErrorCode } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
import { Dialog, DialogContent } from "@components/Dialog";
import Button from "@components/ui/Button";
@ -18,12 +19,14 @@ interface DisableTwoFactorAuthModalProps {
* Called when the user disables two-factor auth
*/
onDisable: () => void;
localeProp: string;
}
const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuthModalProps) => {
const DisableTwoFactorAuthModal = ({ onDisable, onCancel, localeProp }: DisableTwoFactorAuthModalProps) => {
const [password, setPassword] = useState("");
const [isDisabling, setIsDisabling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { t } = useLocale({ localeProp });
async function handleDisable(e: SyntheticEvent) {
e.preventDefault();
@ -43,13 +46,13 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
const body = await response.json();
if (body.error === ErrorCode.IncorrectPassword) {
setErrorMessage("Password is incorrect.");
setErrorMessage(t("incorrect_password"));
} else {
setErrorMessage("Something went wrong.");
setErrorMessage(t("something_went_wrong"));
}
} catch (e) {
setErrorMessage("Something went wrong.");
console.error("Error disabling two-factor authentication", e);
setErrorMessage(t("something_went_wrong"));
console.error(t("error_disabling_2fa"), e);
} finally {
setIsDisabling(false);
}
@ -58,15 +61,12 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
return (
<Dialog open={true}>
<DialogContent>
<TwoFactorModalHeader
title="Disable two-factor authentication"
description="If you need to disable 2FA, we recommend re-enabling it as soon as possible."
/>
<TwoFactorModalHeader title={t("disable_2fa")} description={t("disable_2fa_recommendation")} />
<form onSubmit={handleDisable}>
<div className="mb-4">
<label htmlFor="password" className="mt-4 block text-sm font-medium text-gray-700">
Password
{t("password")}
</label>
<div className="mt-1">
<input
@ -90,10 +90,10 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
className="ml-2"
onClick={handleDisable}
disabled={password.length === 0 || isDisabling}>
Disable
{t("disable")}
</Button>
<Button color="secondary" onClick={onCancel}>
Cancel
{t("cancel")}
</Button>
</div>
</DialogContent>

View File

@ -1,6 +1,7 @@
import React, { SyntheticEvent, useState } from "react";
import { ErrorCode } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
import { Dialog, DialogContent } from "@components/Dialog";
import Button from "@components/ui/Button";
@ -18,6 +19,7 @@ interface EnableTwoFactorModalProps {
* Called when the user enables two-factor auth
*/
onEnable: () => void;
localeProp: string;
}
enum SetupStep {
@ -45,7 +47,7 @@ const WithStep = ({
return step === current ? children : null;
};
const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps) => {
const EnableTwoFactorModal = ({ onEnable, onCancel, localeProp }: EnableTwoFactorModalProps) => {
const [step, setStep] = useState(SetupStep.ConfirmPassword);
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
@ -53,6 +55,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
const [secret, setSecret] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { t } = useLocale({ localeProp });
async function handleSetup(e: SyntheticEvent) {
e.preventDefault();
@ -76,13 +79,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
}
if (body.error === ErrorCode.IncorrectPassword) {
setErrorMessage("Password is incorrect.");
setErrorMessage(t("incorrect_password"));
} else {
setErrorMessage("Something went wrong.");
setErrorMessage(t("something_went_wrong"));
}
} catch (e) {
setErrorMessage("Something went wrong.");
console.error("Error setting up two-factor authentication", e);
setErrorMessage(t("something_went_wrong"));
console.error(t("error_enabling_2fa"), e);
} finally {
setIsSubmitting(false);
}
@ -108,13 +111,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
}
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
setErrorMessage("Code is incorrect. Please try again.");
setErrorMessage(`${t("code_is_incorrect")} ${t("please_try_again")}`);
} else {
setErrorMessage("Something went wrong.");
setErrorMessage(t("something_went_wrong"));
}
} catch (e) {
setErrorMessage("Something went wrong.");
console.error("Error enabling up two-factor authentication", e);
setErrorMessage(t("something_went_wrong"));
console.error(t("error_enabling_2fa"), e);
} finally {
setIsSubmitting(false);
}
@ -123,16 +126,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
return (
<Dialog open={true}>
<DialogContent>
<TwoFactorModalHeader
title="Enable two-factor authentication"
description={setupDescriptions[step]}
/>
<TwoFactorModalHeader title={t("enable_2fa")} description={setupDescriptions[step]} />
<WithStep step={SetupStep.ConfirmPassword} current={step}>
<form onSubmit={handleSetup}>
<div className="mb-4">
<label htmlFor="password" className="mt-4 block text-sm font-medium text-gray-700">
Password
{t("password")}
</label>
<div className="mt-1">
<input
@ -162,7 +162,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
<form onSubmit={handleEnable}>
<div className="mb-4">
<label htmlFor="code" className="mt-4 block text-sm font-medium text-gray-700">
Code
{t("code")}
</label>
<div className="mt-1">
<input
@ -191,12 +191,12 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
className="ml-2"
onClick={handleSetup}
disabled={password.length === 0 || isSubmitting}>
Continue
{t("continue")}
</Button>
</WithStep>
<WithStep step={SetupStep.DisplayQrCode} current={step}>
<Button type="submit" className="ml-2" onClick={() => setStep(SetupStep.EnterTotpCode)}>
Continue
{t("continue")}
</Button>
</WithStep>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
@ -205,11 +205,11 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
className="ml-2"
onClick={handleEnable}
disabled={totpCode.length !== 6 || isSubmitting}>
Enable
{t("enable")}
</Button>
</WithStep>
<Button color="secondary" onClick={onCancel}>
Cancel
{t("cancel")}
</Button>
</div>
</DialogContent>

View File

@ -1,37 +1,45 @@
import { useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import Badge from "@components/ui/Badge";
import Button from "@components/ui/Button";
import DisableTwoFactorModal from "./DisableTwoFactorModal";
import EnableTwoFactorModal from "./EnableTwoFactorModal";
const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean }) => {
const TwoFactorAuthSection = ({
twoFactorEnabled,
localeProp,
}: {
twoFactorEnabled: boolean;
localeProp: string;
}) => {
const [enabled, setEnabled] = useState(twoFactorEnabled);
const [enableModalOpen, setEnableModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
const { t, locale } = useLocale({ localeProp });
return (
<>
<div className="flex flex-row items-center">
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">Two-Factor Authentication</h2>
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">{t("2fa")}</h2>
<Badge className="text-xs ml-2" variant={enabled ? "success" : "gray"}>
{enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-500">
Add an extra layer of security to your account in case your password is stolen.
</p>
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
<Button
className="mt-6"
type="submit"
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
{enabled ? "Disable" : "Enable"} Two-Factor Authentication
{enabled ? "Disable" : "Enable"} {t("2fa")}
</Button>
{enableModalOpen && (
<EnableTwoFactorModal
localeProp={locale}
onEnable={() => {
setEnabled(true);
setEnableModalOpen(false);
@ -42,6 +50,7 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
{disableModalOpen && (
<DisableTwoFactorModal
localeProp={locale}
onDisable={() => {
setEnabled(false);
setDisableModalOpen(false);

View File

@ -1,6 +1,7 @@
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
import React, { useEffect, useRef, useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { Member } from "@lib/member";
import { Team } from "@lib/team";
@ -16,7 +17,11 @@ import ErrorAlert from "@components/ui/alerts/Error";
import MemberList from "./MemberList";
export default function EditTeam(props: { team: Team | undefined | null; onCloseEdit: () => void }) {
export default function EditTeam(props: {
localeProp: string;
team: Team | undefined | null;
onCloseEdit: () => void;
}) {
const [members, setMembers] = useState([]);
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
@ -30,6 +35,7 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
const [inviteModalTeam, setInviteModalTeam] = useState<Team | null | undefined>();
const [errorMessage, setErrorMessage] = useState("");
const [imageSrc, setImageSrc] = useState<string>("");
const { t, locale } = useLocale({ localeProp: props.localeProp });
const loadMembers = () =>
fetch("/api/teams/" + props.team?.id + "/membership")
@ -132,19 +138,19 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
size="sm"
StartIcon={ArrowLeftIcon}
onClick={() => props.onCloseEdit()}>
Back
{t("back")}
</Button>
</div>
<div>
<div className="pb-5 pr-4 sm:pb-6">
<h3 className="text-lg font-bold leading-6 text-gray-900">{props.team?.name}</h3>
<div className="max-w-xl mt-2 text-sm text-gray-500">
<p>Manage your team</p>
<p>{t("manage_your_team")}</p>
</div>
</div>
</div>
<hr className="mt-2" />
<h3 className="font-cal font-bold leading-6 text-gray-900 mt-7 text-md">Profile</h3>
<h3 className="font-cal font-bold leading-6 text-gray-900 mt-7 text-md">{t("profile")}</h3>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateTeamHandler}>
{hasErrors && <ErrorAlert message={errorMessage} />}
<div className="py-6 lg:pb-8">
@ -152,18 +158,22 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
<div className="flex-grow space-y-6">
<div className="block sm:flex">
<div className="w-full mb-6 sm:w-1/2 sm:mr-2">
<UsernameInput ref={teamUrlRef} defaultValue={props.team?.slug} label={"My team URL"} />
<UsernameInput
ref={teamUrlRef}
defaultValue={props.team?.slug}
label={t("my_team_url")}
/>
</div>
<div className="w-full sm:w-1/2 sm:ml-2">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Team name
{t("team_name")}
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder="Your team name"
placeholder={t("your_team_name")}
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
defaultValue={props.team?.name}
@ -172,7 +182,7 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
</div>
<div>
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
About
{t("about")}
</label>
<div className="mt-1">
<textarea
@ -182,9 +192,7 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
rows={3}
defaultValue={props.team?.bio}
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"></textarea>
<p className="mt-2 text-sm text-gray-500">
A few sentences about your team. This will appear on your team&apos;s URL page.
</p>
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
</div>
</div>
<div>
@ -206,7 +214,7 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
<ImageUploader
target="logo"
id="logo-upload"
buttonMsg={imageSrc !== "" ? "Edit logo" : "Upload a logo"}
buttonMsg={imageSrc !== "" ? t("edit_logo") : t("upload_a_logo")}
handleAvatarChange={handleLogoChange}
imageSrc={imageSrc ?? props.team?.logo}
/>
@ -214,20 +222,25 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
<hr className="mt-6" />
</div>
<div className="flex justify-between mt-7">
<h3 className="font-cal font-bold leading-6 text-gray-900 text-md">Members</h3>
<h3 className="font-cal font-bold leading-6 text-gray-900 text-md">{t("members")}</h3>
<div className="relative flex items-center">
<Button
type="button"
color="secondary"
StartIcon={PlusIcon}
onClick={() => onInviteMember(props.team)}>
New Member
{t("new_member")}
</Button>
</div>
</div>
<div>
{!!members.length && (
<MemberList members={members} onRemoveMember={onRemoveMember} onChange={loadMembers} />
<MemberList
localeProp={locale}
members={members}
onRemoveMember={onRemoveMember}
onChange={loadMembers}
/>
)}
<hr className="mt-6" />
</div>
@ -245,14 +258,14 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
</div>
<div className="ml-3 text-sm">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
Disable Cal.com branding
{t("disable_cal_branding")}
</label>
<p className="text-gray-500">Hide all Cal.com branding from your public pages.</p>
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
</div>
</div>
<hr className="mt-6" />
</div>
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">Danger Zone</h3>
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">{t("danger_zone")}</h3>
<div>
<div className="relative flex items-start">
<Dialog>
@ -262,16 +275,15 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
}}
className="btn-sm btn-white">
<TrashIcon className="group-hover:text-red text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
Disband Team
{t("disband_team")}
</DialogTrigger>
<ConfirmationDialogContent
localeProp={locale}
variety="danger"
title="Disband Team"
confirmBtnText="Yes, disband team"
cancelBtnText="Cancel"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={() => deleteTeam()}>
Are you sure you want to disband this team? Anyone who you&apos;ve shared this team
link with will no longer be able to book using it.
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</div>
@ -281,19 +293,23 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
<hr className="mt-8" />
<div className="flex justify-end py-4">
<Button type="submit" color="primary">
Save
{t("save")}
</Button>
</div>
</div>
</form>
<Modal
heading="Team updated successfully"
description="Your team has been updated successfully."
heading={t("team_updated_successfully")}
description={t("your_team_updated_successfully")}
open={successModalOpen}
handleClose={closeSuccessModal}
/>
{showMemberInvitationModal && (
<MemberInvitationModal team={inviteModalTeam} onExit={onMemberInvitationModalExit} />
<MemberInvitationModal
localeProp={locale}
team={inviteModalTeam}
onExit={onMemberInvitationModalExit}
/>
)}
</div>
</div>

View File

@ -1,12 +1,18 @@
import { UsersIcon } from "@heroicons/react/outline";
import { useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { Team } from "@lib/team";
import Button from "@components/ui/Button";
export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) {
export default function MemberInvitationModal(props: {
localeProp: string;
team: Team | undefined | null;
onExit: () => void;
}) {
const [errorMessage, setErrorMessage] = useState("");
const { t } = useLocale({ localeProp: props.localeProp });
const handleError = async (res: Response) => {
const responseData = await res.json();
@ -64,10 +70,10 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
Invite a new member
{t("invite_new_member")}
</h3>
<div>
<p className="text-sm text-gray-400">Invite someone to your team.</p>
<p className="text-sm text-gray-400">{t("invite_new_team_member")}</p>
</div>
</div>
</div>
@ -75,7 +81,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
<div>
<div className="mb-4">
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">
Email or Username
{t("email_or_username")}
</label>
<input
type="text"
@ -88,13 +94,13 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
</div>
<div className="mb-4">
<label className="block mb-2 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
Role
{t("role")}
</label>
<select
id="role"
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-black sm:text-sm">
<option value="MEMBER">Member</option>
<option value="OWNER">Owner</option>
<option value="MEMBER">{t("member")}</option>
<option value="OWNER">{t("owner")}</option>
</select>
</div>
<div className="relative flex items-start">
@ -109,7 +115,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
</div>
<div className="ml-2 text-sm">
<label htmlFor="sendInviteEmail" className="font-medium text-gray-700">
Send an invite email
{t("send_invite_email")}
</label>
</div>
</div>
@ -122,10 +128,10 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
)}
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit" color="primary" className="ml-2">
Invite
{t("invite")}
</Button>
<Button type="button" color="secondary" onClick={props.onExit}>
Cancel
{t("cancel")}
</Button>
</div>
</form>

View File

@ -1,12 +1,16 @@
import { useLocale } from "@lib/hooks/useLocale";
import { Member } from "@lib/member";
import MemberListItem from "./MemberListItem";
export default function MemberList(props: {
localeProp: string;
members: Member[];
onRemoveMember: (text: Member) => void;
onChange: (text: string) => void;
}) {
const { locale } = useLocale({ localeProp: props.localeProp });
const selectAction = (action: string, member: Member) => {
switch (action) {
case "remove":
@ -20,6 +24,7 @@ export default function MemberList(props: {
<ul className="px-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
{props.members.map((member) => (
<MemberListItem
localeProp={locale}
onChange={props.onChange}
key={member.id}
member={member}

View File

@ -1,6 +1,7 @@
import { DotsHorizontalIcon, UserRemoveIcon } from "@heroicons/react/outline";
import { useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { Member } from "@lib/member";
import { Dialog, DialogTrigger } from "@components/Dialog";
@ -11,11 +12,13 @@ import Button from "@components/ui/Button";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
export default function MemberListItem(props: {
localeProp: string;
member: Member;
onActionSelect: (text: string) => void;
onChange: (text: string) => void;
}) {
const [member] = useState(props.member);
const { t, locale } = useLocale({ localeProp: props.localeProp });
return (
member && (
@ -41,21 +44,21 @@ export default function MemberListItem(props: {
{props.member.role === "INVITEE" && (
<>
<span className="self-center h-6 px-3 py-1 mr-2 text-xs text-yellow-700 capitalize rounded-md bg-yellow-50">
Pending
{t("pending")}
</span>
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
Member
{t("member")}
</span>
</>
)}
{props.member.role === "MEMBER" && (
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
Member
{t("member")}
</span>
)}
{props.member.role === "OWNER" && (
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-blue-700 capitalize rounded-md bg-blue-50">
Owner
{t("owner")}
</span>
)}
<Dropdown>
@ -73,16 +76,16 @@ export default function MemberListItem(props: {
color="warn"
StartIcon={UserRemoveIcon}
className="w-full">
Remove User
{t("remove_member")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
localeProp={locale}
variety="danger"
title="Remove member"
confirmBtnText="Yes, remove member"
cancelBtnText="Cancel"
title={t("remove_member")}
confirmBtnText={t("confirm_remove_member")}
onConfirm={() => props.onActionSelect("remove")}>
Are you sure you want to remove this member from the team?
{t("remove_member_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>

View File

@ -1,12 +1,16 @@
import { useLocale } from "@lib/hooks/useLocale";
import { Team } from "@lib/team";
import TeamListItem from "./TeamListItem";
export default function TeamList(props: {
localeProp: string;
teams: Team[];
onChange: () => void;
onEditTeam: (text: Team) => void;
}) {
const { locale } = useLocale({ localeProp: props.localeProp });
const selectAction = (action: string, team: Team) => {
switch (action) {
case "edit":
@ -30,6 +34,7 @@ export default function TeamList(props: {
<ul className="px-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
{props.teams.map((team: Team) => (
<TeamListItem
localeProp={locale}
onChange={props.onChange}
key={team.id}
team={team}

View File

@ -8,6 +8,7 @@ import {
import Link from "next/link";
import { useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { Dialog, DialogTrigger } from "@components/Dialog";
@ -30,12 +31,14 @@ interface Team {
}
export default function TeamListItem(props: {
localeProp: string;
onChange: () => void;
key: number;
team: Team;
onActionSelect: (text: string) => void;
}) {
const [team, setTeam] = useState<Team | null>(props.team);
const { t, locale } = useLocale({ localeProp: props.localeProp });
const acceptInvite = () => invitationResponse(true);
const declineInvite = () => invitationResponse(false);
@ -79,24 +82,24 @@ export default function TeamListItem(props: {
{props.team.role === "INVITEE" && (
<div>
<Button type="button" color="secondary" onClick={declineInvite}>
Reject
{t("reject")}
</Button>
<Button type="button" color="primary" className="ml-1" onClick={acceptInvite}>
Accept
{t("accept")}
</Button>
</div>
)}
{props.team.role === "MEMBER" && (
<div>
<Button type="button" color="primary" onClick={declineInvite}>
Leave
{t("leave")}
</Button>
</div>
)}
{props.team.role === "OWNER" && (
<div className="flex space-x-4">
<span className="self-center h-6 px-3 py-1 text-xs text-gray-700 capitalize rounded-md bg-gray-50">
Owner
{t("owner")}
</span>
<Tooltip content="Copy link">
<Button
@ -104,7 +107,7 @@ export default function TeamListItem(props: {
navigator.clipboard.writeText(
process.env.NEXT_PUBLIC_APP_URL + "/team/" + props.team.slug
);
showToast("Link copied!", "success");
showToast(t("link_copied"), "success");
}}
size="icon"
color="minimal"
@ -124,14 +127,16 @@ export default function TeamListItem(props: {
className="w-full"
onClick={() => props.onActionSelect("edit")}
StartIcon={PencilAltIcon}>
Edit team
{" "}
{t("edit_team")}
</Button>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team.slug}`} passHref={true}>
<a target="_blank">
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}>
Preview team page
{" "}
{t("preview_team")}
</Button>
</a>
</Link>
@ -146,17 +151,16 @@ export default function TeamListItem(props: {
color="warn"
StartIcon={TrashIcon}
className="w-full">
Disband Team
{t("disband_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
localeProp={locale}
variety="danger"
title="Disband Team"
confirmBtnText="Yes, disband team"
cancelBtnText="Cancel"
confirmBtnText={t("confirm_disband_team")}
onConfirm={() => props.onActionSelect("disband")}>
Are you sure you want to disband this team? Anyone who you&apos;ve shared this team
link with will no longer be able to book using it.
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>

View File

@ -4,11 +4,15 @@ import classnames from "classnames";
import Link from "next/link";
import React from "react";
import { useLocale } from "@lib/hooks/useLocale";
import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
import Text from "@components/ui/Text";
const Team = ({ team }) => {
const Team = ({ team, localeProp }) => {
const { t } = useLocale({ localeProp: localeProp });
const Member = ({ member }) => {
const classes = classnames(
"group",
@ -71,7 +75,7 @@ const Team = ({ team }) => {
{team.eventTypes.length > 0 && (
<aside className="text-center dark:text-white mt-8">
<Button color="secondary" href={`/team/${team.slug}`} shallow={true} StartIcon={ArrowLeftIcon}>
Go back
{t("go_back")}
</Button>
</aside>
)}

127
crowdin.yml Normal file
View File

@ -0,0 +1,127 @@
#
# Your Crowdin credentials
#
"project_id_env" : "CROWDIN_PROJECT_ID"
"api_token_env" : "CROWDIN_PERSONAL_TOKEN"
"base_path" : "."
"base_url" : "https://cal.crowdin.com"
#
# Choose file structure in Crowdin
# e.g. true or false
#
"preserve_hierarchy": true
#
# Files configuration
#
files: [
{
#
# Source files filter
# e.g. "/resources/en/*.json"
#
"source" : "/public/static/locales/en/*.json",
#
# Where translations will be placed
# e.g. "/resources/%two_letters_code%/%original_file_name%"
#
"translation" : "/public/static/locales/%two_letters_code%/%original_file_name%",
#
# Files or directories for ignore
# e.g. ["/**/?.txt", "/**/[0-9].txt", "/**/*\?*.txt"]
#
#"ignore" : [],
#
# The dest allows you to specify a file name in Crowdin
# e.g. "/messages.json"
#
#"dest" : "",
#
# File type
# e.g. "json"
#
#"type" : "",
#
# The parameter "update_option" is optional. If it is not set, after the files update the translations for changed strings will be removed. Use to fix typos and for minor changes in the source strings
# e.g. "update_as_unapproved" or "update_without_changes"
#
#"update_option" : "",
#
# Start block (for XML only)
#
#
# Defines whether to translate tags attributes.
# e.g. 0 or 1 (Default is 1)
#
# "translate_attributes" : 1,
#
# Defines whether to translate texts placed inside the tags.
# e.g. 0 or 1 (Default is 1)
#
# "translate_content" : 1,
#
# This is an array of strings, where each item is the XPaths to DOM element that should be imported
# e.g. ["/content/text", "/content/text[@value]"]
#
# "translatable_elements" : [],
#
# Defines whether to split long texts into smaller text segments
# e.g. 0 or 1 (Default is 1)
#
# "content_segmentation" : 1,
#
# End block (for XML only)
#
#
# Start .properties block
#
#
# Defines whether single quote should be escaped by another single quote or backslash in exported translations
# e.g. 0 or 1 or 2 or 3 (Default is 3)
# 0 - do not escape single quote;
# 1 - escape single quote by another single quote;
# 2 - escape single quote by backslash;
# 3 - escape single quote by another single quote only in strings containing variables ( {0} ).
#
# "escape_quotes" : 3,
#
# Defines whether any special characters (=, :, ! and #) should be escaped by backslash in exported translations.
# e.g. 0 or 1 (Default is 0)
# 0 - do not escape special characters
# 1 - escape special characters by a backslash
#
# "escape_special_characters": 0
#
#
# End .properties block
#
#
# Does the first line contain header?
# e.g. true or false
#
#"first_line_contains_header" : true,
#
# for spreadsheets
# e.g. "identifier,source_phrase,context,uk,ru,fr"
#
# "scheme" : "",
}
]

View File

@ -6,8 +6,8 @@ import prisma from "@lib/prisma";
import { i18n } from "../../../next-i18next.config";
export const extractLocaleInfo = async (req: IncomingMessage) => {
const session = await getSession({ req: req });
export const getOrSetUserLocaleFromHeaders = async (req: IncomingMessage) => {
const session = await getSession({ req });
const preferredLocale = parser.pick(i18n.locales, req.headers["accept-language"]);
if (session?.user?.id) {
@ -58,7 +58,14 @@ interface localeType {
export const localeLabels: localeType = {
en: "English",
fr: "French",
it: "Italian",
ru: "Russian",
es: "Spanish",
de: "German",
pt: "Portuguese",
ro: "Romanian",
nl: "Dutch",
};
export type OptionType = {

View File

@ -1,10 +1,10 @@
import { useTranslation } from "next-i18next";
type LocaleProps = {
type LocaleProp = {
localeProp: string;
};
export const useLocale = (props: LocaleProps) => {
export const useLocale = (props: LocaleProp) => {
const { i18n, t } = useTranslation("common");
if (i18n.language !== props.localeProp) {

View File

@ -4,7 +4,7 @@ const path = require("path");
module.exports = {
i18n: {
defaultLocale: "en",
locales: ["en", "ro"],
locales: ["en", "fr", "it", "ru", "es", "de", "pt", "ro", "nl"],
},
localePath: path.resolve("./public/static/locales"),
};

View File

@ -1,29 +1,23 @@
import { ArrowRightIcon } from "@heroicons/react/outline";
import { GetStaticPaths, GetStaticPropsContext } from "next";
import { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import Link from "next/link";
import React from "react";
import { getOrSetUserLocaleFromHeaders } from "@lib/core/i18n/i18n.utils";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import prisma from "@lib/prisma";
import { trpc } from "@lib/trpc";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
import { HeadSeo } from "@components/seo/head-seo";
import Avatar from "@components/ui/Avatar";
import { ssg } from "@server/ssg";
export default function User(props: inferSSRProps<typeof getStaticProps>) {
const { username } = props;
// data of query below will be will be prepopulated b/c of `getStaticProps`
const query = trpc.useQuery(["booking.userEventTypes", { username }]);
const { isReady } = useTheme(query.data?.user.theme);
if (!query.data) {
// this shold never happen as we do `blocking: true`
return <>...</>;
}
const { user, eventTypes } = query.data;
export default function User(props: inferSSRProps<typeof getServerSideProps>) {
const { isReady } = useTheme(props.user.theme);
const { user, localeProp, eventTypes } = props;
const { t, locale } = useLocale({ localeProp });
return (
<>
@ -56,7 +50,7 @@ export default function User(props: inferSSRProps<typeof getStaticProps>) {
<Link href={`/${user.username}/${type.slug}`}>
<a className="block px-6 py-4">
<h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
<EventTypeDescription eventType={type} />
<EventTypeDescription localeProp={locale} eventType={type} />
</a>
</Link>
</div>
@ -65,8 +59,10 @@ export default function User(props: inferSSRProps<typeof getStaticProps>) {
{eventTypes.length === 0 && (
<div className="shadow overflow-hidden rounded-sm">
<div className="p-8 text-center text-gray-400 dark:text-white">
<h2 className="font-cal font-semibold text-3xl text-gray-600 dark:text-white">Uh oh!</h2>
<p className="max-w-md mx-auto">This user hasn&apos;t set up any event types yet.</p>
<h2 className="font-cal font-semibold text-3xl text-gray-600 dark:text-white">
{t("uh_oh")}
</h2>
<p className="max-w-md mx-auto">{t("no_event_types_have_been_setup")}</p>
</div>
</div>
)}
@ -77,43 +73,76 @@ export default function User(props: inferSSRProps<typeof getStaticProps>) {
);
}
export const getStaticPaths: GetStaticPaths = async () => {
const allUsers = await prisma.user.findMany({
select: {
username: true,
},
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const username = (context.query.user as string).toLowerCase();
const locale = await getOrSetUserLocaleFromHeaders(context.req);
const user = await prisma.user.findUnique({
where: {
// will statically render everyone on the PRO plan
// the rest will be statically rendered on first visit
plan: "PRO",
username: username.toLowerCase(),
},
select: {
id: true,
username: true,
email: true,
name: true,
bio: true,
avatar: true,
theme: true,
plan: true,
},
});
const usernames = allUsers.flatMap((u) => (u.username ? [u.username] : []));
return {
paths: usernames.map((user) => ({
params: { user },
})),
// https://nextjs.org/docs/basic-features/data-fetching#fallback-blocking
fallback: "blocking",
};
};
export async function getStaticProps(context: GetStaticPropsContext<{ user: string }>) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const username = context.params!.user;
const data = await ssg.fetchQuery("booking.userEventTypes", { username });
if (!data) {
if (!user) {
return {
notFound: true,
};
}
const eventTypesWithHidden = await prisma.eventType.findMany({
where: {
AND: [
{
teamId: null,
},
{
OR: [
{
userId: user.id,
},
{
users: {
some: {
id: user.id,
},
},
},
],
},
],
},
select: {
id: true,
slug: true,
title: true,
length: true,
description: true,
hidden: true,
schedulingType: true,
price: true,
currency: true,
},
take: user.plan === "FREE" ? 1 : undefined,
});
const eventTypes = eventTypesWithHidden.filter((evt) => !evt.hidden);
return {
props: {
trpcState: ssg.dehydrate(),
username,
localeProp: locale,
user,
eventTypes,
...(await serverSideTranslations(locale, ["common"])),
},
revalidate: 1,
};
}
};

View File

@ -3,7 +3,7 @@ import { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { asStringOrNull } from "@lib/asStringOrNull";
import { extractLocaleInfo } from "@lib/core/i18n/i18n.utils";
import { getOrSetUserLocaleFromHeaders } from "@lib/core/i18n/i18n.utils";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -16,7 +16,9 @@ export default function Type(props: AvailabilityPageProps) {
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const locale = await extractLocaleInfo(context.req);
const locale = await getOrSetUserLocaleFromHeaders(context.req);
// get query params and typecast them to string
// (would be even better to assert them instead of typecasting)
const userParam = asStringOrNull(context.query.user);
const typeParam = asStringOrNull(context.query.type);
const dateParam = asStringOrNull(context.query.date);

View File

@ -5,7 +5,7 @@ import { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { asStringOrThrow } from "@lib/asStringOrNull";
import { extractLocaleInfo } from "@lib/core/i18n/i18n.utils";
import { getOrSetUserLocaleFromHeaders } from "@lib/core/i18n/i18n.utils";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -21,7 +21,7 @@ export default function Book(props: BookPageProps) {
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const locale = await extractLocaleInfo(context.req);
const locale = await getOrSetUserLocaleFromHeaders(context.req);
const user = await prisma.user.findUnique({
where: {

View File

@ -37,7 +37,8 @@ import {
import { getSession } from "@lib/auth";
import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { extractLocaleInfo } from "@lib/core/i18n/i18n.utils";
import { getOrSetUserLocaleFromHeaders } from "@lib/core/i18n/i18n.utils";
import { useLocale } from "@lib/hooks/useLocale";
import getIntegrations, { hasIntegration } from "@lib/integrations/getIntegrations";
import { LocationType } from "@lib/location";
import deleteEventType from "@lib/mutations/event-types/delete-event-type";
@ -84,6 +85,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const { eventType, locationOptions, availability, team, teamMembers, hasPaymentIntegration, currency } =
props;
const { locale } = useLocale({ localeProp: props.localeProp });
const router = useRouter();
const [successModalOpen, setSuccessModalOpen] = useState(false);
@ -983,6 +985,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
Delete
</DialogTrigger>
<ConfirmationDialogContent
localeProp={locale}
variety="danger"
title="Delete Event Type"
confirmBtnText="Yes, delete event type"
@ -1097,7 +1100,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req, query } = context;
const session = await getSession({ req });
const locale = await extractLocaleInfo(context.req);
const locale = await getOrSetUserLocaleFromHeaders(context.req);
const typeParam = parseInt(asStringOrThrow(query.type));

View File

@ -22,7 +22,7 @@ import { asStringOrNull } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { extractLocaleInfo } from "@lib/core/i18n/i18n.utils";
import { getOrSetUserLocaleFromHeaders } from "@lib/core/i18n/i18n.utils";
import { ONBOARDING_INTRODUCED_AT } from "@lib/getting-started";
import { useLocale } from "@lib/hooks/useLocale";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
@ -56,10 +56,7 @@ type Profile = PageProps["profiles"][number];
type MembershipCount = EventType["metadata"]["membershipCount"];
const EventTypesPage = (props: PageProps) => {
const { locale } = useLocale({
localeProp: props.localeProp,
namespaces: "event-types-page",
});
const { locale } = useLocale({ localeProp: props.localeProp });
const CreateFirstEventTypeView = () => (
<div className="md:py-20">
@ -164,7 +161,7 @@ const EventTypesPage = (props: PageProps) => {
</span>
)}
</div>
<EventTypeDescription eventType={type} />
<EventTypeDescription localeProp={locale} eventType={type} />
</a>
</Link>
@ -379,13 +376,13 @@ const CreateNewEventDialog = ({
disabled: true,
})}
StartIcon={PlusIcon}>
{t("new-event-type-btn")}
{t("new_event_type_btn")}
</Button>
)}
{profiles.filter((profile) => profile.teamId).length > 0 && (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button EndIcon={ChevronDownIcon}>{t("new-event-type-btn")}</Button>
<Button EndIcon={ChevronDownIcon}>{t("new_event_type_btn")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Create an event type under your name or a team.</DropdownMenuLabel>
@ -563,7 +560,7 @@ const CreateNewEventDialog = ({
export async function getServerSideProps(context) {
const session = await getSession(context);
const locale = await extractLocaleInfo(context.req);
const locale = await getOrSetUserLocaleFromHeaders(context.req);
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };

View File

@ -7,7 +7,12 @@ import TimezoneSelect from "react-timezone-select";
import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { extractLocaleInfo, localeLabels, localeOptions, OptionType } from "@lib/core/i18n/i18n.utils";
import {
getOrSetUserLocaleFromHeaders,
localeLabels,
localeOptions,
OptionType,
} from "@lib/core/i18n/i18n.utils";
import { useLocale } from "@lib/hooks/useLocale";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import prisma from "@lib/prisma";
@ -416,7 +421,7 @@ export default function Settings(props: Props) {
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getSession(context);
const locale = await extractLocaleInfo(context.req);
const locale = await getOrSetUserLocaleFromHeaders(context.req);
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };

View File

@ -1,6 +1,9 @@
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import React from "react";
import { getSession } from "@lib/auth";
import { getOrSetUserLocaleFromHeaders } from "@lib/core/i18n/i18n.utils";
import { useLocale } from "@lib/hooks/useLocale";
import prisma from "@lib/prisma";
import SettingsShell from "@components/SettingsShell";
@ -8,12 +11,14 @@ import Shell from "@components/Shell";
import ChangePasswordSection from "@components/security/ChangePasswordSection";
import TwoFactorAuthSection from "@components/security/TwoFactorAuthSection";
export default function Security({ user }) {
export default function Security({ user, localeProp }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { locale, t } = useLocale({ localeProp });
return (
<Shell heading="Security" subtitle="Manage your account's security.">
<Shell heading={t("security")} subtitle={t("manage_account_security")}>
<SettingsShell>
<ChangePasswordSection />
<TwoFactorAuthSection twoFactorEnabled={user.twoFactorEnabled} />
<ChangePasswordSection localeProp={locale} />
<TwoFactorAuthSection localeProp={locale} twoFactorEnabled={user.twoFactorEnabled} />
</SettingsShell>
</Shell>
);
@ -21,6 +26,8 @@ export default function Security({ user }) {
export async function getServerSideProps(context) {
const session = await getSession(context);
const locale = await getOrSetUserLocaleFromHeaders(context.req);
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
@ -38,6 +45,11 @@ export async function getServerSideProps(context) {
});
return {
props: { session, user },
props: {
localeProp: locale,
session,
user,
...(await serverSideTranslations(locale, ["common"])),
},
};
}

View File

@ -3,9 +3,12 @@ import { PlusIcon } from "@heroicons/react/solid";
import { GetServerSideProps } from "next";
import type { Session } from "next-auth";
import { useSession } from "next-auth/client";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { useEffect, useRef, useState } from "react";
import { getSession } from "@lib/auth";
import { getOrSetUserLocaleFromHeaders } from "@lib/core/i18n/i18n.utils";
import { useLocale } from "@lib/hooks/useLocale";
import { Member } from "@lib/member";
import { Team } from "@lib/team";
@ -17,7 +20,7 @@ import TeamList from "@components/team/TeamList";
import TeamListItem from "@components/team/TeamListItem";
import Button from "@components/ui/Button";
export default function Teams() {
export default function Teams(props: { localeProp: string }) {
const noop = () => undefined;
const [, loading] = useSession();
const [teams, setTeams] = useState([]);
@ -26,6 +29,7 @@ export default function Teams() {
const [editTeamEnabled, setEditTeamEnabled] = useState(false);
const [teamToEdit, setTeamToEdit] = useState<Team | null>();
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const { locale } = useLocale({ localeProp: props.localeProp });
const handleErrors = async (resp: Response) => {
if (!resp.ok) {
@ -110,7 +114,11 @@ export default function Teams() {
</div>
<div>
{!!teams.length && (
<TeamList teams={teams} onChange={loadData} onEditTeam={editTeam}></TeamList>
<TeamList
localeProp={locale}
teams={teams}
onChange={loadData}
onEditTeam={editTeam}></TeamList>
)}
{!!invites.length && (
@ -119,6 +127,7 @@ export default function Teams() {
<ul className="px-4 mt-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
{invites.map((team: Team) => (
<TeamListItem
localeProp={locale}
onChange={loadData}
key={team.id}
team={team}
@ -131,7 +140,7 @@ export default function Teams() {
</div>
</div>
)}
{!!editTeamEnabled && <EditTeam team={teamToEdit} onCloseEdit={onCloseEdit} />}
{!!editTeamEnabled && <EditTeam localeProp={locale} team={teamToEdit} onCloseEdit={onCloseEdit} />}
{showCreateTeamModal && (
<div
className="fixed inset-0 z-50 overflow-y-auto"
@ -200,11 +209,16 @@ export default function Teams() {
// Export the `session` prop to use sessions with Server Side Rendering
export const getServerSideProps: GetServerSideProps<{ session: Session | null }> = async (context) => {
const session = await getSession(context);
const locale = await getOrSetUserLocaleFromHeaders(context.req);
if (!session) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
return {
props: { session },
props: {
session,
localeProp: locale,
...(await serverSideTranslations(locale, ["common"])),
},
};
};

View File

@ -1,9 +1,12 @@
import { ArrowRightIcon } from "@heroicons/react/solid";
import { Prisma } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import Link from "next/link";
import React from "react";
import { getOrSetUserLocaleFromHeaders } from "@lib/core/i18n/i18n.utils";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import prisma from "@lib/prisma";
@ -18,9 +21,10 @@ import AvatarGroup from "@components/ui/AvatarGroup";
import Button from "@components/ui/Button";
import Text from "@components/ui/Text";
function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
function TeamPage({ team, localeProp }: inferSSRProps<typeof getServerSideProps>) {
const { isReady } = useTheme();
const showMembers = useToggleQuery("members");
const { t, locale } = useLocale({ localeProp: localeProp });
const eventTypes = (
<ul className="space-y-3">
@ -33,7 +37,7 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
<a className="px-6 py-4 flex justify-between">
<div className="flex-shrink">
<h2 className="font-cal font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
<EventTypeDescription className="text-sm" eventType={type} />
<EventTypeDescription localeProp={locale} className="text-sm" eventType={type} />
</div>
<div className="mt-1">
<AvatarGroup
@ -64,7 +68,7 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
<Avatar alt={teamName} imageSrc={team.logo} className="mx-auto w-20 h-20 rounded-full mb-4" />
<Text variant="headline">{teamName}</Text>
</div>
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{(showMembers.isOn || !team.eventTypes.length) && <Team localeProp={locale} team={team} />}
{!showMembers.isOn && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl">
{eventTypes}
@ -75,7 +79,7 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
</div>
<div className="relative flex justify-center">
<span className="px-2 bg-gray-100 text-sm text-gray-500 dark:bg-black dark:text-gray-500">
OR
{t("or")}
</span>
</div>
</div>
@ -86,7 +90,7 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
EndIcon={ArrowRightIcon}
href={`/team/${team.slug}?members=1`}
shallow={true}>
Book a team member instead
{t("book_a_team_member")}
</Button>
</aside>
</div>
@ -98,6 +102,7 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const locale = await getOrSetUserLocaleFromHeaders(context.req);
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
const userSelect = Prisma.validator<Prisma.UserSelect>()({
@ -160,7 +165,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
localeProp: locale,
team,
...(await serverSideTranslations(locale, ["common"])),
},
};
};

View File

@ -2,7 +2,7 @@ import { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { asStringOrNull } from "@lib/asStringOrNull";
import { extractLocaleInfo } from "@lib/core/i18n/i18n.utils";
import { getOrSetUserLocaleFromHeaders } from "@lib/core/i18n/i18n.utils";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -15,7 +15,7 @@ export default function TeamType(props: AvailabilityTeamPageProps) {
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const locale = await extractLocaleInfo(context.req);
const locale = await getOrSetUserLocaleFromHeaders(context.req);
const slugParam = asStringOrNull(context.query.slug);
const typeParam = asStringOrNull(context.query.type);
const dateParam = asStringOrNull(context.query.date);

View File

@ -1,7 +1,9 @@
import { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import "react-phone-number-input/style.css";
import { asStringOrThrow } from "@lib/asStringOrNull";
import { getOrSetUserLocaleFromHeaders } from "@lib/core/i18n/i18n.utils";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -14,6 +16,7 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const locale = await getOrSetUserLocaleFromHeaders(context.req);
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
return {
@ -86,6 +89,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
localeProp: locale,
profile: {
...eventTypeObject.team,
slug: "team/" + eventTypeObject.slug,
@ -94,6 +98,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
eventType: eventTypeObject,
booking,
...(await serverSideTranslations(locale, ["common"])),
},
};
}

View File

@ -0,0 +1,147 @@
{
"edit_logo": "Logo bearbeiten",
"upload_a_logo": "Logo hochladen",
"enable": "Aktivieren",
"code": "Code",
"code_is_incorrect": "Code ist falsch.",
"add_an_extra_layer_of_security": "Fügen Sie Ihrem Konto eine zusätzliche Sicherheitsstufe hinzu, falls Ihr Passwort gestohlen wird.",
"2fa": "Zwei-Faktor-Authentifizierung",
"enable_2fa": "Zwei-Faktor-Authentifizierung aktivieren",
"disable_2fa": "Zwei-Faktor-Authentifizierung deaktivieren",
"disable_2fa_recommendation": "Falls Sie 2FA deaktivieren müssen, empfehlen wir, es so schnell wie möglich wieder zu aktivieren.",
"error_disabling_2fa": "Fehler beim Deaktivieren der Zwei-Faktor-Authentifizierung",
"error_enabling_2fa": "Fehler beim Einrichten der Zwei-Faktor-Authentifizierung",
"security": "Sicherheit",
"manage_account_security": "Verwalten Sie die Sicherheit Ihres Kontos.",
"password": "Passwort",
"password_updated_successfully": "Passwort erfolgreich aktualisiert",
"password_has_been_changed": "Ihr Passwort wurde erfolgreich geändert.",
"error_changing_password": "Fehler beim Ändern des Passworts",
"something_went_wrong": "Etwas ist schief gelaufen",
"please_try_again": "Bitte erneut versuchen",
"super_secure_new_password": "Ihr supersicheres neues Passwort",
"new_password": "Neues Passwort",
"your_old_password": "Ihr altes Passwort",
"current_password": "Aktuelles Passwort",
"change_password": "Passwort ändern",
"new_password_matches_old_password": "Neues Passwort stimmt mit Ihrem alten Passwort überein. Bitte wählen Sie ein anderes Passwort.",
"current_incorrect_password": "Aktuelles Passwort ist falsch",
"incorrect_password": "Passwort ist falsch",
"1_on_1": "1-on-1",
"24_h": "24 Std",
"use_setting": "Benutze Einstellung",
"am_pm": "am/pm",
"time_options": "Zeitoptionen",
"january": "Januar",
"february": "Februar",
"march": "März",
"april": "April",
"may": "Mai",
"june": "Juni",
"july": "Juli",
"august": "August",
"september": "September",
"october": "Oktober",
"november": "November",
"december": "Dezember",
"monday": "Montag",
"tuesday": "Dienstag",
"wednesday": "Mittwoch",
"thursday": "Donnerstag",
"friday": "Freitag",
"saturday": "Samstag",
"sunday": "Sonntag",
"all_booked_today": "Ausgebucht für heute.",
"slots_load_fail": "Die verfügbaren Zeitfenster konnten nicht geladen werden.",
"additional_guests": "+ Weitere Gäste",
"your_name": "Ihr Name",
"email_address": "E-Mail Adresse",
"location": "Ort",
"yes": "Ja",
"no": "Nein",
"additional_notes": "Zusätzliche Notizen",
"booking_fail": "Termin konnte nicht gebucht werden.",
"reschedule_fail": "Termin konnte nicht neugeplant werden.",
"share_additional_notes": "Bitten teilen Sie Notizen zur Vorbereitung des Termins, falls nötig.",
"booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}",
"booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um",
"in_person_meeting": "Link oder Vor-Ort-Termin",
"phone_call": "Telefonat",
"phone_number": "Telefonnummer",
"enter_phone_number": "Telefonnummer eingeben",
"reschedule": "Neuplanen",
"book_a_team_member": "Teammitglied stattdessen buchen",
"or": "ODER",
"go_back": "Zurück",
"email_or_username": "E-Mail oder Benutzername",
"send_invite_email": "Einladungs-E-Mail senden",
"role": "Rolle",
"edit_team": "Team bearbeiten",
"reject": "Ablehnen",
"accept": "Annehmen",
"leave": "Verlassen",
"profile": "Profil",
"my_team_url": "Meine Team-URL",
"team_name": "Teamname",
"your_team_name": "Ihr Teamname",
"team_updated_successfully": "Team erfolgreich aktualisiert",
"your_team_updated_successfully": "Ihr Team wurde erfolgreich aktualisiert.",
"about": "Beschreibung",
"team_description": "Ein paar Sätze über Ihr Team auf der öffentlichen Teamseite.",
"members": "Mitglieder",
"member": "Mitglied",
"owner": "Inhaber",
"new_member": "Neues Mitglied",
"invite": "Einladen",
"invite_new_member": "Ein neues Mitglied einladen",
"invite_new_team_member": "Laden Sie jemanden in Ihr Team ein.",
"disable_cal_branding": "Cal.com Werbung deaktivieren",
"disable_cal_branding_description": "Verstecken Sie Cal.com Werbung auf Ihren öffentlichen Seiten.",
"danger_zone": "Achtung",
"back": "Zurück",
"cancel": "Abbrechen",
"continue": "Weiter",
"confirm": "Bestätigen",
"disband_team": "Team auflösen",
"disband_team_confirmation_message": "Bist du sicher, dass du dieses Team auflösen möchtest? Jeder der diesen Team-Link erhalten hat, kann Sie nicht mehr buchen.",
"remove_member_confirmation_message": "Sind Sie sicher, dass Sie dieses Mitglied aus dem Team entfernen möchten?",
"confirm_disband_team": "Ja, Team auflösen",
"confirm_remove_member": "Ja, Mitglied entfernen",
"remove_member": "Mitglied entfernen",
"manage_your_team": "Team verwalten",
"submit": "Abschicken",
"delete": "Löschen",
"update": "Aktualisieren",
"save": "Speichern",
"pending": "Ausstehend",
"open_options": "Optionen öffnen",
"copy_link": "Link kopieren",
"preview": "Vorschau",
"link_copied": "Link kopiert!",
"title": "Titel",
"description": "Beschreibung",
"quick_video_meeting": "Ein schnelles Video-Meeting.",
"scheduling_type": "Termintyp",
"preview_team": "Teamvorschau",
"collective": "Kollektiv",
"collective_description": "Planen Sie Meetings, bei denen alle ausgewählten Teammitglieder verfügbar sind.",
"duration": "Dauer",
"minutes": "Minuten",
"round_robin": "Round Robin",
"round_robin_description": "Treffen zwischen mehreren Teammitgliedern durchwechseln.",
"url": "URL",
"hidden": "Versteckt",
"readonly": "Schreibgeschützt",
"plan_upgrade": "Sie müssen Ihr Paket upgraden, um mehr als einen aktiven Ereignistyp zu haben.",
"plan_upgrade_instructions": "Zum Upgrade, gehen Sie auf <a href=\"https://cal.com/upgrade\" className=\"underline\">https://cal.com/upgrade</a>",
"event_types_page_title": "Ereignistypen",
"event_types_page_subtitle": "Erstellen Sie teilbare Ereignisse, die andere Personen buchen können.",
"new_event_type_btn": "Neuer Ereignistyp",
"new_event_type_heading": "Erstellen Sie Ihren ersten Ereignistyp",
"new_event_type_description": "Mit Ereignistypen kann man verfügbare Zeiten im Kalendar freigeben, die andere Personen dann buchen können.",
"new_event_title": "Neuen Ereignistyp hinzufügen",
"new_event_subtitle": "Erstellen Sie einen Ereignistyp unter Ihrem Namen oder einem Team.",
"new_team_event": "Neuen Ereignistyp hinzufügen",
"new_event_description": "Erstellen Sie einen neuen Ereignistyp, mit dem Personen Zeiten buchen können.",
"event_type_created_successfully": "{{eventTypeTitle}} Ereignistyp erfolgreich erstellt"
}

View File

@ -1,3 +1,149 @@
{
"new-event-type-btn": "New event type"
"uh_oh": "Uh oh!",
"no_event_types_have_been_setup": "This user hasn't set up any event types yet.",
"edit_logo": "Edit logo",
"upload_a_logo": "Upload a logo",
"enable": "Enable",
"code": "Code",
"code_is_incorrect": "Code is incorrect.",
"add_an_extra_layer_of_security": "Add an extra layer of security to your account in case your password is stolen.",
"2fa": "Two-Factor Authentication",
"enable_2fa": "Enable two-factor authentication",
"disable_2fa": "Disable two-factor authentication",
"disable_2fa_recommendation": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
"error_disabling_2fa": "Error disabling two-factor authentication",
"error_enabling_2fa": "Error setting up two-factor authentication",
"security": "Security",
"manage_account_security": "Manage your account's security.",
"password": "Password",
"password_updated_successfully": "Password updated successfully",
"password_has_been_changed": "Your password has been successfully changed.",
"error_changing_password": "Error changing password",
"something_went_wrong": "Something went wrong",
"please_try_again": "Please try again",
"super_secure_new_password": "Your super secure new password",
"new_password": "New Password",
"your_old_password": "Your old password",
"current_password": "Current Password",
"change_password": "Change Password",
"new_password_matches_old_password": "New password matches your old password. Please choose a different password.",
"current_incorrect_password": "Current password is incorrect",
"incorrect_password": "Password is incorrect",
"1_on_1": "1-on-1",
"24_h": "24h",
"use_setting": "Use setting",
"am_pm": "am/pm",
"time_options": "Time options",
"january": "January",
"february": "February",
"march": "March",
"april": "April",
"may": "May",
"june": "June",
"july": "July",
"august": "August",
"september": "September",
"october": "October",
"november": "November",
"december": "December",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"all_booked_today": "All booked today.",
"slots_load_fail": "Could not load the available time slots.",
"additional_guests": "+ Additional Guests",
"your_name": "Your name",
"email_address": "Email address",
"location": "Location",
"yes": "yes",
"no": "no",
"additional_notes": "Additional notes",
"booking_fail": "Could not book the meeting.",
"reschedule_fail": "Could not reschedule the meeting.",
"share_additional_notes": "Please share anything that will help prepare for our meeting.",
"booking_confirmation": "Confirm your {{eventTypeTitle}} with {{profileName}}",
"booking_reschedule_confirmation": "Reschedule your {{eventTypeTitle}} with {{profileName}}",
"in_person_meeting": "Link or In-person meeting",
"phone_call": "Phone call",
"phone_number": "Phone Number",
"enter_phone_number": "Enter phone number",
"reschedule": "Reschedule",
"book_a_team_member": "Book a team member instead",
"or": "OR",
"go_back": "Go back",
"email_or_username": "Email or Username",
"send_invite_email": "Send an invite email",
"role": "Role",
"edit_team": "Edit team",
"reject": "Reject",
"accept": "Accept",
"leave": "Leave",
"profile": "Profile",
"my_team_url": "My team URL",
"team_name": "Team name",
"your_team_name": "Your team name",
"team_updated_successfully": "Team updated successfully",
"your_team_updated_successfully": "Your team has been updated successfully.",
"about": "About",
"team_description": "A few sentences about your team. This will appear on your team&apos;s URL page.",
"members": "Members",
"member": "Member",
"owner": "Owner",
"new_member": "New Member",
"invite": "Invite",
"invite_new_member": "Invite a new member",
"invite_new_team_member": "Invite someone to your team.",
"disable_cal_branding": "Disable Cal.com branding",
"disable_cal_branding_description": "Hide all Cal.com branding from your public pages.",
"danger_zone": "Danger Zone",
"back": "Back",
"cancel": "Cancel",
"continue": "Continue",
"confirm": "Confirm",
"disband_team": "Disband Team",
"disband_team_confirmation_message": "Are you sure you want to disband this team? Anyone who you&apos;ve shared this team link with will no longer be able to book using it.",
"remove_member_confirmation_message": "Are you sure you want to remove this member from the team?",
"confirm_disband_team": "Yes, disband team",
"confirm_remove_member": "Yes, remove member",
"remove_member": "Remove member",
"manage_your_team": "Manage your team",
"submit": "Submit",
"delete": "Delete",
"update": "Update",
"save": "Save",
"pending": "Pending",
"open_options": "Open options",
"copy_link": "Copy link to event",
"preview": "Preview",
"link_copied": "Link copied!",
"title": "Title",
"description": "Description",
"quick_video_meeting": "A quick video meeting.",
"scheduling_type": "Scheduling Type",
"preview_team": "Preview team",
"collective": "Collective",
"collective_description": "Schedule meetings when all selected team members are available.",
"duration": "Duration",
"minutes": "minutes",
"round_robin": "Round Robin",
"round_robin_description": "Cycle meetings between multiple team members.",
"url": "URL",
"hidden": "Hidden",
"readonly": "Readonly",
"plan_upgrade": "You need to upgrade your plan to have more than one active event type.",
"plan_upgrade_instructions": "To upgrade, go to <a href=\"https://cal.com/upgrade\" className=\"underline\">https://cal.com/upgrade</a>",
"event_types_page_title": "Event Types",
"event_types_page_subtitle": "Create events to share for people to book on your calendar.",
"new_event_type_btn": "New event type",
"new_event_type_heading": "Create your first event type",
"new_event_type_description": "Event types enable you to share links that show available times on your calendar and allow people to make bookings with you.",
"new_event_title": "Add a new event type",
"new_event_subtitle": "Create an event type under your name or a team.",
"new_team_event": "Add a new team event type",
"new_event_description": "Create a new event type for people to book times with.",
"event_type_created_successfully": "{{eventTypeTitle}} event type created successfully"
}

View File

@ -0,0 +1,147 @@
{
"edit_logo": "Cambiar la marca",
"upload_a_logo": "Subir una marca",
"enable": "Habilitar",
"code": "Código",
"code_is_incorrect": "El código es incorrecto.",
"add_an_extra_layer_of_security": "Agregue una capa adicional de seguridad a su cuenta en caso de que le roben su contraseña.",
"2fa": "Autorización de dos factores",
"enable_2fa": "Habilitar la autenticación de dos factores",
"disable_2fa": "Deshabilitar la autenticación de dos factores",
"disable_2fa_recommendation": "Si necesita deshabilitar 2FA, le recomendamos que lo vuelva a habilitar lo antes posible.",
"error_disabling_2fa": "Error al deshabilitar la autenticación de dos factores",
"error_enabling_2fa": "Error al configurar la autenticación de dos factores",
"security": "Seguridad",
"manage_account_security": "Administra la seguridad de tu cuenta.",
"password": "Contraseña",
"password_updated_successfully": "Contraseña actualizada con éxito",
"password_has_been_changed": "Su contraseña se ha cambiado correctamente.",
"error_changing_password": "Error al cambiar la contraseña",
"something_went_wrong": "Algo ha fallado",
"please_try_again": "Por favor, inténtalo de nuevo",
"super_secure_new_password": "Su nueva contraseña super segura",
"new_password": "Nueva contraseña",
"your_old_password": "Su contraseña antigua",
"current_password": "Contraseña actual",
"change_password": "Cambiar Contraseña",
"new_password_matches_old_password": "La nueva contraseña coincide con su contraseña antigua. Por favor, elija una contraseña diferente.",
"current_incorrect_password": "La contraseña actual es incorrecta",
"incorrect_password": "La contraseña es incorrecta",
"1_on_1": "1 a 1",
"24_h": "24hs",
"use_setting": "Usar configuración",
"am_pm": "am/pm",
"time_options": "Opciones de tiempo",
"january": "Enero",
"february": "Febrero",
"march": "Marzo",
"april": "Abril",
"may": "Mayo",
"june": "Junio",
"july": "Julio",
"august": "Agosto",
"september": "Septiembre",
"october": "Octubre",
"november": "Noviembre",
"december": "Diciembre",
"monday": "Lunes",
"tuesday": "Martes",
"wednesday": "Miércoles",
"thursday": "Jueves",
"friday": "Viernes",
"saturday": "Sábado",
"sunday": "Domingo",
"all_booked_today": "Todo reservado hoy.",
"slots_load_fail": "No se pudo cargar el intervalo de tiempo disponible.",
"additional_guests": "+ Invitados adicionales",
"your_name": "Tu nombre",
"email_address": "Correo electrónico",
"location": "Ubicación",
"yes": "sí",
"no": "no",
"additional_notes": "Notas adicionales",
"booking_fail": "No se pudo reservar la reunión.",
"reschedule_fail": "No se pudo cambiar la reunión.",
"share_additional_notes": "Por favor comparta cualquier cosa que nos ayude preparar para esta reunión.",
"booking_confirmation": "Confirma tu {{eventTypeTitle}} con {{profileName}}",
"booking_reschedule_confirmation": "Cambia tu {{eventTypeTitle}} con {{profileName}}",
"in_person_meeting": "Reunión en línea o en persona",
"phone_call": "Llamada telefónica",
"phone_number": "Número telefónico",
"enter_phone_number": "Entra un número de teléfono",
"reschedule": "Cambiar",
"book_a_team_member": "Reservar un miembro del equipo en su lugar",
"or": "O",
"go_back": "Volver",
"email_or_username": "Correo electrónico o nombre de usuario",
"send_invite_email": "Enviar una invitación electrónica",
"role": "Título",
"edit_team": "Editar equipo",
"reject": "Rechazar",
"accept": "Aceptar",
"leave": "Salir",
"profile": "Perfil",
"my_team_url": "URL de mi equipo",
"team_name": "Nombre del equipo",
"your_team_name": "Nombre de tu equipo",
"team_updated_successfully": "Equipo actualizado correctamente",
"your_team_updated_successfully": "Tu equipo se ha actualizado correctamente.",
"about": "Acerca de",
"team_description": "Algunas frases sobre tu equipo. Esto aparecerá en la página de tu equipo.",
"members": "Miembros",
"member": "Miembro",
"owner": "Propietario",
"new_member": "Nuevo miembro",
"invite": "Invitar",
"invite_new_member": "Invita a un nuevo miembro",
"invite_new_team_member": "Invita a alguien a tu equipo.",
"disable_cal_branding": "Desactivar marca de Cal.com",
"disable_cal_branding_description": "Ocultar todas las marcas de Cal.com de sus páginas públicas.",
"danger_zone": "Zona peligrosa",
"back": "Atrás",
"cancel": "Cancelar",
"continue": "Continuar",
"confirm": "Confirmar",
"disband_team": "Disolver Equipo",
"disband_team_confirmation_message": "¿Estás seguro de que quieres disolver este equipo? Cualquiera con quien has compartido este enlace de equipo ya no podrá reservar usando el mismo.",
"remove_member_confirmation_message": "¿Estás seguro de que quieres eliminar este miembro del equipo?",
"confirm_disband_team": "Sí, disolver equipo",
"confirm_remove_member": "Sí, eliminar miembro",
"remove_member": "Eliminar miembro",
"manage_your_team": "Administra tu equipo",
"submit": "Enviar",
"delete": "Eliminar",
"update": "Actualizar",
"save": "Guardar",
"pending": "Pendiente",
"open_options": "Abrir opciones",
"copy_link": "Copiar enlace al evento",
"preview": "Vista previa",
"link_copied": "¡Enlace copiado!",
"title": "Título",
"description": "Descripción",
"quick_video_meeting": "Una reunión de vídeo rápida.",
"scheduling_type": "Tipo de programación",
"preview_team": "Vista previa del equipo",
"collective": "Colectivo",
"collective_description": "Programe reuniones cuando todos los miembros del equipo seleccionados estén disponibles.",
"duration": "Duración",
"minutes": "minutos",
"round_robin": "Petición firmada por turnos",
"round_robin_description": "Ciclo de reuniones entre varios miembros del equipo.",
"url": "URL",
"hidden": "Oculto",
"readonly": "Sólo lectura",
"plan_upgrade": "Necesitas actualizar tu plan para tener más de un tipo de evento activo.",
"plan_upgrade_instructions": "Para actualizar, dirígete a <a href=\"https://cal.com/upgrade\" className=\"underline\">https://cal.com/upgrade</a>",
"event_types_page_title": "Tipos de Evento",
"event_types_page_subtitle": "Crea eventos para que la gente que invites reserve en tu calendario.",
"new_event_type_btn": "Nuevo tipo de evento",
"new_event_type_heading": "Crea tu primer tipo de evento",
"new_event_type_description": "Los tipos de eventos te permiten compartir enlaces que muestran las horas disponibles en tu calendario y permitir que la gente haga reservas contigo.",
"new_event_title": "Agregar un nuevo tipo de evento",
"new_event_subtitle": "Crea un tipo de evento bajo tu nombre o equipo.",
"new_team_event": "Agregar un nuevo tipo de evento de equipo",
"new_event_description": "Crea un nuevo tipo de evento con el que la gente pueda hacer reservaciónes.",
"event_type_created_successfully": "{{eventTypeTitle}} tipo de evento creado con éxito"
}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,147 @@
{
"edit_logo": "Editar Logo",
"upload_a_logo": "Carregar Logo",
"enable": "Ativar",
"code": "Código",
"code_is_incorrect": "O código está incorreto.",
"add_an_extra_layer_of_security": "Adicione uma camada extra de segurança à sua conta, caso a sua palavra-passe seja roubada.",
"2fa": "Autenticação com dois fatores",
"enable_2fa": "Ativar autenticação de dois fatores",
"disable_2fa": "Desativar autenticação de dois fatores",
"disable_2fa_recommendation": "Se precisar desativar o 2FA, recomendamos reativá-lo o mais rápido possível.",
"error_disabling_2fa": "Erro ao desativar autenticação de dois fatores",
"error_enabling_2fa": "Erro ao configurar a autenticação de dois fatores",
"security": "Segurança",
"manage_account_security": "Gerir a segurança da sua conta.",
"password": "Palavra-Passe",
"password_updated_successfully": "Palavra-Passe atualizada com sucesso",
"password_has_been_changed": "A sua palavra-passe foi alterada com sucesso.",
"error_changing_password": "Erro ao alterar a palavra-passe",
"something_went_wrong": "Ocorreu um erro",
"please_try_again": "Por favor, tente novamente",
"super_secure_new_password": "A sua nova palavra-passe é super segura",
"new_password": "Nova Palavra-Passe",
"your_old_password": "A sua palavra-passe antiga",
"current_password": "Palavra-Passe Atual",
"change_password": "Alterar Palavra-Passe",
"new_password_matches_old_password": "Nova palavra-passe é igual à palavra-passe antiga. Por favor, escolha uma palavra-passe diferente.",
"current_incorrect_password": "Palavra-Passe atual está incorreta",
"incorrect_password": "Palavra-Passe incorreta",
"1_on_1": "1 para 1",
"24_h": "24h",
"use_setting": "Usar Configuração",
"am_pm": "am/pm",
"time_options": "Opções de Hora",
"january": "Janeiro",
"february": "Fevereiro",
"march": "Março",
"april": "Abril",
"may": "Maio",
"june": "Junho",
"july": "Julho",
"august": "Agosto",
"september": "Setembro",
"october": "Outubro",
"november": "Novembro",
"december": "Dezembro",
"monday": "Segunda-Feira",
"tuesday": "Terça-Feira",
"wednesday": "Quarta-Feira",
"thursday": "Quinta-Feira",
"friday": "Sexta-Feira",
"saturday": "Sábado",
"sunday": "Domingo",
"all_booked_today": "Todo o Dia Reservado.",
"slots_load_fail": "Não foi possível carregar os horários disponíveis.",
"additional_guests": "+ Convidados Adicionais",
"your_name": "Seu Nome",
"email_address": "Endereço de E-mail",
"location": "Localização",
"yes": "Sim",
"no": "Não",
"additional_notes": "Notas Adicionais",
"booking_fail": "Não foi possível agendar a reunião.",
"reschedule_fail": "Não foi possível re-agendar a reunião.",
"share_additional_notes": "Por favor, partilhe qualquer informação para preparar a nossa reunião.",
"booking_confirmation": "Confirme o seu {{eventTypeTitle}} com {{profileName}}",
"booking_reschedule_confirmation": "Reagende o seu {{eventTypeTitle}} com {{profileName}}",
"in_person_meeting": "Link ou Reunião Presencial",
"phone_call": "Chamada Telefónica",
"phone_number": "Número de Telefone",
"enter_phone_number": "Inserir Número do Telefone",
"reschedule": "Reagendar",
"book_a_team_member": "Reserve um Membro da sua Equipa no seu lugar",
"or": "Ou",
"go_back": "Voltar atrás",
"email_or_username": "E-mail ou Nome de Utilizador",
"send_invite_email": "Enviar um e-mail de convite",
"role": "Função",
"edit_team": "Editar Equipa",
"reject": "Rejeitar",
"accept": "Aceitar",
"leave": "Ausente",
"profile": "Perfil",
"my_team_url": "URL da Minha Equipa",
"team_name": "Nome da Equipa",
"your_team_name": "Nome da sua equipa",
"team_updated_successfully": "Equipa atualizada com sucesso",
"your_team_updated_successfully": "A sua equipa foi atualizada com sucesso.",
"about": "Sobre",
"team_description": "Algumas frases sobre a sua equipa. Isso aparecerá na sua equipa&apos;URL .",
"members": "Membros",
"member": "Membro",
"owner": "Proprietário",
"new_member": "Novo Membro",
"invite": "Convidar",
"invite_new_member": "Convidar um Novo Membro",
"invite_new_team_member": "Convide alguém para a sua equipa.",
"disable_cal_branding": "Desativar a marca Cal.com",
"disable_cal_branding_description": "Ocultar todas as marcas de Cal.com das suas páginas públicas.",
"danger_zone": "Zona de Perigo",
"back": "Anterior",
"cancel": "Cancelar",
"continue": "Continuar",
"confirm": "Confirmar",
"disband_team": "Dissolver Equipa",
"disband_team_confirmation_message": "Tem a certeza de que deseja dissolver esta equipa? Qualquer pessoa com quem&apos;ve partilhou este link de equipa não conseguirá fazer uma reserva usando-o.",
"remove_member_confirmation_message": "Tem a certeza de que deseja remover este membro da equipa?",
"confirm_disband_team": "Sim, dissolver equipa",
"confirm_remove_member": "Sim, remover membro",
"remove_member": "Remover Membro",
"manage_your_team": "Gerir a sua equipa",
"submit": "Enviar",
"delete": "Apagar",
"update": "Atualizar",
"save": "Guardar",
"pending": "Pendente",
"open_options": "Abrir Opções",
"copy_link": "Copiar link do evento",
"preview": "Pré-Visualizar",
"link_copied": "Link copiado!",
"title": "Título",
"description": "Descrição",
"quick_video_meeting": "Uma breve reunião em vídeo.",
"scheduling_type": "Tipo do Agendamento",
"preview_team": "Pré-visualizar Equipa",
"collective": "Coletivo",
"collective_description": "Agende reuniões quando todos os membros selecionados da equipa estiverem disponíveis.",
"duration": "Duração",
"minutes": "Minutos",
"round_robin": "Round Robin",
"round_robin_description": "Reuniões de ciclo entre vários membros da equipa.",
"url": "URL",
"hidden": "Oculto",
"readonly": "Somente Leitura",
"plan_upgrade": "Precisa atualizar o seu plano para ter mais de um tipo de evento ativo.",
"plan_upgrade_instructions": "Para fazer a atualização, aceda <a href=\"https://cal.com/upgrade\" className=\"underline\">https://cal.com/upgrade</a>",
"event_types_page_title": "Tipo de Eventos",
"event_types_page_subtitle": "Crie eventos para partilhar, para que as pessoas façam reservas no seu calendário.",
"new_event_type_btn": "Novo tipo de evento",
"new_event_type_heading": "Crie o seu primeiro tipo de evento",
"new_event_type_description": "Os tipos de evento permitem partilhar links que mostram os horários disponíveis na sua agenda e permitem que as pessoas façam reservas consigo.",
"new_event_title": "Adicionar um novo tipo de evento",
"new_event_subtitle": "Crie um tipo de evento sob o seu nome ou equipa.",
"new_team_event": "Adicionar um novo tipo de evento de equipa",
"new_event_description": "Crie um novo tipo de evento para as pessoas reservarem uma hora.",
"event_type_created_successfully": "{{eventTypeTitle}} tipo de evento criado com sucesso"
}

View File

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

View File

@ -0,0 +1,147 @@
{
"edit_logo": "Изменить логотип",
"upload_a_logo": "Загрузить логотип",
"enable": "Включить",
"code": "Код",
"code_is_incorrect": "Неверный код.",
"add_an_extra_layer_of_security": "Добавьте дополнительный уровень безопасности в свою учетную запись на случай кражи пароля.",
"2fa": "Двухфакторная авторизация",
"enable_2fa": "Включить двухфакторную авторизацию",
"disable_2fa": "Отключить двухфакторную авторизацию",
"disable_2fa_recommendation": "Если вам нужно отключить двухфакторную авторизацию, мы рекомендуем включить её как можно скорее.",
"error_disabling_2fa": "Ошибка отключения двухфакторной авторизации",
"error_enabling_2fa": "Ошибка настройки двухфакторной авторизации",
"security": "Безопасность",
"manage_account_security": "Управление безопасностью вашего аккаунта.",
"password": "Пароль",
"password_updated_successfully": "Пароль успешно обновлен",
"password_has_been_changed": "Ваш пароль был успешно изменен.",
"error_changing_password": "Ошибка при изменении пароля",
"something_went_wrong": "Что-то пошло не так",
"please_try_again": "Пожалуйста, попробуйте еще раз",
"super_secure_new_password": "Ваш супербезопасный новый пароль",
"new_password": "Новый пароль",
"your_old_password": "Ваш старый пароль",
"current_password": "Текущий пароль",
"change_password": "Изменить пароль",
"new_password_matches_old_password": "Новый пароль совпадает с вашим старым паролем. Пожалуйста, выберите другой пароль.",
"current_incorrect_password": "Неверный текущий пароль",
"incorrect_password": "Неверный пароль",
"1_on_1": "1-на-1",
"24_h": "24 часа",
"use_setting": "Использовать настройки",
"am_pm": "am/pm",
"time_options": "Настройки времени",
"january": "Январь",
"february": "Февраль",
"march": "Март",
"april": "Апрель",
"may": "Май",
"june": "Июнь",
"july": "Июль",
"august": "Август",
"september": "Сентябрь",
"october": "Октябрь",
"november": "Ноябрь",
"december": "Декабрь",
"monday": "Понедельник",
"tuesday": "Вторник",
"wednesday": "Среда",
"thursday": "Четверг",
"friday": "Пятница",
"saturday": "Суббота",
"sunday": "Воскресенье",
"all_booked_today": "Сегодня всё забронировано.",
"slots_load_fail": "Не удалось загрузить доступные временные интервалы.",
"additional_guests": "+ Дополнительные гости",
"your_name": "Ваше имя",
"email_address": "Адрес электронной почты",
"location": "Местоположение",
"yes": "да",
"no": "нет",
"additional_notes": "Дополнительная информация",
"booking_fail": "Не удалось забронировать встречу.",
"reschedule_fail": "Не удалось перенести встречу.",
"share_additional_notes": "Дополнительная информация, которая может помочь подготовиться к нашей встрече.",
"booking_confirmation": "Подтвердите вашу встречу «{{eventTypeTitle}}» с {{profileName}}",
"booking_reschedule_confirmation": "Перенесите вашу встречу «{{eventTypeTitle}}» с {{profileName}}",
"in_person_meeting": "Ссылка или личная встреча",
"phone_call": "Телефонный звонок",
"phone_number": "Номер телефона",
"enter_phone_number": "Введите номер телефона",
"reschedule": "Перенести",
"book_a_team_member": "Забронировать встречу с одним из членов команды",
"or": "ИЛИ",
"go_back": "Вернуться",
"email_or_username": "Email или имя пользователя",
"send_invite_email": "Отправить приглашение по электронной почте",
"role": "Роль",
"edit_team": "Редактировать команду",
"reject": "Отклонить",
"accept": "Принять",
"leave": "Покинуть",
"profile": "Профиль",
"my_team_url": "URL-адрес моей команды",
"team_name": "Название команды",
"your_team_name": "Название вашей команды",
"team_updated_successfully": "Команда успешно обновлена",
"your_team_updated_successfully": "Ваша команда успешно обновлена.",
"about": "О нас",
"team_description": "Несколько предложений о вашей команде. Это появится на странице вашей команды.",
"members": "Участники",
"member": "Участник",
"owner": "Владелец",
"new_member": "Новый участник",
"invite": "Пригласить",
"invite_new_member": "Пригласить нового участника",
"invite_new_team_member": "Пригласите кого-нибудь в вашу команду.",
"disable_cal_branding": "Отключить брендинг Cal.com",
"disable_cal_branding_description": "Скрыть весь брендинг Cal.com с ваших публичных страниц.",
"danger_zone": "Опасная зона",
"back": "Назад",
"cancel": "Отмена",
"continue": "Продолжить",
"confirm": "Подтвердить",
"disband_team": "Распустить команду",
"disband_team_confirmation_message": "Вы уверены, что хотите распустить эту команду? Любой, с кем вы поделились ссылкой на эту команду, больше не сможет забронировать её.",
"remove_member_confirmation_message": "Вы уверены, что хотите удалить этого участника из команды?",
"confirm_disband_team": "Да, распустить команду",
"confirm_remove_member": "Да, удалить участника",
"remove_member": "Удалить участника",
"manage_your_team": "Управление вашей командой",
"submit": "Отправить",
"delete": "Удалить",
"update": "Обновить",
"save": "Сохранить",
"pending": "В ожидании",
"open_options": "Открыть настройки",
"copy_link": "Скопировать ссылку на событие",
"preview": "Предпросмотр",
"link_copied": "Ссылка скопирована!",
"title": "Заголовок",
"description": "Описание",
"quick_video_meeting": "Быстрая видео-встреча.",
"scheduling_type": "Тип расписания",
"preview_team": "Предпросмотр команды",
"collective": "Коллективная встреча",
"collective_description": "Расписание встреч, когда доступны все выбранные члены команды.",
"duration": "Продолжительность",
"minutes": "мин.",
"round_robin": "По кругу",
"round_robin_description": "Цикл встреч между несколькими членами команды.",
"url": "URL",
"hidden": "Скрытый",
"readonly": "Только для чтения",
"plan_upgrade": "Необходимо обновить тарифный план, чтобы иметь более одного активного типа события.",
"plan_upgrade_instructions": "Для повышения перейдите на <a href=\"https://cal.com/upgrade\" className=\"underline\">https://cal.com/upgrade</a>",
"event_types_page_title": "Типы мероприятий",
"event_types_page_subtitle": "Создайте мероприятие, чтобы поделиться с людьми для бронирования в вашем календаре.",
"new_event_type_btn": "Новый тип мероприятия",
"new_event_type_heading": "Создайте свой первый тип мероприятия",
"new_event_type_description": "Типы мероприятий позволяют делиться ссылками, которые показывают время в вашем календаре и позволяют людям бронировать встречи с вами.",
"new_event_title": "Добавить новый тип события",
"new_event_subtitle": "Создайте тип события для себя или команды.",
"new_team_event": "Добавить новый тип события команды",
"new_event_description": "Создайте новый тип мероприятия, с помощью которого люди смогут забронировать время.",
"event_type_created_successfully": "{{eventTypeTitle}} тип мероприятия успешно создан"
}

View File

@ -4,7 +4,6 @@
import superjson from "superjson";
import { createRouter } from "../createRouter";
import { bookingRouter } from "./booking";
import { viewerRouter } from "./viewer";
/**
@ -24,7 +23,6 @@ export const appRouter = createRouter()
* @link https://trpc.io/docs/error-formatting
*/
// .formatError(({ shape, error }) => { })
.merge("viewer.", viewerRouter)
.merge("booking.", bookingRouter);
.merge("viewer.", viewerRouter);
export type AppRouter = typeof appRouter;

View File

@ -1,73 +0,0 @@
import { z } from "zod";
import { createRouter } from "../createRouter";
export const bookingRouter = createRouter().query("userEventTypes", {
input: z.object({
username: z.string().min(1),
}),
async resolve({ input, ctx }) {
const { prisma } = ctx;
const { username } = input;
const user = await prisma.user.findUnique({
where: {
username: username.toLowerCase(),
},
select: {
id: true,
username: true,
email: true,
name: true,
bio: true,
avatar: true,
theme: true,
plan: true,
},
});
if (!user) {
return null;
}
const eventTypesWithHidden = await prisma.eventType.findMany({
where: {
AND: [
{
teamId: null,
},
{
OR: [
{
userId: user.id,
},
{
users: {
some: {
id: user.id,
},
},
},
],
},
],
},
select: {
id: true,
slug: true,
title: true,
length: true,
description: true,
hidden: true,
schedulingType: true,
price: true,
currency: true,
},
take: user.plan === "FREE" ? 1 : undefined,
});
const eventTypes = eventTypesWithHidden.filter((evt) => !evt.hidden);
return {
user,
eventTypes,
};
},
});

3041
yarn.lock

File diff suppressed because it is too large Load Diff