Merged develop into feat/SW-266-seo-loyalty-pages
This commit is contained in:
13
Auth.md
Normal file
13
Auth.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Auth
|
||||||
|
|
||||||
|
The web is using OAuth 2.0 to handle auth. We host our own instance of [Curity](https://curity.io), which is our identity and access management solution.
|
||||||
|
|
||||||
|
## Session management in Next
|
||||||
|
We use [Auth.js](https://authjs.dev) to handle everything regarding auth in the web. We use the JWT session strategy, which means that everything regarding the session is stored in a JWT, which is stored in the browser in an encrypted cookie.
|
||||||
|
|
||||||
|
## Keeping the access token alive
|
||||||
|
When the user performs a navigation the web app often does multiple requests to Next. If the access token has expired Next will do a request to Curity to renew the tokens. Since we only allow a single refresh token to be used only once only the first request will succeed and the following requests will fail.
|
||||||
|
|
||||||
|
To avoid that we have a component whose only purpose is to keep the access token alive. As long as no other request is happening at the same time this will work fine.
|
||||||
|
|
||||||
|
To avoid a session that keeps on refreshing forever, if the user have the page open in the background e.g., we have a timeout that stops the refreshing if the user is not active.
|
||||||
@@ -17,6 +17,13 @@ export async function GET(
|
|||||||
const returnUrl = request.headers.get("x-returnurl")
|
const returnUrl = request.headers.get("x-returnurl")
|
||||||
const isMFA = request.headers.get("x-mfa-login")
|
const isMFA = request.headers.get("x-mfa-login")
|
||||||
|
|
||||||
|
// This is to support seamless login when using magic link login
|
||||||
|
const isMagicLinkUpdateLogin = !!request.headers.get("x-magic-link")
|
||||||
|
|
||||||
|
if (!env.PUBLIC_URL) {
|
||||||
|
throw internalServerError("No value for env.PUBLIC_URL")
|
||||||
|
}
|
||||||
|
|
||||||
if (returnUrl) {
|
if (returnUrl) {
|
||||||
// Seamless login request from Current web
|
// Seamless login request from Current web
|
||||||
redirectTo = returnUrl
|
redirectTo = returnUrl
|
||||||
@@ -29,9 +36,6 @@ export async function GET(
|
|||||||
|
|
||||||
// Make relative URL to absolute URL
|
// Make relative URL to absolute URL
|
||||||
if (redirectTo.startsWith("/")) {
|
if (redirectTo.startsWith("/")) {
|
||||||
if (!env.PUBLIC_URL) {
|
|
||||||
throw internalServerError("No value for env.PUBLIC_URL")
|
|
||||||
}
|
|
||||||
redirectTo = new URL(redirectTo, env.PUBLIC_URL).href
|
redirectTo = new URL(redirectTo, env.PUBLIC_URL).href
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +72,14 @@ export async function GET(
|
|||||||
const redirectUrl = new URL(redirectUrlValue)
|
const redirectUrl = new URL(redirectUrlValue)
|
||||||
redirectUrl.searchParams.set("returnurl", redirectTo)
|
redirectUrl.searchParams.set("returnurl", redirectTo)
|
||||||
redirectTo = redirectUrl.toString()
|
redirectTo = redirectUrl.toString()
|
||||||
|
|
||||||
|
/** Set cookie with redirect Url to appropriately redirect user when using magic link login */
|
||||||
|
redirectHeaders.append(
|
||||||
|
"set-cookie",
|
||||||
|
"magicLinkRedirectTo=" +
|
||||||
|
redirectTo +
|
||||||
|
"; Max-Age=300; Path=/; HttpOnly; SameSite=Lax"
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
"Unable to create URL for seamless login, proceeding without it."
|
"Unable to create URL for seamless login, proceeding without it."
|
||||||
@@ -86,27 +98,37 @@ export async function GET(
|
|||||||
console.log({ login_env: process.env })
|
console.log({ login_env: process.env })
|
||||||
|
|
||||||
console.log({ login_redirectTo: redirectTo })
|
console.log({ login_redirectTo: redirectTo })
|
||||||
const params = isMFA
|
const params = {
|
||||||
? {
|
ui_locales: context.params.lang,
|
||||||
ui_locales: context.params.lang,
|
scope: ["openid", "profile"].join(" "),
|
||||||
scope: ["profile_update", "openid", "profile"].join(" "),
|
/**
|
||||||
/**
|
* The `acr_values` param is used to make Curity display the proper login
|
||||||
* The below acr value is required as for New Web same Curity Client is used for MFA
|
* page for Scandic. Without the parameter Curity presents some choices
|
||||||
* while in current web it is being setup using different Curity Client
|
* to the user which we do not want.
|
||||||
*/
|
*/
|
||||||
acr_values:
|
acr_values: "acr",
|
||||||
"urn:se:curity:authentication:otp-authenticator:OTP-Authenticator_web",
|
|
||||||
}
|
/**
|
||||||
: {
|
* Both of the below two params are required to send for initiating login as well
|
||||||
ui_locales: context.params.lang,
|
* because user might choose to do Email link login.
|
||||||
scope: ["openid", "profile"].join(" "),
|
* */
|
||||||
/**
|
// The `for_origin` param is used to make Curity email login functionality working.
|
||||||
* The `acr_values` param is used to make Curity display the proper login
|
for_origin: env.PUBLIC_URL,
|
||||||
* page for Scandic. Without the parameter Curity presents some choices
|
// This is new param set for differentiate between the Magic link login of New web and current web
|
||||||
* to the user which we do not want.
|
version: "2",
|
||||||
*/
|
}
|
||||||
acr_values: "acr",
|
if (isMFA) {
|
||||||
}
|
// Append profile_update scope for MFA
|
||||||
|
params.scope = params.scope + " profile_udpate"
|
||||||
|
/**
|
||||||
|
* The below acr value is required as for New Web same Curity Client is used for MFA
|
||||||
|
* while in current web it is being setup using different Curity Client
|
||||||
|
*/
|
||||||
|
params.acr_values =
|
||||||
|
"urn:se:curity:authentication:otp-authenticator:OTP-Authenticator_web"
|
||||||
|
} else if (isMagicLinkUpdateLogin) {
|
||||||
|
params.acr_values = "abc"
|
||||||
|
}
|
||||||
const redirectUrl = await signIn(
|
const redirectUrl = await signIn(
|
||||||
"curity",
|
"curity",
|
||||||
{
|
{
|
||||||
|
|||||||
75
app/[lang]/(live)/(public)/verifymagiclink/route.ts
Normal file
75
app/[lang]/(live)/(public)/verifymagiclink/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { AuthError } from "next-auth"
|
||||||
|
|
||||||
|
import { Lang } from "@/constants/languages"
|
||||||
|
import { login } from "@/constants/routes/handleAuth"
|
||||||
|
import { env } from "@/env/server"
|
||||||
|
import { badRequest, internalServerError } from "@/server/errors/next"
|
||||||
|
|
||||||
|
import { signIn } from "@/auth"
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
context: { params: { lang: Lang } }
|
||||||
|
) {
|
||||||
|
let redirectTo: string
|
||||||
|
|
||||||
|
// Set redirect url from the magicLinkRedirect Cookie which is set when intiating login
|
||||||
|
redirectTo =
|
||||||
|
request.cookies.get("magicLinkRedirectTo")?.value ||
|
||||||
|
"/" + context.params.lang
|
||||||
|
|
||||||
|
if (!env.PUBLIC_URL) {
|
||||||
|
throw internalServerError("No value for env.PUBLIC_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make relative URL to absolute URL
|
||||||
|
if (redirectTo.startsWith("/")) {
|
||||||
|
redirectTo = new URL(redirectTo, env.PUBLIC_URL).href
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Seamless login url as Magic link login has a different authenticator in Curity
|
||||||
|
redirectTo = redirectTo.replace("updatelogin", "updateloginemail")
|
||||||
|
|
||||||
|
const loginKey = request.nextUrl.searchParams.get("loginKey")
|
||||||
|
|
||||||
|
if (!loginKey) {
|
||||||
|
return badRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/**
|
||||||
|
* Passing `redirect: false` to `signIn` will return the URL instead of
|
||||||
|
* automatically redirecting to it inside of `signIn`.
|
||||||
|
* https://github.com/nextauthjs/next-auth/blob/3c035ec/packages/next-auth/src/lib/actions.ts#L76
|
||||||
|
*/
|
||||||
|
console.log({ login_redirectTo: redirectTo })
|
||||||
|
let redirectUrl = await signIn(
|
||||||
|
"curity",
|
||||||
|
{
|
||||||
|
redirectTo,
|
||||||
|
redirect: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ui_locales: context.params.lang,
|
||||||
|
scope: ["openid", "profile"].join(" "),
|
||||||
|
loginKey: loginKey,
|
||||||
|
for_origin: env.PUBLIC_URL,
|
||||||
|
acr_values: "abc",
|
||||||
|
version: "2",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (redirectUrl) {
|
||||||
|
return NextResponse.redirect(redirectUrl)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
console.error({ signInAuthError: error })
|
||||||
|
} else {
|
||||||
|
console.error({ signInError: error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return internalServerError()
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import Script from "next/script"
|
|||||||
|
|
||||||
import TrpcProvider from "@/lib/trpc/Provider"
|
import TrpcProvider from "@/lib/trpc/Provider"
|
||||||
|
|
||||||
|
import TokenRefresher from "@/components/Auth/TokenRefresher"
|
||||||
import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
|
import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
|
||||||
import Footer from "@/components/Current/Footer"
|
import Footer from "@/components/Current/Footer"
|
||||||
import VwoScript from "@/components/Current/VwoScript"
|
import VwoScript from "@/components/Current/VwoScript"
|
||||||
@@ -55,6 +56,7 @@ export default async function RootLayout({
|
|||||||
{header}
|
{header}
|
||||||
{children}
|
{children}
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<TokenRefresher />
|
||||||
</TrpcProvider>
|
</TrpcProvider>
|
||||||
</ServerIntlProvider>
|
</ServerIntlProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import "@scandic-hotels/design-system/style.css"
|
|||||||
|
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
|
|
||||||
|
import TokenRefresher from "@/components/Auth/TokenRefresher"
|
||||||
import AdobeScript from "@/components/Current/AdobeScript"
|
import AdobeScript from "@/components/Current/AdobeScript"
|
||||||
import Footer from "@/components/Current/Footer"
|
import Footer from "@/components/Current/Footer"
|
||||||
import Header from "@/components/Current/Header"
|
import Header from "@/components/Current/Header"
|
||||||
@@ -72,6 +73,7 @@ export default async function RootLayout({
|
|||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<TokenRefresher />
|
||||||
</ServerIntlProvider>
|
</ServerIntlProvider>
|
||||||
<Script id="page-tracking">{`
|
<Script id="page-tracking">{`
|
||||||
typeof _satellite !== "undefined" && _satellite.pageBottom();
|
typeof _satellite !== "undefined" && _satellite.pageBottom();
|
||||||
|
|||||||
143
auth.ts
143
auth.ts
@@ -1,25 +1,84 @@
|
|||||||
import NextAuth from "next-auth"
|
import NextAuth from "next-auth"
|
||||||
|
|
||||||
|
import { PRE_REFRESH_TIME_IN_SECONDS } from "@/constants/auth"
|
||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
import { LoginTypeEnum } from "./types/components/tracking"
|
import { LoginTypeEnum } from "./types/components/tracking"
|
||||||
|
|
||||||
import type { NextAuthConfig, User } from "next-auth"
|
import type { NextAuthConfig, User } from "next-auth"
|
||||||
|
import type { JWT } from "next-auth/jwt"
|
||||||
import type { OIDCConfig } from "next-auth/providers"
|
import type { OIDCConfig } from "next-auth/providers"
|
||||||
|
|
||||||
function getLoginType(user: User) {
|
function getLoginType(user: User) {
|
||||||
// TODO: handle magic link, should be enough to just check for Nonce.
|
|
||||||
// if (user?.nonce) {
|
|
||||||
// return LoginTypeEnum.MagicLink
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (user?.login_with.includes("@")) {
|
if (user?.login_with.includes("@")) {
|
||||||
return LoginTypeEnum.email
|
return LoginTypeEnum.email
|
||||||
|
} else if (user?.login_with.toLowerCase() == LoginTypeEnum["email link"]) {
|
||||||
|
return LoginTypeEnum["email link"]
|
||||||
} else {
|
} else {
|
||||||
return LoginTypeEnum["membership number"]
|
return LoginTypeEnum["membership number"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshTokens(token: JWT) {
|
||||||
|
try {
|
||||||
|
console.log("token-debug Access token expired, trying to refresh it.", {
|
||||||
|
expires_at: token.expires_at,
|
||||||
|
sub: token.sub,
|
||||||
|
token: token.access_token,
|
||||||
|
})
|
||||||
|
const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, {
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: env.CURITY_CLIENT_ID_USER,
|
||||||
|
client_secret: env.CURITY_CLIENT_SECRET_USER,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: token.refresh_token,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
|
||||||
|
const new_tokens = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log("token-debug Token response was not ok", {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
sub: token.sub,
|
||||||
|
})
|
||||||
|
throw new_tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("token-debug Successfully got new token(s)", {
|
||||||
|
expires_at: new_tokens.expires_at,
|
||||||
|
got_new_refresh_token: new_tokens.refresh_token !== token.refresh_token,
|
||||||
|
got_new_access_token: new_tokens.access_token !== token.access_token,
|
||||||
|
sub: token.sub,
|
||||||
|
})
|
||||||
|
|
||||||
|
const expiresAt = new_tokens.expires_in
|
||||||
|
? Date.now() + new_tokens.expires_in * 1000
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
access_token: new_tokens.access_token,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
refresh_token: new_tokens.refresh_token,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("token-debug Error thrown when trying to refresh", {
|
||||||
|
error,
|
||||||
|
sub: token.sub,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: "RefreshAccessTokenError" as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const curityProvider = {
|
const curityProvider = {
|
||||||
id: "curity",
|
id: "curity",
|
||||||
name: "Curity",
|
name: "Curity",
|
||||||
@@ -103,7 +162,7 @@ export const config = {
|
|||||||
async authorized({ auth, request }) {
|
async authorized({ auth, request }) {
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
async jwt({ account, token, trigger, user, profile }) {
|
async jwt({ account, session, token, trigger, user, profile }) {
|
||||||
const loginType = getLoginType(user)
|
const loginType = getLoginType(user)
|
||||||
if (trigger === "signIn" && account) {
|
if (trigger === "signIn" && account) {
|
||||||
const mfa_scope =
|
const mfa_scope =
|
||||||
@@ -121,71 +180,15 @@ export const config = {
|
|||||||
mfa_scope: mfa_scope,
|
mfa_scope: mfa_scope,
|
||||||
mfa_expires_at: mfa_expires_at,
|
mfa_expires_at: mfa_expires_at,
|
||||||
}
|
}
|
||||||
} else if (Date.now() < token.expires_at) {
|
} else if (
|
||||||
return token
|
token.expires_at &&
|
||||||
} else {
|
Date.now() > token.expires_at - PRE_REFRESH_TIME_IN_SECONDS * 1000 &&
|
||||||
try {
|
session?.doRefresh
|
||||||
console.log(
|
) {
|
||||||
"token-debug Access token expired, trying to refresh it.",
|
return refreshTokens(token)
|
||||||
{
|
|
||||||
expires_at: token.expires_at,
|
|
||||||
sub: token.sub,
|
|
||||||
token: token.access_token,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const response = await fetch(
|
|
||||||
`${env.CURITY_ISSUER_USER}/oauth/v2/token`,
|
|
||||||
{
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: env.CURITY_CLIENT_ID_USER,
|
|
||||||
client_secret: env.CURITY_CLIENT_SECRET_USER,
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: token.refresh_token,
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
method: "POST",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const new_tokens = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.log("token-debug Token response was not ok", {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
sub: token.sub,
|
|
||||||
})
|
|
||||||
throw new_tokens
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("token-debug Successfully got new token(s)", {
|
|
||||||
expires_at: new_tokens.expires_at,
|
|
||||||
got_new_refresh_token:
|
|
||||||
new_tokens.refresh_token !== token.refresh_token,
|
|
||||||
got_new_access_token:
|
|
||||||
new_tokens.access_token !== token.access_token,
|
|
||||||
sub: token.sub,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
...token,
|
|
||||||
access_token: new_tokens.access_token,
|
|
||||||
expires_at: new_tokens.expires_at,
|
|
||||||
refresh_token: new_tokens.refresh_token ?? token.refresh_token,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log("token-debug Error thrown when trying to refresh", {
|
|
||||||
error,
|
|
||||||
sub: token.sub,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
...token,
|
|
||||||
error: "RefreshAccessTokenError" as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return token
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// events: {
|
// events: {
|
||||||
|
|||||||
65
components/Auth/TokenRefresher.tsx
Normal file
65
components/Auth/TokenRefresher.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"use client"
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation"
|
||||||
|
import { SessionProvider, useSession } from "next-auth/react"
|
||||||
|
import { useEffect, useMemo, useRef } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
MAX_KEEP_ALIVE_TIME_IN_MINUTES,
|
||||||
|
PRE_REFRESH_TIME_IN_SECONDS,
|
||||||
|
} from "@/constants/auth"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the access token alive by proactively refreshing it
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export default function TokenRefresher() {
|
||||||
|
return (
|
||||||
|
<SessionProvider basePath="/api/web/auth">
|
||||||
|
<Refresher />
|
||||||
|
</SessionProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Refresher() {
|
||||||
|
const session = useSession()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const timeoutId = useRef<NodeJS.Timeout>()
|
||||||
|
|
||||||
|
// Simple inactivity control. Reset when the URL changes.
|
||||||
|
const stopPreRefreshAt = useMemo(
|
||||||
|
() => Date.now() + MAX_KEEP_ALIVE_TIME_IN_MINUTES * 60 * 1000,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[pathname, searchParams]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timeoutId.current) {
|
||||||
|
clearTimeout(timeoutId.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.data?.token.expires_at) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshIn =
|
||||||
|
session.data.token.expires_at -
|
||||||
|
Date.now() -
|
||||||
|
PRE_REFRESH_TIME_IN_SECONDS * 1000
|
||||||
|
|
||||||
|
timeoutId.current = setTimeout(
|
||||||
|
async () => {
|
||||||
|
if (stopPreRefreshAt > Date.now()) {
|
||||||
|
await session.update({ doRefresh: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// If the token has already expired `refreshIn` will be
|
||||||
|
// negative, and we will refresh immediately (in 1 ms)
|
||||||
|
Math.max(refreshIn, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId.current)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [session.data?.token.expires_at, stopPreRefreshAt])
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import { amenities } from "@/constants/routes/hotelPageParams"
|
||||||
|
|
||||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||||
import { ChevronRightIcon } from "@/components/Icons"
|
import { ChevronRightIcon } from "@/components/Icons"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import SidePeekContainer from "@/components/TempDesignSystem/SidePeek/Container"
|
|
||||||
import SidePeekContent from "@/components/TempDesignSystem/SidePeek/Content"
|
|
||||||
import { generateSidePeekLink } from "@/components/TempDesignSystem/SidePeek/data"
|
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
import { getLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
import styles from "./amenitiesList.module.css"
|
import styles from "./amenitiesList.module.css"
|
||||||
|
|
||||||
@@ -17,15 +17,15 @@ export default async function AmenitiesList({
|
|||||||
}: {
|
}: {
|
||||||
detailedFacilities: HotelData["data"]["attributes"]["detailedFacilities"]
|
detailedFacilities: HotelData["data"]["attributes"]["detailedFacilities"]
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage } = await getIntl()
|
const intl = await getIntl()
|
||||||
const sidePeekLink = generateSidePeekLink("amenities")
|
|
||||||
const sortedAmenities = detailedFacilities
|
const sortedAmenities = detailedFacilities
|
||||||
.sort((a, b) => b.sortOrder - a.sortOrder)
|
.sort((a, b) => b.sortOrder - a.sortOrder)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
|
const lang = getLang()
|
||||||
return (
|
return (
|
||||||
<section className={styles.amenitiesContainer}>
|
<section className={styles.amenitiesContainer}>
|
||||||
<Subtitle type="two" color="black">
|
<Subtitle type="two" color="black">
|
||||||
{formatMessage({ id: "At the hotel" })}
|
{intl.formatMessage({ id: "At the hotel" })}
|
||||||
</Subtitle>
|
</Subtitle>
|
||||||
<div className={styles.amenityItemList}>
|
<div className={styles.amenityItemList}>
|
||||||
{sortedAmenities.map((facility) => {
|
{sortedAmenities.map((facility) => {
|
||||||
@@ -38,18 +38,15 @@ export default async function AmenitiesList({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Link scroll={false} href={sidePeekLink} color="burgundy" variant="icon">
|
<Link
|
||||||
{formatMessage({ id: "Show all amenities" })}
|
scroll={false}
|
||||||
|
href={`?s=${amenities[lang]}`}
|
||||||
|
color="burgundy"
|
||||||
|
variant="icon"
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: "Show all amenities" })}
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon />
|
||||||
</Link>
|
</Link>
|
||||||
<SidePeekContainer>
|
|
||||||
<SidePeekContent
|
|
||||||
contentKey={"amenities"}
|
|
||||||
title={formatMessage({ id: "Amenities" })}
|
|
||||||
>
|
|
||||||
{/* TODO: Render amenities as per the design. */}
|
|
||||||
</SidePeekContent>
|
|
||||||
</SidePeekContainer>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getLang } from "@/i18n/serverContext"
|
|||||||
import AmenitiesList from "./AmenitiesList"
|
import AmenitiesList from "./AmenitiesList"
|
||||||
import IntroSection from "./IntroSection"
|
import IntroSection from "./IntroSection"
|
||||||
import { Rooms } from "./Rooms"
|
import { Rooms } from "./Rooms"
|
||||||
|
import SidePeeks from "./SidePeeks"
|
||||||
import TabNavigation from "./TabNavigation"
|
import TabNavigation from "./TabNavigation"
|
||||||
|
|
||||||
import styles from "./hotelPage.module.css"
|
import styles from "./hotelPage.module.css"
|
||||||
@@ -16,10 +17,10 @@ export default async function HotelPage() {
|
|||||||
if (!hotelPageIdentifierData) {
|
if (!hotelPageIdentifierData) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
const lang = getLang()
|
||||||
const { attributes, roomCategories } = await serverClient().hotel.getHotel({
|
const { attributes, roomCategories } = await serverClient().hotel.getHotel({
|
||||||
hotelId: hotelPageIdentifierData.hotel_page_id,
|
hotelId: hotelPageIdentifierData.hotel_page_id,
|
||||||
language: getLang(),
|
language: lang,
|
||||||
include: ["RoomCategories"],
|
include: ["RoomCategories"],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export default async function HotelPage() {
|
|||||||
address={attributes.address}
|
address={attributes.address}
|
||||||
tripAdvisor={attributes.ratings.tripAdvisor}
|
tripAdvisor={attributes.ratings.tripAdvisor}
|
||||||
/>
|
/>
|
||||||
|
<SidePeeks />
|
||||||
<AmenitiesList detailedFacilities={attributes.detailedFacilities} />
|
<AmenitiesList detailedFacilities={attributes.detailedFacilities} />
|
||||||
</div>
|
</div>
|
||||||
<Rooms rooms={roomCategories} />
|
<Rooms rooms={roomCategories} />
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { about } from "@/constants/routes/hotelPageParams"
|
||||||
|
|
||||||
import ArrowRight from "@/components/Icons/ArrowRight"
|
import ArrowRight from "@/components/Icons/ArrowRight"
|
||||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
@@ -6,6 +8,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
|||||||
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
import { getLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
import { IntroSectionProps } from "./types"
|
import { IntroSectionProps } from "./types"
|
||||||
|
|
||||||
@@ -19,15 +22,15 @@ export default async function IntroSection({
|
|||||||
tripAdvisor,
|
tripAdvisor,
|
||||||
}: IntroSectionProps) {
|
}: IntroSectionProps) {
|
||||||
const intl = await getIntl()
|
const intl = await getIntl()
|
||||||
const { formatMessage } = intl
|
|
||||||
const { streetAddress, city } = address
|
const { streetAddress, city } = address
|
||||||
const { distanceToCentre } = location
|
const { distanceToCentre } = location
|
||||||
const formattedDistanceText = formatMessage(
|
const formattedDistanceText = intl.formatMessage(
|
||||||
{ id: "Distance to city centre" },
|
{ id: "Distance to city centre" },
|
||||||
{ number: distanceToCentre }
|
{ number: distanceToCentre }
|
||||||
)
|
)
|
||||||
|
const lang = getLang()
|
||||||
const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})`
|
const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})`
|
||||||
const formattedTripAdvisorText = formatMessage(
|
const formattedTripAdvisorText = intl.formatMessage(
|
||||||
{ id: "Tripadvisor reviews" },
|
{ id: "Tripadvisor reviews" },
|
||||||
{ rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews }
|
{ rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews }
|
||||||
)
|
)
|
||||||
@@ -37,7 +40,7 @@ export default async function IntroSection({
|
|||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<div className={styles.titleContainer}>
|
<div className={styles.titleContainer}>
|
||||||
<BiroScript tilted="medium" color="red">
|
<BiroScript tilted="medium" color="red">
|
||||||
{formatMessage({ id: "Welcome to" })}:
|
{intl.formatMessage({ id: "Welcome to" })}:
|
||||||
</BiroScript>
|
</BiroScript>
|
||||||
<Title level="h2">{hotelName}</Title>
|
<Title level="h2">{hotelName}</Title>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,14 +61,12 @@ export default async function IntroSection({
|
|||||||
<Preamble>{hotelDescription}</Preamble>
|
<Preamble>{hotelDescription}</Preamble>
|
||||||
<Link
|
<Link
|
||||||
className={styles.introLink}
|
className={styles.introLink}
|
||||||
target="_blank"
|
|
||||||
color="peach80"
|
color="peach80"
|
||||||
textDecoration="underline"
|
textDecoration="underline"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
href="#"
|
href={`?s=${about[lang]}`}
|
||||||
>
|
>
|
||||||
{/*TODO: Ask content team where this should link to. */}
|
{intl.formatMessage({ id: "Read more about the hotel" })}
|
||||||
{formatMessage({ id: "Read more about the hotel" })}
|
|
||||||
<ArrowRight color="peach80" />
|
<ArrowRight color="peach80" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
64
components/ContentType/HotelPage/SidePeeks.tsx
Normal file
64
components/ContentType/HotelPage/SidePeeks.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { about, amenities } from "@/constants/routes/hotelPageParams"
|
||||||
|
|
||||||
|
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||||
|
import SidePeekItem from "@/components/TempDesignSystem/SidePeek/Item"
|
||||||
|
import { SidePeekContentKey } from "@/components/TempDesignSystem/SidePeek/types"
|
||||||
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
|
function SidePeekContainer() {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [activeSidePeek, setActiveSidePeek] =
|
||||||
|
useState<SidePeekContentKey | null>(() => {
|
||||||
|
const sidePeekParam = searchParams.get("s") as SidePeekContentKey | null
|
||||||
|
return sidePeekParam || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const lang = useLang()
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sidePeekParam = searchParams.get("s") as SidePeekContentKey | null
|
||||||
|
if (sidePeekParam !== activeSidePeek) {
|
||||||
|
setActiveSidePeek(sidePeekParam)
|
||||||
|
}
|
||||||
|
}, [searchParams, activeSidePeek])
|
||||||
|
|
||||||
|
function handleClose(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
setActiveSidePeek(null)
|
||||||
|
|
||||||
|
const nextSearchParams = new URLSearchParams(searchParams.toString())
|
||||||
|
nextSearchParams.delete("s")
|
||||||
|
|
||||||
|
router.push(`${pathname}?${nextSearchParams}`, { scroll: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidePeek handleClose={handleClose} activeSidePeek={activeSidePeek}>
|
||||||
|
<SidePeekItem
|
||||||
|
contentKey={amenities[lang]}
|
||||||
|
title={intl.formatMessage({ id: "Amenities" })}
|
||||||
|
>
|
||||||
|
{/* TODO: Render amenities as per the design. */}
|
||||||
|
Read more about the amenities here
|
||||||
|
</SidePeekItem>
|
||||||
|
<SidePeekItem
|
||||||
|
contentKey={about[lang]}
|
||||||
|
title={intl.formatMessage({ id: "Read more about the hotel" })}
|
||||||
|
>
|
||||||
|
Some additional information about the hotel
|
||||||
|
</SidePeekItem>
|
||||||
|
</SidePeek>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SidePeekContainer
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"title": "Sen utcheckning – 1 timme, i mån av plats"
|
"title": "Sen utcheckning – 1 timme, i mån av plats"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Voucher 50 kr"
|
"title": "Kupong 50 kr"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -49,13 +49,13 @@
|
|||||||
"requiredNights": 0,
|
"requiredNights": 0,
|
||||||
"benefits": [
|
"benefits": [
|
||||||
{
|
{
|
||||||
"title": "25% poängboost"
|
"title": "25 % poängboost"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Tidig incheckning – 1 timme, i mån av plats"
|
"title": "Tidig incheckning – 1 timme, i mån av plats"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Voucher 75 kr"
|
"title": "Kupong 75 kr"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
"title": "Kostnadsfri uppgradering av rum, i mån av plats"
|
"title": "Kostnadsfri uppgradering av rum, i mån av plats"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Voucher 100 kr"
|
"title": "Kupong 100 kr"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Frukost 2 för 1"
|
"title": "Frukost 2 för 1"
|
||||||
@@ -83,10 +83,10 @@
|
|||||||
"requiredNights": 0,
|
"requiredNights": 0,
|
||||||
"benefits": [
|
"benefits": [
|
||||||
{
|
{
|
||||||
"title": "50% poängboost"
|
"title": "50 % poängboost"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Voucher 150 kr"
|
"title": "Kupong 150 kr"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "48 timmars rumsgaranti"
|
"title": "48 timmars rumsgaranti"
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
"requiredNights": 100,
|
"requiredNights": 100,
|
||||||
"benefits": [
|
"benefits": [
|
||||||
{
|
{
|
||||||
"title": "Voucher 200 kr"
|
"title": "Kupong 200 kr"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Spännande gåva varje år"
|
"title": "Spännande gåva varje år"
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import React, { Children, cloneElement, useEffect, useState } from "react"
|
|
||||||
|
|
||||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
|
||||||
import { SidePeekContentKey } from "@/components/TempDesignSystem/SidePeek/types"
|
|
||||||
|
|
||||||
export default function SidePeekContainer({
|
|
||||||
children,
|
|
||||||
}: React.PropsWithChildren) {
|
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const [activeSidePeek, setActiveSidePeek] =
|
|
||||||
useState<SidePeekContentKey | null>(() => {
|
|
||||||
const sidePeekParam = searchParams.get(
|
|
||||||
"sidepeek"
|
|
||||||
) as SidePeekContentKey | null
|
|
||||||
return sidePeekParam || null
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sidePeekParam = searchParams.get(
|
|
||||||
"sidepeek"
|
|
||||||
) as SidePeekContentKey | null
|
|
||||||
if (sidePeekParam !== activeSidePeek) {
|
|
||||||
setActiveSidePeek(sidePeekParam)
|
|
||||||
}
|
|
||||||
}, [searchParams, activeSidePeek])
|
|
||||||
|
|
||||||
function handleClose(isOpen: boolean) {
|
|
||||||
if (!isOpen) {
|
|
||||||
setActiveSidePeek(null)
|
|
||||||
|
|
||||||
const nextSearchParams = new URLSearchParams(searchParams.toString())
|
|
||||||
nextSearchParams.delete("sidepeek")
|
|
||||||
|
|
||||||
router.push(`${pathname}?${nextSearchParams}`, { scroll: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const childrenWithIsActive = Children.map(children, (child) => {
|
|
||||||
if (!React.isValidElement(child)) {
|
|
||||||
return child
|
|
||||||
}
|
|
||||||
return cloneElement(child as React.ReactElement, {
|
|
||||||
isActive:
|
|
||||||
(child.props.contentKey as SidePeekContentKey) === activeSidePeek,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidePeek activeContent={activeSidePeek} onClose={handleClose}>
|
|
||||||
{childrenWithIsActive}
|
|
||||||
</SidePeek>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Children, PropsWithChildren } from "react"
|
import { PropsWithChildren } from "react"
|
||||||
|
|
||||||
import { CloseIcon } from "@/components/Icons"
|
import { CloseIcon } from "@/components/Icons"
|
||||||
import { SidePeekContentProps } from "@/components/TempDesignSystem/SidePeek/types"
|
import { SidePeekContentProps } from "@/components/TempDesignSystem/SidePeek/types"
|
||||||
@@ -8,17 +8,16 @@ import Title from "@/components/TempDesignSystem/Text/Title"
|
|||||||
|
|
||||||
import Button from "../../Button"
|
import Button from "../../Button"
|
||||||
|
|
||||||
import styles from "./content.module.css"
|
import styles from "./sidePeekItem.module.css"
|
||||||
|
|
||||||
export default function Content({
|
function SidePeekItem({
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
contentKey,
|
|
||||||
isActive = false,
|
isActive = false,
|
||||||
onClose,
|
onClose,
|
||||||
}: PropsWithChildren<SidePeekContentProps>) {
|
}: PropsWithChildren<SidePeekContentProps>) {
|
||||||
return isActive ? (
|
return isActive ? (
|
||||||
<aside className={styles.content}>
|
<aside className={styles.sidePeekItem}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<Title color="burgundy" textTransform="uppercase" level="h2" as="h3">
|
<Title color="burgundy" textTransform="uppercase" level="h2" as="h3">
|
||||||
{title}
|
{title}
|
||||||
@@ -36,3 +35,5 @@ export default function Content({
|
|||||||
</aside>
|
</aside>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default SidePeekItem
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
.content {
|
.sidePeekItem {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: min-content auto;
|
grid-template-rows: min-content auto;
|
||||||
gap: var(--Spacing-x4);
|
gap: var(--Spacing-x4);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content > * {
|
.content>* {
|
||||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media and screen (min-width: 1367px) {
|
@media screen and (min-width: 1367px) {
|
||||||
.content > * {
|
.content>* {
|
||||||
padding: var(--Spacing-x4);
|
padding: var(--Spacing-x4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { SidePeekContentKey } from "./types"
|
|
||||||
|
|
||||||
export const generateSidePeekLink = (key: SidePeekContentKey) => {
|
|
||||||
return `?sidepeek=${key}`
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,53 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Children, cloneElement, PropsWithChildren } from "react"
|
import { useIsSSR } from "@react-aria/ssr"
|
||||||
import { Dialog, DialogTrigger, Modal } from "react-aria-components"
|
import React, { Children, cloneElement } from "react"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
} from "react-aria-components"
|
||||||
|
|
||||||
import { SidePeekProps } from "./types"
|
import { SidePeekContentKey } from "@/components/TempDesignSystem/SidePeek/types"
|
||||||
|
|
||||||
import styles from "./sidePeek.module.css"
|
import styles from "./sidePeek.module.css"
|
||||||
|
|
||||||
export default function SidePeek({
|
import type { SidePeekProps } from "./sidePeek"
|
||||||
|
|
||||||
|
function SidePeek({
|
||||||
children,
|
children,
|
||||||
onClose,
|
handleClose,
|
||||||
activeContent,
|
activeSidePeek,
|
||||||
}: PropsWithChildren<SidePeekProps>) {
|
}: React.PropsWithChildren<SidePeekProps>) {
|
||||||
return (
|
const sidePeekChildren = Children.map(children, (child) => {
|
||||||
|
if (!React.isValidElement(child)) {
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
return cloneElement(child as React.ReactElement, {
|
||||||
|
isActive:
|
||||||
|
(child.props.contentKey as SidePeekContentKey) === activeSidePeek,
|
||||||
|
onClose: handleClose,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSSR = useIsSSR()
|
||||||
|
return isSSR ? (
|
||||||
|
<div>{children}</div>
|
||||||
|
) : (
|
||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
<Modal
|
<ModalOverlay
|
||||||
className={styles.sidePeek}
|
className={styles.overlay}
|
||||||
isOpen={!!activeContent}
|
isOpen={!!activeSidePeek}
|
||||||
onOpenChange={onClose}
|
onOpenChange={handleClose}
|
||||||
|
isDismissable
|
||||||
>
|
>
|
||||||
<Dialog className={styles.dialog}>
|
<Modal className={styles.sidePeek}>
|
||||||
{({ close }) => (
|
<Dialog className={styles.dialog}>{sidePeekChildren}</Dialog>
|
||||||
<>
|
</Modal>
|
||||||
{Children.map(children, (child) => {
|
</ModalOverlay>
|
||||||
return cloneElement(child as React.ReactElement, {
|
|
||||||
onClose: close,
|
|
||||||
})
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
</Modal>
|
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default SidePeek
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 70.047px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
@keyframes slide-in {
|
@keyframes slide-in {
|
||||||
from {
|
from {
|
||||||
right: -600px;
|
right: -600px;
|
||||||
@@ -56,4 +64,7 @@
|
|||||||
.sidePeek[data-exiting] {
|
.sidePeek[data-exiting] {
|
||||||
animation: slide-in 250ms reverse;
|
animation: slide-in 250ms reverse;
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
components/TempDesignSystem/SidePeek/sidePeek.ts
Normal file
4
components/TempDesignSystem/SidePeek/sidePeek.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface SidePeekProps {
|
||||||
|
handleClose: (isOpen: boolean) => void
|
||||||
|
activeSidePeek: string | null
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type SidePeekContentKey = "amenities" | "read_more_about_the_hotel"
|
export type SidePeekContentKey = string
|
||||||
|
|
||||||
export type SidePeekProps = {
|
export type SidePeekProps = {
|
||||||
activeContent: string | null
|
activeContent: string | null
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
border-radius: var(--Corner-radius-Medium);
|
border-radius: var(--Corner-radius-Medium);
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thead {
|
.thead {
|
||||||
|
|||||||
12
constants/auth.ts
Normal file
12
constants/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* How many seconds before the expiry of the access token
|
||||||
|
* the client should refresh the tokens
|
||||||
|
*/
|
||||||
|
export const PRE_REFRESH_TIME_IN_SECONDS = 30
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many minutes the client should be allowed to be inactive
|
||||||
|
* and still get the tokens refreshed. The inactivity control
|
||||||
|
* gets reset when the URL changes.
|
||||||
|
*/
|
||||||
|
export const MAX_KEEP_ALIVE_TIME_IN_MINUTES = 2 * 60
|
||||||
@@ -22,4 +22,18 @@ export const logout = {
|
|||||||
sv: "/sv/logga-ut",
|
sv: "/sv/logga-ut",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handleAuth = [...Object.values(login), ...Object.values(logout)]
|
/** @type {import('@/types/routes').LangRoute} */
|
||||||
|
export const verifymagiclink = {
|
||||||
|
da: "/da/verifymagiclink",
|
||||||
|
de: "/de/verifymagiclink",
|
||||||
|
en: "/en/verifymagiclink",
|
||||||
|
fi: "/fi/verifymagiclink",
|
||||||
|
no: "/no/verifymagiclink",
|
||||||
|
sv: "/sv/verifymagiclink",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleAuth = [
|
||||||
|
...Object.values(login),
|
||||||
|
...Object.values(logout),
|
||||||
|
...Object.values(verifymagiclink),
|
||||||
|
]
|
||||||
|
|||||||
21
constants/routes/hotelPageParams.js
Normal file
21
constants/routes/hotelPageParams.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export const about = {
|
||||||
|
en: "about",
|
||||||
|
sv: "om-hotellet",
|
||||||
|
no: "om-hotellet",
|
||||||
|
fi: "hotellista",
|
||||||
|
da: "om-hotellet",
|
||||||
|
de: "uber-das-hotel",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const amenities = {
|
||||||
|
en: "amenities",
|
||||||
|
sv: "bekvamligheter",
|
||||||
|
no: "fasiliteter",
|
||||||
|
da: "faciliteter",
|
||||||
|
fi: "palvelut",
|
||||||
|
de: "annehmlichkeiten",
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = { about, amenities }
|
||||||
|
|
||||||
|
export default params
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"Close": "Schließen",
|
"Close": "Schließen",
|
||||||
"Coming up": "Demnächst",
|
"Coming up": "Demnächst",
|
||||||
"Compare all levels": "Vergleichen Sie alle Levels",
|
"Compare all levels": "Vergleichen Sie alle Levels",
|
||||||
"Contact us": "Kontaktiere uns",
|
"Contact us": "Kontaktieren Sie uns",
|
||||||
"Continue": "Weitermachen",
|
"Continue": "Weitermachen",
|
||||||
"Could not find requested resource": "Die angeforderte Ressource konnte nicht gefunden werden.",
|
"Could not find requested resource": "Die angeforderte Ressource konnte nicht gefunden werden.",
|
||||||
"Country": "Land",
|
"Country": "Land",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"Address": "Adress",
|
"Address": "Adress",
|
||||||
"All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.": "Alla våra sängar är från Bliss, med möjlighet att justera fastheten för perfekt komfort.",
|
"All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.": "Alla våra sängar är från Bliss, med möjlighet att justera fastheten för perfekt komfort.",
|
||||||
"All rooms comes with standard amenities": "Alla rum har standardbekvämligheter",
|
"All rooms comes with standard amenities": "Alla rum har standardbekvämligheter",
|
||||||
"Already a friend?": "Redan en vän?",
|
"Already a friend?": "Är du redan en vän?",
|
||||||
"Amenities": "Bekvämligheter",
|
"Amenities": "Bekvämligheter",
|
||||||
"Arrival date": "Ankomstdatum",
|
"Arrival date": "Ankomstdatum",
|
||||||
"as of today": "från och med idag",
|
"as of today": "från och med idag",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as authRequired from "./middlewares/authRequired"
|
|||||||
import * as bookingFlow from "./middlewares/bookingFlow"
|
import * as bookingFlow from "./middlewares/bookingFlow"
|
||||||
import * as cmsContent from "./middlewares/cmsContent"
|
import * as cmsContent from "./middlewares/cmsContent"
|
||||||
import * as currentWebLogin from "./middlewares/currentWebLogin"
|
import * as currentWebLogin from "./middlewares/currentWebLogin"
|
||||||
|
import * as currentWebLoginEmail from "./middlewares/currentWebLoginEmail"
|
||||||
import * as currentWebLogout from "./middlewares/currentWebLogout"
|
import * as currentWebLogout from "./middlewares/currentWebLogout"
|
||||||
import * as handleAuth from "./middlewares/handleAuth"
|
import * as handleAuth from "./middlewares/handleAuth"
|
||||||
import * as myPages from "./middlewares/myPages"
|
import * as myPages from "./middlewares/myPages"
|
||||||
@@ -31,6 +32,7 @@ export const middleware: NextMiddleware = async (request, event) => {
|
|||||||
|
|
||||||
const middlewares = [
|
const middlewares = [
|
||||||
currentWebLogin,
|
currentWebLogin,
|
||||||
|
currentWebLoginEmail,
|
||||||
currentWebLogout,
|
currentWebLogout,
|
||||||
authRequired,
|
authRequired,
|
||||||
handleAuth,
|
handleAuth,
|
||||||
|
|||||||
33
middlewares/currentWebLoginEmail.ts
Normal file
33
middlewares/currentWebLoginEmail.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { badRequest } from "@/server/errors/next"
|
||||||
|
|
||||||
|
import { findLang } from "@/utils/languages"
|
||||||
|
|
||||||
|
import type { NextMiddleware } from "next/server"
|
||||||
|
|
||||||
|
import type { MiddlewareMatcher } from "@/types/middleware"
|
||||||
|
|
||||||
|
export const middleware: NextMiddleware = (request) => {
|
||||||
|
const returnUrl = request.nextUrl.searchParams.get("returnurl")
|
||||||
|
|
||||||
|
if (!returnUrl) {
|
||||||
|
return badRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = findLang(request.nextUrl.pathname)!
|
||||||
|
|
||||||
|
const headers = new Headers(request.headers)
|
||||||
|
headers.set("x-returnurl", returnUrl)
|
||||||
|
headers.set("x-magic-link", "1")
|
||||||
|
|
||||||
|
return NextResponse.rewrite(new URL(`/${lang}/login`, request.nextUrl), {
|
||||||
|
request: {
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const matcher: MiddlewareMatcher = (request) => {
|
||||||
|
return request.nextUrl.pathname.endsWith("/updateloginemail")
|
||||||
|
}
|
||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@netlify/plugin-nextjs": "^5.1.1",
|
"@netlify/plugin-nextjs": "^5.1.1",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@react-aria/ssr": "^3.9.5",
|
||||||
"@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.8",
|
"@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.8",
|
||||||
"@t3-oss/env-nextjs": "^0.9.2",
|
"@t3-oss/env-nextjs": "^0.9.2",
|
||||||
"@tanstack/react-query": "^5.28.6",
|
"@tanstack/react-query": "^5.28.6",
|
||||||
@@ -4317,10 +4318,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/ssr": {
|
"node_modules/@react-aria/ssr": {
|
||||||
"version": "3.9.2",
|
"version": "3.9.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.5.tgz",
|
||||||
"integrity": "sha512-0gKkgDYdnq1w+ey8KzG9l+H5Z821qh9vVjztk55rUg71vTk/Eaebeir+WtzcLLwTjw3m/asIjx8Y59y1lJZhBw==",
|
"integrity": "sha512-xEwGKoysu+oXulibNUSkXf8itW0npHHTa6c4AyYeZIJyRoegeteYuFpZUBPtIDE8RfHdNsSmE1ssOkxRnwbkuQ==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
},
|
},
|
||||||
@@ -4328,7 +4328,7 @@
|
|||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/switch": {
|
"node_modules/@react-aria/switch": {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@netlify/plugin-nextjs": "^5.1.1",
|
"@netlify/plugin-nextjs": "^5.1.1",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@react-aria/ssr": "^3.9.5",
|
||||||
"@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.8",
|
"@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.8",
|
||||||
"@t3-oss/env-nextjs": "^0.9.2",
|
"@t3-oss/env-nextjs": "^0.9.2",
|
||||||
"@tanstack/react-query": "^5.28.6",
|
"@tanstack/react-query": "^5.28.6",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import type {
|
|||||||
async function getVerifiedUser({ session }: { session: Session }) {
|
async function getVerifiedUser({ session }: { session: Session }) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
if (session.token.expires_at < now) {
|
if (session.token.expires_at && session.token.expires_at < now) {
|
||||||
return { error: true, cause: "token_expired" } as const
|
return { error: true, cause: "token_expired" } as const
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type TrackingSDKPageData = {
|
|||||||
export enum LoginTypeEnum {
|
export enum LoginTypeEnum {
|
||||||
email = "email",
|
email = "email",
|
||||||
"membership number" = "membership number",
|
"membership number" = "membership number",
|
||||||
// MagicLink = "magic link",
|
"email link" = "email link",
|
||||||
}
|
}
|
||||||
export type LoginType = keyof typeof LoginTypeEnum
|
export type LoginType = keyof typeof LoginTypeEnum
|
||||||
|
|
||||||
|
|||||||
2
types/jwt.d.ts
vendored
2
types/jwt.d.ts
vendored
@@ -10,7 +10,7 @@ declare module "next-auth/jwt" {
|
|||||||
/** Returned by the `jwt` callback and `auth`, when using JWT sessions */
|
/** Returned by the `jwt` callback and `auth`, when using JWT sessions */
|
||||||
interface JWT extends DefaultJWT, RefreshTokenError {
|
interface JWT extends DefaultJWT, RefreshTokenError {
|
||||||
access_token: string
|
access_token: string
|
||||||
expires_at: number
|
expires_at?: number
|
||||||
refresh_token: string
|
refresh_token: string
|
||||||
loginType: LoginType
|
loginType: LoginType
|
||||||
mfa_scope: boolean
|
mfa_scope: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user