chore: migrate the event-types page to the app directory (#12390)
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
parent
53d7e2ec3f
commit
1de05ebf3d
|
@ -283,3 +283,9 @@ E2E_TEST_OIDC_USER_EMAIL=
|
||||||
E2E_TEST_OIDC_USER_PASSWORD=
|
E2E_TEST_OIDC_USER_PASSWORD=
|
||||||
|
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
|
|
||||||
|
# provide a value between 0 and 100 to ensure the percentage of traffic
|
||||||
|
# redirected from the legacy to the future pages
|
||||||
|
AB_TEST_BUCKET_PROBABILITY=50
|
||||||
|
# whether we redirect to the future/event-types from event-types or not
|
||||||
|
APP_ROUTER_EVENT_TYPES_ENABLED=1
|
||||||
|
|
|
@ -1,16 +1,21 @@
|
||||||
import { getBucket } from "abTest/utils";
|
import { getBucket } from "abTest/utils";
|
||||||
import type { NextMiddleware, NextRequest } from "next/server";
|
import type { NextMiddleware, NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse, URLPattern } from "next/server";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
const ROUTES: [RegExp, boolean][] = [
|
const ROUTES: [URLPattern, boolean][] = [
|
||||||
[/^\/event-types$/, Boolean(process.env.APP_ROUTER_EVENT_TYPES_ENABLED)],
|
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
|
||||||
];
|
].map(([pathname, enabled]) => [
|
||||||
|
new URLPattern({
|
||||||
|
pathname,
|
||||||
|
}),
|
||||||
|
enabled,
|
||||||
|
]);
|
||||||
|
|
||||||
const FUTURE_ROUTES_OVERRIDE_COOKIE_NAME = "x-calcom-future-routes-override";
|
const FUTURE_ROUTES_OVERRIDE_COOKIE_NAME = "x-calcom-future-routes-override";
|
||||||
const FUTURE_ROUTES_ENABLED_COOKIE_NAME = "x-calcom-future-routes-enabled";
|
const FUTURE_ROUTES_ENABLED_COOKIE_NAME = "x-calcom-future-routes-enabled";
|
||||||
|
|
||||||
const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]).default("legacy");
|
const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]);
|
||||||
|
|
||||||
export const abTestMiddlewareFactory =
|
export const abTestMiddlewareFactory =
|
||||||
(next: (req: NextRequest) => Promise<NextResponse<unknown>>): NextMiddleware =>
|
(next: (req: NextRequest) => Promise<NextResponse<unknown>>): NextMiddleware =>
|
||||||
|
@ -21,7 +26,7 @@ export const abTestMiddlewareFactory =
|
||||||
|
|
||||||
const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME);
|
const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME);
|
||||||
|
|
||||||
const route = ROUTES.find(([regExp]) => regExp.test(pathname)) ?? null;
|
const route = ROUTES.find(([regExp]) => regExp.test(req.url)) ?? null;
|
||||||
|
|
||||||
const enabled = route !== null ? route[1] || override : false;
|
const enabled = route !== null ? route[1] || override : false;
|
||||||
|
|
||||||
|
@ -35,16 +40,29 @@ export const abTestMiddlewareFactory =
|
||||||
|
|
||||||
if (!safeParsedBucket.success) {
|
if (!safeParsedBucket.success) {
|
||||||
// cookie does not exist or it has incorrect value
|
// cookie does not exist or it has incorrect value
|
||||||
|
const bucket = getBucket();
|
||||||
|
|
||||||
const res = NextResponse.next(response);
|
response.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, bucket, {
|
||||||
res.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, getBucket(), { expires: 1000 * 60 * 30 }); // 30 min in ms
|
expires: Date.now() + 1000 * 60 * 30,
|
||||||
return res;
|
httpOnly: true,
|
||||||
|
}); // 30 min in ms
|
||||||
|
|
||||||
|
if (bucket === "legacy") {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = `future${pathname}/`;
|
||||||
|
|
||||||
|
return NextResponse.rewrite(url, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bucketUrlPrefix = safeParsedBucket.data === "future" ? "future" : "";
|
if (safeParsedBucket.data === "legacy") {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const url = req.nextUrl.clone();
|
const url = req.nextUrl.clone();
|
||||||
url.pathname = `${bucketUrlPrefix}${pathname}/`;
|
url.pathname = `future${pathname}/`;
|
||||||
|
|
||||||
return NextResponse.rewrite(url, response);
|
return NextResponse.rewrite(url, response);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createHydrateClient } from "app/_trpc/createHydrateClient";
|
||||||
|
import superjson from "superjson";
|
||||||
|
|
||||||
|
export const HydrateClient = createHydrateClient({
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||||
|
|
||||||
|
import { createTRPCReact } from "@trpc/react-query";
|
||||||
|
|
||||||
|
export const trpc = createTRPCReact<AppRouter>({});
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type DehydratedState, Hydrate } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { DataTransformer } from "@trpc/server";
|
||||||
|
|
||||||
|
export function createHydrateClient(opts: { transformer?: DataTransformer }) {
|
||||||
|
return function HydrateClient(props: { children: React.ReactNode; state: DehydratedState }) {
|
||||||
|
const { state, children } = props;
|
||||||
|
|
||||||
|
const transformedState: DehydratedState = useMemo(() => {
|
||||||
|
if (opts.transformer) {
|
||||||
|
return opts.transformer.deserialize(state);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return <Hydrate state={transformedState}>{children}</Hydrate>;
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { type DehydratedState, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { HydrateClient } from "app/_trpc/HydrateClient";
|
||||||
|
import { trpc } from "app/_trpc/client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import superjson from "superjson";
|
||||||
|
|
||||||
|
import { httpBatchLink } from "@calcom/trpc/client/links/httpBatchLink";
|
||||||
|
import { httpLink } from "@calcom/trpc/client/links/httpLink";
|
||||||
|
import { loggerLink } from "@calcom/trpc/client/links/loggerLink";
|
||||||
|
import { splitLink } from "@calcom/trpc/client/links/splitLink";
|
||||||
|
|
||||||
|
const ENDPOINTS = [
|
||||||
|
"admin",
|
||||||
|
"apiKeys",
|
||||||
|
"appRoutingForms",
|
||||||
|
"apps",
|
||||||
|
"auth",
|
||||||
|
"availability",
|
||||||
|
"appBasecamp3",
|
||||||
|
"bookings",
|
||||||
|
"deploymentSetup",
|
||||||
|
"eventTypes",
|
||||||
|
"features",
|
||||||
|
"insights",
|
||||||
|
"payments",
|
||||||
|
"public",
|
||||||
|
"saml",
|
||||||
|
"slots",
|
||||||
|
"teams",
|
||||||
|
"organizations",
|
||||||
|
"users",
|
||||||
|
"viewer",
|
||||||
|
"webhook",
|
||||||
|
"workflows",
|
||||||
|
"appsRouter",
|
||||||
|
"googleWorkspace",
|
||||||
|
] as const;
|
||||||
|
export type Endpoint = (typeof ENDPOINTS)[number];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const resolveEndpoint = (links: any) => {
|
||||||
|
// TODO: Update our trpc routes so they are more clear.
|
||||||
|
// This function parses paths like the following and maps them
|
||||||
|
// to the correct API endpoints.
|
||||||
|
// - viewer.me - 2 segment paths like this are for logged in requests
|
||||||
|
// - viewer.public.i18n - 3 segments paths can be public or authed
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return (ctx: any) => {
|
||||||
|
const parts = ctx.op.path.split(".");
|
||||||
|
let endpoint;
|
||||||
|
let path = "";
|
||||||
|
if (parts.length == 2) {
|
||||||
|
endpoint = parts[0] as keyof typeof links;
|
||||||
|
path = parts[1];
|
||||||
|
} else {
|
||||||
|
endpoint = parts[1] as keyof typeof links;
|
||||||
|
path = parts.splice(2, parts.length - 2).join(".");
|
||||||
|
}
|
||||||
|
return links[endpoint]({ ...ctx, op: { ...ctx.op, path } });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TrpcProvider: React.FC<{ children: React.ReactNode; dehydratedState?: DehydratedState }> = ({
|
||||||
|
children,
|
||||||
|
dehydratedState,
|
||||||
|
}) => {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: { queries: { staleTime: 5000 } },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const url =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? "/api/trpc"
|
||||||
|
: process.env.VERCEL_URL
|
||||||
|
? `https://${process.env.VERCEL_URL}/api/trpc`
|
||||||
|
: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`;
|
||||||
|
|
||||||
|
const [trpcClient] = useState(() =>
|
||||||
|
trpc.createClient({
|
||||||
|
links: [
|
||||||
|
// adds pretty logs to your console in development and logs errors in production
|
||||||
|
loggerLink({
|
||||||
|
enabled: (opts) =>
|
||||||
|
!!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error),
|
||||||
|
}),
|
||||||
|
splitLink({
|
||||||
|
// check for context property `skipBatch`
|
||||||
|
condition: (op) => !!op.context.skipBatch,
|
||||||
|
// when condition is true, use normal request
|
||||||
|
true: (runtime) => {
|
||||||
|
const links = Object.fromEntries(
|
||||||
|
ENDPOINTS.map((endpoint) => [
|
||||||
|
endpoint,
|
||||||
|
httpLink({
|
||||||
|
url: `${url}/${endpoint}`,
|
||||||
|
})(runtime),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return resolveEndpoint(links);
|
||||||
|
},
|
||||||
|
// when condition is false, use batch request
|
||||||
|
false: (runtime) => {
|
||||||
|
const links = Object.fromEntries(
|
||||||
|
ENDPOINTS.map((endpoint) => [
|
||||||
|
endpoint,
|
||||||
|
httpBatchLink({
|
||||||
|
url: `${url}/${endpoint}`,
|
||||||
|
})(runtime),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return resolveEndpoint(links);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
transformer: superjson,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{dehydratedState ? <HydrateClient state={dehydratedState}>{children}</HydrateClient> : children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</trpc.Provider>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { type TFunction } from "i18next";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import { constructGenericImage } from "@calcom/lib/OgImages";
|
||||||
|
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG } from "@calcom/lib/constants";
|
||||||
|
import { getFixedT } from "@calcom/lib/server/getFixedT";
|
||||||
|
|
||||||
|
import { preparePageMetadata } from "@lib/metadata";
|
||||||
|
|
||||||
|
export const _generateMetadata = async (
|
||||||
|
getTitle: (t: TFunction<string, undefined>) => string,
|
||||||
|
getDescription: (t: TFunction<string, undefined>) => string
|
||||||
|
) => {
|
||||||
|
const h = headers();
|
||||||
|
const canonical = h.get("x-pathname") ?? "";
|
||||||
|
const locale = h.get("x-locale") ?? "en";
|
||||||
|
|
||||||
|
const t = await getFixedT(locale, "common");
|
||||||
|
|
||||||
|
const title = getTitle(t);
|
||||||
|
const description = getDescription(t);
|
||||||
|
|
||||||
|
const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL);
|
||||||
|
|
||||||
|
const image =
|
||||||
|
SEO_IMG_OGIMG +
|
||||||
|
constructGenericImage({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
return preparePageMetadata({
|
||||||
|
title,
|
||||||
|
canonical,
|
||||||
|
image,
|
||||||
|
description,
|
||||||
|
siteName: APP_NAME,
|
||||||
|
metadataBase,
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,64 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typescript class based component for custom-error
|
||||||
|
* @link https://nextjs.org/docs/advanced-features/custom-error-page
|
||||||
|
*/
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import type { ErrorProps } from "next/error";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { redactError } from "@calcom/lib/redactError";
|
||||||
|
|
||||||
|
import { ErrorPage } from "@components/error/error-page";
|
||||||
|
|
||||||
|
type NextError = Error & { digest?: string };
|
||||||
|
|
||||||
|
// Ref: https://nextjs.org/docs/app/api-reference/file-conventions/error#props
|
||||||
|
export type DefaultErrorProps = {
|
||||||
|
error: NextError;
|
||||||
|
reset: () => void; // A function to reset the error boundary
|
||||||
|
};
|
||||||
|
|
||||||
|
type AugmentedError = NextError | HttpError | null;
|
||||||
|
|
||||||
|
type CustomErrorProps = {
|
||||||
|
err?: AugmentedError;
|
||||||
|
statusCode?: number;
|
||||||
|
message?: string;
|
||||||
|
} & Omit<ErrorProps, "err" | "statusCode">;
|
||||||
|
|
||||||
|
const log = logger.getSubLogger({ prefix: ["[error]"] });
|
||||||
|
|
||||||
|
const CustomError: NextPage<DefaultErrorProps> = (props) => {
|
||||||
|
const { error } = props;
|
||||||
|
let errorObject: CustomErrorProps = {
|
||||||
|
message: error.message,
|
||||||
|
err: error,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
const redactedError = redactError(error);
|
||||||
|
errorObject = {
|
||||||
|
statusCode: error.statusCode,
|
||||||
|
title: redactedError.name,
|
||||||
|
message: redactedError.message,
|
||||||
|
err: {
|
||||||
|
...redactedError,
|
||||||
|
...error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `error.digest` property contains an automatically generated hash of the error that can be used to match the corresponding error in server-side logs
|
||||||
|
log.debug(`${error?.toString() ?? JSON.stringify(error)}`);
|
||||||
|
log.info("errorObject: ", errorObject);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorPage statusCode={errorObject.statusCode} error={errorObject.err} message={errorObject.message} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomError;
|
|
@ -0,0 +1,10 @@
|
||||||
|
import EventTypes from "@pages/event-types";
|
||||||
|
import { _generateMetadata } from "app/_utils";
|
||||||
|
|
||||||
|
export const generateMetadata = async () =>
|
||||||
|
await _generateMetadata(
|
||||||
|
(t) => t("event_types_page_title"),
|
||||||
|
(t) => t("event_types_page_subtitle")
|
||||||
|
);
|
||||||
|
|
||||||
|
export default EventTypes;
|
|
@ -0,0 +1,22 @@
|
||||||
|
// pages without layout (e.g., /availability/index.tsx) are supposed to go under (layout) folder
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { type ReactElement } from "react";
|
||||||
|
|
||||||
|
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||||
|
|
||||||
|
import PageWrapper from "@components/PageWrapperAppDir";
|
||||||
|
|
||||||
|
type WrapperWithLayoutProps = {
|
||||||
|
children: ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function WrapperWithLayout({ children }: WrapperWithLayoutProps) {
|
||||||
|
const h = headers();
|
||||||
|
const nonce = h.get("x-nonce") ?? undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null}>
|
||||||
|
{children}
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type NextPage } from "next";
|
||||||
|
|
||||||
|
import CustomError, { type DefaultErrorProps } from "./error";
|
||||||
|
|
||||||
|
export const GlobalError: NextPage<DefaultErrorProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<CustomError {...props} />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalError;
|
|
@ -1,84 +1,63 @@
|
||||||
import type { Metadata } from "next";
|
import { dir } from "i18next";
|
||||||
import { headers as nextHeaders, cookies as nextCookies } from "next/headers";
|
import { headers, cookies } from "next/headers";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
|
|
||||||
|
import { prepareRootMetadata } from "@lib/metadata";
|
||||||
|
|
||||||
import "../styles/globals.css";
|
import "../styles/globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const generateMetadata = () =>
|
||||||
icons: {
|
prepareRootMetadata({
|
||||||
icon: [
|
twitterCreator: "@calcom",
|
||||||
{
|
twitterSite: "@calcom",
|
||||||
sizes: "32x32",
|
robots: {
|
||||||
url: "/api/logo?type=favicon-32",
|
index: false,
|
||||||
},
|
follow: false,
|
||||||
{
|
|
||||||
sizes: "16x16",
|
|
||||||
url: "/api/logo?type=favicon-16",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
apple: {
|
|
||||||
sizes: "180x180",
|
|
||||||
url: "/api/logo?type=apple-touch-icon",
|
|
||||||
},
|
},
|
||||||
other: [
|
});
|
||||||
{
|
|
||||||
url: "/safari-pinned-tab.svg",
|
|
||||||
rel: "mask-icon",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
manifest: "/site.webmanifest",
|
|
||||||
themeColor: [
|
|
||||||
{ media: "(prefers-color-scheme: light)", color: "#f9fafb" },
|
|
||||||
{ media: "(prefers-color-scheme: dark)", color: "#1C1C1C" },
|
|
||||||
],
|
|
||||||
other: {
|
|
||||||
"msapplication-TileColor": "#000000",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInitialProps = async (
|
const getInitialProps = async (url: string) => {
|
||||||
url: string,
|
|
||||||
headers: ReturnType<typeof nextHeaders>,
|
|
||||||
cookies: ReturnType<typeof nextCookies>
|
|
||||||
) => {
|
|
||||||
const { pathname, searchParams } = new URL(url);
|
const { pathname, searchParams } = new URL(url);
|
||||||
|
|
||||||
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
|
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
|
||||||
const embedColorScheme = searchParams?.get("ui.color-scheme");
|
const embedColorScheme = searchParams?.get("ui.color-scheme");
|
||||||
|
|
||||||
// @ts-expect-error we cannot access ctx.req in app dir, however headers and cookies are only properties needed to extract the locale
|
const req = { headers: headers(), cookies: cookies() };
|
||||||
const newLocale = await getLocale({ headers, cookies });
|
const newLocale = await getLocale(req);
|
||||||
let direction = "ltr";
|
const direction = dir(newLocale);
|
||||||
|
|
||||||
try {
|
|
||||||
const intlLocale = new Intl.Locale(newLocale);
|
|
||||||
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
|
||||||
direction = intlLocale.textInfo?.direction;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isEmbed, embedColorScheme, locale: newLocale, direction };
|
return { isEmbed, embedColorScheme, locale: newLocale, direction };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFallbackProps = () => ({
|
||||||
|
locale: "en",
|
||||||
|
direction: "ltr",
|
||||||
|
isEmbed: false,
|
||||||
|
embedColorScheme: false,
|
||||||
|
});
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const headers = nextHeaders();
|
const h = headers();
|
||||||
const cookies = nextCookies();
|
|
||||||
|
|
||||||
const fullUrl = headers.get("x-url") ?? "";
|
const fullUrl = h.get("x-url") ?? "";
|
||||||
const nonce = headers.get("x-csp") ?? "";
|
const nonce = h.get("x-csp") ?? "";
|
||||||
|
|
||||||
|
const isSSG = !fullUrl;
|
||||||
|
|
||||||
|
const { locale, direction, isEmbed, embedColorScheme } = isSSG
|
||||||
|
? getFallbackProps()
|
||||||
|
: await getInitialProps(fullUrl);
|
||||||
|
|
||||||
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps(fullUrl, headers, cookies);
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang={locale}
|
lang={locale}
|
||||||
dir={direction}
|
dir={direction}
|
||||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}
|
||||||
|
data-nextjs-router="app">
|
||||||
<head nonce={nonce}>
|
<head nonce={nonce}>
|
||||||
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
|
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
|
||||||
// eslint-disable-next-line @next/next/no-sync-scripts
|
// eslint-disable-next-line @next/next/no-sync-scripts
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { type DehydratedState } from "@tanstack/react-query";
|
||||||
import type { SSRConfig } from "next-i18next";
|
import type { SSRConfig } from "next-i18next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
@ -10,7 +11,6 @@ import type { ReactNode } from "react";
|
||||||
|
|
||||||
import "@calcom/embed-core/src/embed-iframe";
|
import "@calcom/embed-core/src/embed-iframe";
|
||||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
|
|
||||||
import type { AppProps } from "@lib/app-providers-app-dir";
|
import type { AppProps } from "@lib/app-providers-app-dir";
|
||||||
import AppProviders from "@lib/app-providers-app-dir";
|
import AppProviders from "@lib/app-providers-app-dir";
|
||||||
|
@ -29,13 +29,14 @@ const calFont = localFont({
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PageWrapperProps = Readonly<{
|
export type PageWrapperProps = Readonly<{
|
||||||
getLayout: (page: React.ReactElement) => ReactNode;
|
getLayout: ((page: React.ReactElement) => ReactNode) | null;
|
||||||
children: React.ReactElement;
|
children: React.ReactElement;
|
||||||
requiresLicense: boolean;
|
requiresLicense: boolean;
|
||||||
isThemeSupported: boolean;
|
|
||||||
isBookingPage: boolean;
|
|
||||||
nonce: string | undefined;
|
nonce: string | undefined;
|
||||||
themeBasis: string | null;
|
themeBasis: string | null;
|
||||||
|
dehydratedState?: DehydratedState;
|
||||||
|
isThemeSupported?: boolean;
|
||||||
|
isBookingPage?: boolean;
|
||||||
i18n?: SSRConfig;
|
i18n?: SSRConfig;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
@ -85,4 +86,4 @@ function PageWrapper(props: PageWrapperProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default trpc.withTRPC(PageWrapper);
|
export default PageWrapper;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||||
|
import { TrpcProvider } from "app/_trpc/trpc-provider";
|
||||||
import { dir } from "i18next";
|
import { dir } from "i18next";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { SessionProvider, useSession } from "next-auth/react";
|
import { SessionProvider, useSession } from "next-auth/react";
|
||||||
|
@ -255,26 +256,28 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||||
const isBookingPage = useIsBookingPage();
|
const isBookingPage = useIsBookingPage();
|
||||||
|
|
||||||
const RemainingProviders = (
|
const RemainingProviders = (
|
||||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
<TrpcProvider dehydratedState={props.dehydratedState}>
|
||||||
<SessionProvider>
|
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||||
<CustomI18nextProvider i18n={props.i18n}>
|
<SessionProvider>
|
||||||
<TooltipProvider>
|
<CustomI18nextProvider i18n={props.i18n}>
|
||||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
<TooltipProvider>
|
||||||
<CalcomThemeProvider
|
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||||
themeBasis={props.themeBasis}
|
<CalcomThemeProvider
|
||||||
nonce={props.nonce}
|
themeBasis={props.themeBasis}
|
||||||
isThemeSupported={props.isThemeSupported}
|
nonce={props.nonce}
|
||||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported ?? true}
|
||||||
<FeatureFlagsProvider>
|
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||||
<OrgBrandProvider>
|
<FeatureFlagsProvider>
|
||||||
<MetaProvider>{props.children}</MetaProvider>
|
<OrgBrandProvider>
|
||||||
</OrgBrandProvider>
|
<MetaProvider>{props.children}</MetaProvider>
|
||||||
</FeatureFlagsProvider>
|
</OrgBrandProvider>
|
||||||
</CalcomThemeProvider>
|
</FeatureFlagsProvider>
|
||||||
</TooltipProvider>
|
</CalcomThemeProvider>
|
||||||
</CustomI18nextProvider>
|
</TooltipProvider>
|
||||||
</SessionProvider>
|
</CustomI18nextProvider>
|
||||||
</EventCollectionProvider>
|
</SessionProvider>
|
||||||
|
</EventCollectionProvider>
|
||||||
|
</TrpcProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isBookingPage) {
|
if (isBookingPage) {
|
||||||
|
|
|
@ -0,0 +1,83 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { truncateOnWord } from "@calcom/lib/text";
|
||||||
|
|
||||||
|
type RootMetadataRecipe = Readonly<{
|
||||||
|
twitterCreator: string;
|
||||||
|
twitterSite: string;
|
||||||
|
robots: {
|
||||||
|
index: boolean;
|
||||||
|
follow: boolean;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type PageMetadataRecipe = Readonly<{
|
||||||
|
title: string;
|
||||||
|
canonical: string;
|
||||||
|
image: string;
|
||||||
|
description: string;
|
||||||
|
siteName: string;
|
||||||
|
metadataBase: URL;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const prepareRootMetadata = (recipe: RootMetadataRecipe): Metadata => ({
|
||||||
|
icons: {
|
||||||
|
icon: "/favicon.icon",
|
||||||
|
apple: "/api/logo?type=apple-touch-icon",
|
||||||
|
other: [
|
||||||
|
{
|
||||||
|
rel: "icon-mask",
|
||||||
|
url: "/safari-pinned-tab.svg",
|
||||||
|
// @ts-expect-error TODO available in the never Next.js version
|
||||||
|
color: "#000000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/logo?type=favicon-16",
|
||||||
|
sizes: "16x16",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/logo?type=favicon-32",
|
||||||
|
sizes: "32x32",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manifest: "/site.webmanifest",
|
||||||
|
viewport: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0",
|
||||||
|
robots: recipe.robots,
|
||||||
|
other: {
|
||||||
|
"application-TileColor": "#ff0000",
|
||||||
|
},
|
||||||
|
themeColor: [
|
||||||
|
{
|
||||||
|
media: "(prefers-color-scheme: light)",
|
||||||
|
color: "#f9fafb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
media: "(prefers-color-scheme: dark)",
|
||||||
|
color: "#1C1C1C",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
twitter: {
|
||||||
|
site: recipe.twitterSite,
|
||||||
|
creator: recipe.twitterCreator,
|
||||||
|
card: "summary_large_image",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const preparePageMetadata = (recipe: PageMetadataRecipe): Metadata => ({
|
||||||
|
title: recipe.title,
|
||||||
|
alternates: {
|
||||||
|
canonical: recipe.canonical,
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
description: truncateOnWord(recipe.description, 158),
|
||||||
|
url: recipe.canonical,
|
||||||
|
type: "website",
|
||||||
|
siteName: recipe.siteName,
|
||||||
|
title: recipe.title,
|
||||||
|
images: [recipe.image],
|
||||||
|
},
|
||||||
|
metadataBase: recipe.metadataBase,
|
||||||
|
});
|
|
@ -1,13 +1,16 @@
|
||||||
import { get } from "@vercel/edge-config";
|
import { get } from "@vercel/edge-config";
|
||||||
import { collectEvents } from "next-collect/server";
|
import { collectEvents } from "next-collect/server";
|
||||||
import type { NextMiddleware } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||||
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
||||||
|
|
||||||
import { csp } from "@lib/csp";
|
import { csp } from "@lib/csp";
|
||||||
|
|
||||||
const middleware: NextMiddleware = async (req) => {
|
import { abTestMiddlewareFactory } from "./abTest/middlewareFactory";
|
||||||
|
|
||||||
|
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||||
const url = req.nextUrl;
|
const url = req.nextUrl;
|
||||||
const requestHeaders = new Headers(req.headers);
|
const requestHeaders = new Headers(req.headers);
|
||||||
|
|
||||||
|
@ -61,6 +64,12 @@ const middleware: NextMiddleware = async (req) => {
|
||||||
requestHeaders.set("x-csp-enforce", "true");
|
requestHeaders.set("x-csp-enforce", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestHeaders.set("x-pathname", url.pathname);
|
||||||
|
|
||||||
|
const locale = await getLocale(req);
|
||||||
|
|
||||||
|
requestHeaders.set("x-locale", locale);
|
||||||
|
|
||||||
return NextResponse.next({
|
return NextResponse.next({
|
||||||
request: {
|
request: {
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
|
@ -90,11 +99,13 @@ export const config = {
|
||||||
* Paths required by routingForms.handle
|
* Paths required by routingForms.handle
|
||||||
*/
|
*/
|
||||||
"/apps/routing_forms/:path*",
|
"/apps/routing_forms/:path*",
|
||||||
|
"/event-types",
|
||||||
|
"/future/event-types/",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default collectEvents({
|
export default collectEvents({
|
||||||
middleware,
|
middleware: abTestMiddlewareFactory(middleware),
|
||||||
...nextCollectBasicSettings,
|
...nextCollectBasicSettings,
|
||||||
cookieName: "__clnds",
|
cookieName: "__clnds",
|
||||||
extend: extendEventData,
|
extend: extendEventData,
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import type { User } from "@prisma/client";
|
import type { User } from "@prisma/client";
|
||||||
import { Trans } from "next-i18next";
|
import { Trans } from "next-i18next";
|
||||||
|
|
|
@ -5,16 +5,47 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { randomString } from "@calcom/lib/random";
|
import { randomString } from "@calcom/lib/random";
|
||||||
|
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
|
import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes";
|
||||||
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||||
|
|
||||||
test.describe.configure({ mode: "parallel" });
|
test.describe.configure({ mode: "parallel" });
|
||||||
|
|
||||||
|
test.describe("Event Types A/B tests", () => {
|
||||||
|
test("should point to the /future/event-types page", async ({ page, users, context }) => {
|
||||||
|
await context.addCookies([
|
||||||
|
{
|
||||||
|
name: "x-calcom-future-routes-override",
|
||||||
|
value: "1",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const user = await users.create();
|
||||||
|
|
||||||
|
await user.apiLogin();
|
||||||
|
|
||||||
|
await page.goto("/event-types");
|
||||||
|
|
||||||
|
await page.waitForLoadState();
|
||||||
|
|
||||||
|
const dataNextJsRouter = await page.evaluate(() =>
|
||||||
|
window.document.documentElement.getAttribute("data-nextjs-router")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dataNextJsRouter).toEqual("app");
|
||||||
|
|
||||||
|
const locator = page.getByRole("heading", { name: "Event Types" });
|
||||||
|
|
||||||
|
await expect(locator).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("Event Types tests", () => {
|
test.describe("Event Types tests", () => {
|
||||||
test.describe("user", () => {
|
testBothFutureAndLegacyRoutes.describe("user", () => {
|
||||||
test.beforeEach(async ({ page, users }) => {
|
test.beforeEach(async ({ page, users }) => {
|
||||||
const user = await users.create();
|
const user = await users.create();
|
||||||
await user.apiLogin();
|
await user.apiLogin();
|
||||||
await page.goto("/event-types");
|
await page.goto("/event-types");
|
||||||
|
|
||||||
// We wait until loading is finished
|
// We wait until loading is finished
|
||||||
await page.waitForSelector('[data-testid="event-types"]');
|
await page.waitForSelector('[data-testid="event-types"]');
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { test } from "./fixtures";
|
||||||
|
|
||||||
|
export type RouteVariant = "future" | "legacy";
|
||||||
|
|
||||||
|
const routeVariants = ["future", "legacy"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small wrapper around test.describe().
|
||||||
|
* When using testbothFutureLegacyRoutes.describe() instead of test.describe(), this will run the specified
|
||||||
|
* tests twice. One with the pages route, and one with the new app dir "future" route. It will also add the route variant
|
||||||
|
* name to the test name for easier debugging.
|
||||||
|
* Finally it also adds a parameter routeVariant to your testBothFutureAndLegacyRoutes.describe() callback, which
|
||||||
|
* can be used to do any conditional rendering in the test for a specific route variant (should be as little
|
||||||
|
* as possible).
|
||||||
|
*
|
||||||
|
* See apps/web/playwright/event-types.e2e.ts for an example.
|
||||||
|
*/
|
||||||
|
export const testBothFutureAndLegacyRoutes = {
|
||||||
|
describe: (testName: string, testFn: (routeVariant: RouteVariant) => void) => {
|
||||||
|
routeVariants.forEach((routeVariant) => {
|
||||||
|
test.describe(`${testName} -- ${routeVariant}`, () => {
|
||||||
|
if (routeVariant === "future") {
|
||||||
|
test.beforeEach(({ context }) => {
|
||||||
|
context.addCookies([
|
||||||
|
{ name: "x-calcom-future-routes-override", value: "1", url: "http://localhost:3000" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
testFn(routeVariant as RouteVariant);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -5,6 +5,7 @@
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["modules/*"],
|
"~/*": ["modules/*"],
|
||||||
"@components/*": ["components/*"],
|
"@components/*": ["components/*"],
|
||||||
|
"@pages/*": ["pages/*"],
|
||||||
"@lib/*": ["lib/*"],
|
"@lib/*": ["lib/*"],
|
||||||
"@server/*": ["server/*"],
|
"@server/*": ["server/*"],
|
||||||
"@prisma/client/*": ["@calcom/prisma/client/*"]
|
"@prisma/client/*": ["@calcom/prisma/client/*"]
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
|
|
||||||
|
export default function MainLayout({
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||||
|
return (
|
||||||
|
<Shell withoutMain={true} {...rest}>
|
||||||
|
{children}
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLayout = (page: React.ReactElement) => <MainLayout>{page}</MainLayout>;
|
|
@ -2,9 +2,11 @@ import { parse } from "accept-language-parser";
|
||||||
import { lookup } from "bcp-47-match";
|
import { lookup } from "bcp-47-match";
|
||||||
import type { GetTokenParams } from "next-auth/jwt";
|
import type { GetTokenParams } from "next-auth/jwt";
|
||||||
import { getToken } from "next-auth/jwt";
|
import { getToken } from "next-auth/jwt";
|
||||||
|
import { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||||
|
import { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
|
||||||
|
|
||||||
//@ts-expect-error no type definitions
|
//@ts-expect-error no type definitions
|
||||||
import { i18n } from "@calcom/web/next-i18next.config";
|
import { i18n } from "@calcom/config/next-i18next.config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is a slimmed down version of the `getServerSession` function from
|
* This is a slimmed down version of the `getServerSession` function from
|
||||||
|
@ -17,9 +19,16 @@ import { i18n } from "@calcom/web/next-i18next.config";
|
||||||
* token has expired (30 days). This should be fine as we call `/auth/session`
|
* token has expired (30 days). This should be fine as we call `/auth/session`
|
||||||
* frequently enough on the client-side to keep the session alive.
|
* frequently enough on the client-side to keep the session alive.
|
||||||
*/
|
*/
|
||||||
export const getLocale = async (req: GetTokenParams["req"]): Promise<string> => {
|
export const getLocale = async (
|
||||||
|
req:
|
||||||
|
| GetTokenParams["req"]
|
||||||
|
| {
|
||||||
|
cookies: ReadonlyRequestCookies;
|
||||||
|
headers: ReadonlyHeaders;
|
||||||
|
}
|
||||||
|
): Promise<string> => {
|
||||||
const token = await getToken({
|
const token = await getToken({
|
||||||
req,
|
req: req as GetTokenParams["req"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokenLocale = token?.["locale"];
|
const tokenLocale = token?.["locale"];
|
||||||
|
|
|
@ -106,4 +106,11 @@ export const APP_CREDENTIAL_SHARING_ENABLED =
|
||||||
|
|
||||||
export const DEFAULT_LIGHT_BRAND_COLOR = "#292929";
|
export const DEFAULT_LIGHT_BRAND_COLOR = "#292929";
|
||||||
export const DEFAULT_DARK_BRAND_COLOR = "#fafafa";
|
export const DEFAULT_DARK_BRAND_COLOR = "#fafafa";
|
||||||
export const AB_TEST_BUCKET_PROBABILITY = Number(process.env.AB_TEST_BUCKET_PROBABILITY ?? "10");
|
|
||||||
|
const defaultOnNaN = (testedValue: number, defaultValue: number) =>
|
||||||
|
!Number.isNaN(testedValue) ? testedValue : defaultValue;
|
||||||
|
|
||||||
|
export const AB_TEST_BUCKET_PROBABILITY = defaultOnNaN(
|
||||||
|
parseInt(process.env.AB_TEST_BUCKET_PROBABILITY ?? "10", 10),
|
||||||
|
10
|
||||||
|
);
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
|
|
||||||
|
//@ts-expect-error no type definitions
|
||||||
|
import config from "@calcom/web/next-i18next.config";
|
||||||
|
|
||||||
|
export const create = async (locale: string, ns: string) => {
|
||||||
|
const { _nextI18Next } = await serverSideTranslations(locale, [ns], config);
|
||||||
|
|
||||||
|
const _i18n = i18next.createInstance();
|
||||||
|
_i18n.init({
|
||||||
|
lng: locale,
|
||||||
|
resources: _nextI18Next?.initialI18nStore,
|
||||||
|
fallbackLng: _nextI18Next?.userConfig?.i18n.defaultLocale,
|
||||||
|
});
|
||||||
|
return _i18n;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFixedT = async (locale: string, ns: string) => {
|
||||||
|
const i18n = await create(locale, ns);
|
||||||
|
|
||||||
|
return i18n.getFixedT(locale, ns);
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user