diff --git a/apps/partner-sas/app/ClientComponent.tsx b/apps/partner-sas/app/[lang]/ClientComponent.tsx
similarity index 100%
rename from apps/partner-sas/app/ClientComponent.tsx
rename to apps/partner-sas/app/[lang]/ClientComponent.tsx
diff --git a/apps/partner-sas/app/layout.tsx b/apps/partner-sas/app/[lang]/layout.tsx
similarity index 85%
rename from apps/partner-sas/app/layout.tsx
rename to apps/partner-sas/app/[lang]/layout.tsx
index 7919902db..cc501de95 100644
--- a/apps/partner-sas/app/layout.tsx
+++ b/apps/partner-sas/app/[lang]/layout.tsx
@@ -1,14 +1,13 @@
import "@scandic-hotels/design-system/style.css"
import "@scandic-hotels/design-system/fonts.css"
import "@/public/_static/css/design-system-new-deprecated.css"
-import "./globals.css"
import { Lang } from "@scandic-hotels/common/constants/language"
import { TrpcProvider } from "@scandic-hotels/trpc/Provider"
-import { getMessages } from "../i18n"
-import ClientIntlProvider from "../i18n/Provider"
-import { setLang } from "../i18n/serverContext"
+import { getMessages } from "@/i18n"
+import ClientIntlProvider from "@/i18n/Provider"
+import { setLang } from "@/i18n/serverContext"
import type { Metadata } from "next"
@@ -27,8 +26,7 @@ type RootLayoutProps = {
}
export default async function RootLayout(props: RootLayoutProps) {
- // const params = await props.params
- const params = { lang: Lang.sv }
+ const params = await props.params
const { children } = props
diff --git a/apps/partner-sas/app/[lang]/middleware-error/404/page.tsx b/apps/partner-sas/app/[lang]/middleware-error/404/page.tsx
new file mode 100644
index 000000000..d61fa34cb
--- /dev/null
+++ b/apps/partner-sas/app/[lang]/middleware-error/404/page.tsx
@@ -0,0 +1,4 @@
+export default function NotFoundPage() {
+ // eslint-disable-next-line formatjs/no-literal-string-in-jsx
+ return
Not found, forgot lang in url?
+}
diff --git a/apps/partner-sas/app/[lang]/middleware-error/[status]/page.module.css b/apps/partner-sas/app/[lang]/middleware-error/[status]/page.module.css
new file mode 100644
index 000000000..7f039aed6
--- /dev/null
+++ b/apps/partner-sas/app/[lang]/middleware-error/[status]/page.module.css
@@ -0,0 +1,3 @@
+.layout {
+ font-family: var(--typography-Body-Regular-fontFamily);
+}
diff --git a/apps/partner-sas/app/[lang]/middleware-error/[status]/page.tsx b/apps/partner-sas/app/[lang]/middleware-error/[status]/page.tsx
new file mode 100644
index 000000000..13ff1aa2e
--- /dev/null
+++ b/apps/partner-sas/app/[lang]/middleware-error/[status]/page.tsx
@@ -0,0 +1,16 @@
+import styles from "./page.module.css"
+
+import type { Lang } from "@scandic-hotels/common/constants/language"
+
+export default async function MiddlewareError(props: {
+ params: Promise<{ lang: Lang; status: string }>
+}) {
+ const params = await props.params
+
+ return (
+ // eslint-disable-next-line formatjs/no-literal-string-in-jsx
+
+ Middleware error {params.lang} {params.status}
+
+ )
+}
diff --git a/apps/partner-sas/app/[lang]/not-found.tsx b/apps/partner-sas/app/[lang]/not-found.tsx
new file mode 100644
index 000000000..bca1006c4
--- /dev/null
+++ b/apps/partner-sas/app/[lang]/not-found.tsx
@@ -0,0 +1,4 @@
+export default function NotFoundPage() {
+ // eslint-disable-next-line formatjs/no-literal-string-in-jsx
+ return Not Found, missing lang in url?
+}
diff --git a/apps/partner-sas/app/page.module.css b/apps/partner-sas/app/[lang]/page.module.css
similarity index 100%
rename from apps/partner-sas/app/page.module.css
rename to apps/partner-sas/app/[lang]/page.module.css
diff --git a/apps/partner-sas/app/page.tsx b/apps/partner-sas/app/[lang]/page.tsx
similarity index 97%
rename from apps/partner-sas/app/page.tsx
rename to apps/partner-sas/app/[lang]/page.tsx
index 9b0be0a54..be56c55ef 100644
--- a/apps/partner-sas/app/page.tsx
+++ b/apps/partner-sas/app/[lang]/page.tsx
@@ -4,7 +4,8 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { serverClient } from "@/lib/trpc"
-import { getIntl } from "../i18n"
+import { getIntl } from "@/i18n"
+
import { ClientComponent } from "./ClientComponent"
import styles from "./page.module.css"
diff --git a/apps/partner-sas/app/globals.css b/apps/partner-sas/app/globals.css
deleted file mode 100644
index 25fa51c5a..000000000
--- a/apps/partner-sas/app/globals.css
+++ /dev/null
@@ -1,2 +0,0 @@
-body {
-}
diff --git a/apps/partner-sas/env/server.ts b/apps/partner-sas/env/server.ts
new file mode 100644
index 000000000..032203dff
--- /dev/null
+++ b/apps/partner-sas/env/server.ts
@@ -0,0 +1,18 @@
+import { createEnv } from "@t3-oss/env-nextjs"
+import { z } from "zod"
+
+export const env = createEnv({
+ /**
+ * Due to t3-env only checking typeof window === "undefined"
+ * and Netlify running Deno, window is never "undefined"
+ * https://github.com/t3-oss/t3-env/issues/154
+ */
+ isServer: typeof window === "undefined" || "Deno" in window,
+ server: {
+ PUBLIC_URL: z.string().default(""),
+ },
+ emptyStringAsUndefined: true,
+ runtimeEnv: {
+ PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL,
+ },
+})
diff --git a/apps/partner-sas/middleware.ts b/apps/partner-sas/middleware.ts
new file mode 100644
index 000000000..785855436
--- /dev/null
+++ b/apps/partner-sas/middleware.ts
@@ -0,0 +1,113 @@
+import * as Sentry from "@sentry/nextjs"
+import { type NextMiddleware, NextResponse } from "next/server"
+
+import { Lang } from "@scandic-hotels/common/constants/language"
+import { logger } from "@scandic-hotels/common/logger"
+import { findLang } from "@scandic-hotels/common/utils/languages"
+
+import * as invalidUrl from "@/middlewares/invalidUrl"
+import * as trailingSlash from "@/middlewares/trailingSlash"
+import { getDefaultRequestHeaders } from "@/middlewares/utils"
+
+export const middleware: NextMiddleware = async (request, event) => {
+ // auth() overrides the request origin, we need the original for internal rewrites
+ // @see getInternalNextURL()
+ request.headers.set("x-sh-origin", request.nextUrl.origin)
+
+ const headers = getDefaultRequestHeaders(request)
+ const lang = findLang(request.nextUrl.pathname)
+
+ if (!lang) {
+ // Lang is required for all our middleware.
+ // Without it we shortcircuit early.
+
+ // Default to English if no lang is found.
+ headers.set("x-lang", Lang.en)
+ return NextResponse.rewrite(
+ new URL(`/${Lang.en}/middleware-error/404`, request.nextUrl),
+ {
+ request: {
+ headers,
+ },
+ status: 404,
+ statusText: "Not found",
+ }
+ )
+ }
+
+ // Note that the order of middlewares is important since that is the order they are matched by.
+ const middlewares: { middleware: NextMiddleware; matcher: any }[] = [
+ invalidUrl,
+ trailingSlash,
+ // authRequired,
+ // handleAuth,
+ // bookingFlow,
+ // cmsContent,
+ ]
+
+ try {
+ for (let i = 0; i < middlewares.length; ++i) {
+ const middleware = middlewares[i]
+
+ if (middleware.matcher(request)) {
+ const result = await middleware.middleware(request, event)
+
+ const _continue = result?.headers.get("x-continue")
+ if (_continue) {
+ continue
+ }
+ // Clean up internal headers
+ result?.headers.delete("x-sh-origin")
+
+ return result
+ }
+ }
+ } catch (e) {
+ if (e instanceof NextResponse && e.status) {
+ const cause = await e.json()
+ logger.error(`NextResponse Error in middleware`, cause)
+ Sentry.captureException(cause)
+
+ return NextResponse.rewrite(
+ new URL(`/${lang}/middleware-error/${e.status}`, request.nextUrl),
+ {
+ request: {
+ headers,
+ },
+ status: e.status,
+ statusText: e.statusText,
+ }
+ )
+ }
+
+ logger.error(`Error in middleware`, e)
+ Sentry.captureException(e)
+
+ return NextResponse.rewrite(
+ new URL(`/${lang}/middleware-error/500`, request.nextUrl),
+ {
+ request: {
+ headers,
+ },
+ status: 500,
+ statusText: "Internal Server Error",
+ }
+ )
+ }
+
+ // Follow through with normal App router rules.
+ return NextResponse.next({
+ request: {
+ headers,
+ },
+ })
+}
+
+export const config = {
+ /**
+ * Copied from Clerk to protect all routes by default and handle
+ * public routes inside middleware.
+ * (https://clerk.com/docs/quickstarts/nextjs?utm_source=sponsorship&utm_medium=youtube&utm_campaign=code-with-antonio&utm_content=12-31-2023#add-authentication-to-your-app)
+ */
+ matcher: ["/((?!.+\\.[\\w]+$|_next|_static|.netlify|api|trpc|sitemap).*)"],
+}
diff --git a/apps/partner-sas/middlewares/invalidUrl.ts b/apps/partner-sas/middlewares/invalidUrl.ts
new file mode 100644
index 000000000..f1351bade
--- /dev/null
+++ b/apps/partner-sas/middlewares/invalidUrl.ts
@@ -0,0 +1,16 @@
+import { type NextMiddleware, NextResponse } from "next/server"
+
+import { getDefaultRequestHeaders } from "./utils"
+
+import type { MiddlewareMatcher } from "./types"
+
+export const middleware: NextMiddleware = async (request) => {
+ const headers = getDefaultRequestHeaders(request)
+ return NextResponse.next({
+ headers,
+ })
+}
+
+export const matcher: MiddlewareMatcher = (request) => {
+ return request.nextUrl.pathname.includes("*")
+}
diff --git a/apps/partner-sas/middlewares/trailingSlash.ts b/apps/partner-sas/middlewares/trailingSlash.ts
new file mode 100644
index 000000000..a4ca2fc22
--- /dev/null
+++ b/apps/partner-sas/middlewares/trailingSlash.ts
@@ -0,0 +1,25 @@
+import { type NextMiddleware, NextResponse } from "next/server"
+
+import { getPublicNextURL } from "@/server/utils"
+
+import { getDefaultRequestHeaders } from "./utils"
+
+import type { MiddlewareMatcher } from "./types"
+
+export const middleware: NextMiddleware = async (request) => {
+ const headers = getDefaultRequestHeaders(request)
+
+ const newUrl = new URL(
+ request.nextUrl.pathname.slice(0, -1),
+ getPublicNextURL(request)
+ )
+
+ return NextResponse.redirect(newUrl, {
+ headers,
+ status: 308,
+ })
+}
+
+export const matcher: MiddlewareMatcher = (request) => {
+ return request.nextUrl.pathname.endsWith("/")
+}
diff --git a/apps/partner-sas/middlewares/types.ts b/apps/partner-sas/middlewares/types.ts
new file mode 100644
index 000000000..ac9e53699
--- /dev/null
+++ b/apps/partner-sas/middlewares/types.ts
@@ -0,0 +1,3 @@
+import type { NextRequest } from "next/server"
+
+export type MiddlewareMatcher = (request: NextRequest) => boolean
diff --git a/apps/partner-sas/middlewares/utils.ts b/apps/partner-sas/middlewares/utils.ts
new file mode 100644
index 000000000..5f48598f7
--- /dev/null
+++ b/apps/partner-sas/middlewares/utils.ts
@@ -0,0 +1,22 @@
+import { findLang } from "@scandic-hotels/common/utils/languages"
+import { removeTrailingSlash } from "@scandic-hotels/common/utils/url"
+
+import { getPublicNextURL } from "@/server/utils"
+
+import type { NextRequest } from "next/server"
+
+export function getDefaultRequestHeaders(request: NextRequest) {
+ const lang = findLang(request.nextUrl.pathname)!
+ const nextUrlPublic = getPublicNextURL(request)
+ const headers = new Headers(request.headers)
+ headers.set("x-lang", lang)
+ headers.set(
+ "x-pathname",
+ removeTrailingSlash(
+ request.nextUrl.pathname.replace(`/${lang}`, "").replace(`/webview`, "")
+ )
+ )
+ headers.set("x-url", removeTrailingSlash(nextUrlPublic.href))
+
+ return headers
+}
diff --git a/apps/partner-sas/server/utils.ts b/apps/partner-sas/server/utils.ts
new file mode 100644
index 000000000..c1ceac4e3
--- /dev/null
+++ b/apps/partner-sas/server/utils.ts
@@ -0,0 +1,94 @@
+import { NextRequest } from "next/server"
+
+import { logger } from "@scandic-hotels/common/logger"
+
+import { env } from "@/env/server"
+/**
+ * Use this function when you want to create URLs that are public facing, for
+ * example for redirects or redirectTo query parameters.
+ * Dedicated environments are behind Akamai (test, stage, production). They have
+ * env.PUBLIC_URL set.
+ * All other environment like deploy previews and branch deployments are not
+ * behind Akamai and therefore do not have env.PUBLIC_URL set.
+ * We need this approach because Netlify uses x-forwarded-host internally and
+ * strips it from ever reaching our code.
+ * TODO: Replace this approach with custom header in Akamai that mirrors the
+ * value in x-forwarded-host which would not get stripped by Netlify.
+ * @param request The incoming request.
+ * @returns NextURL The public facing URL instance for the given request.
+ */
+export function getPublicNextURL(request: NextRequest) {
+ if (env.PUBLIC_URL) {
+ const publicNextURL = request.nextUrl.clone()
+ // Akamai in front of Netlify for dedicated environments
+ // require us to rewrite the incoming host and hostname
+ // to match the public URL used to visit Akamai.
+ const url = new URL(env.PUBLIC_URL)
+ publicNextURL.host = url.host
+ publicNextURL.hostname = url.hostname
+ return publicNextURL
+ }
+ return request.nextUrl
+}
+
+/**
+ * Use this function when you want the public facing URL for the given request.
+ * Read about the motivation in getPublicNextURL above.
+ * @see getPublicNextURL
+ * @param request The incoming request.
+ * @returns string The public facing origin for the given request.
+ */
+export function getPublicURL(request: NextRequest) {
+ if (env.PUBLIC_URL) {
+ return env.PUBLIC_URL
+ }
+ return request.nextUrl.origin
+}
+
+/**
+ * Use this function when you want to create URLs that are internal (behind Akamai),
+ * for example for rewrites. Mainly used for middleware wrapped in auth().
+ * The auth() function overrides the origin of the incoming request. It sets it
+ * to the origin of AUTH_URL/NEXTAUTH_URL. This means we cannot use the augmented
+ * request from auth() for rewrites, as those will point to auth url origin
+ * (in front of Akamai) and not the origin of the incoming request (behind Akamai).
+ * This results in rewrites going over the internet instead of going through the
+ * internal routing at Netlify.
+ * For dedicated environments (test, stage and production) we are behind Akamai.
+ * For those we have set a value for AUTH_URL/NEXTAUTH_URL, they point to the
+ * PUBLIC_URL. For rewrites we need the internal origin inside Netlify.
+ * In middleware.ts we copy the incoming origin to a header 'x-sh-origin'. We try
+ * and use that first, if not present we assume the internal origin is the value
+ * of the host header, as that is what Netlify used for routing to this deployment.
+ * @param request The incoming request.
+ * @returns NextURL The internal request, in Netlify behind Akamai.
+ */
+export function getInternalNextURL(request: NextRequest) {
+ const { href, origin } = request.nextUrl
+
+ const originHeader = request.headers.get("x-sh-origin")
+ if (originHeader) {
+ logger.debug(`[internalNextUrl] using x-sh-origin header`, {
+ origin,
+ originHeader,
+ newOrigin: href.replace(origin, originHeader),
+ })
+ return new NextRequest(href.replace(origin, originHeader), request).nextUrl
+ }
+
+ const hostHeader = request.headers.get("host")
+ if (hostHeader) {
+ const inputHostOrigin = `${request.nextUrl.protocol}//${hostHeader}`
+ logger.debug(`[internalNextUrl] using host header`, {
+ origin,
+ hostHeader,
+ hostOrigin: inputHostOrigin,
+ newOrigin: href.replace(origin, inputHostOrigin),
+ })
+ const { origin: hostOrigin } = new URL(inputHostOrigin)
+ return new NextRequest(href.replace(origin, hostOrigin), request).nextUrl
+ }
+
+ logger.debug(`[internalNextUrl] falling back to incoming request`)
+ return request.nextUrl
+}