Merge branch 'feat/SW-266-seo-loyalty-pages' of https://bitbucket.org/scandic-swap/web into feat/SW-266-seo-loyalty-pages

This commit is contained in:
Pontus Dreij
2024-08-20 10:58:05 +02:00
33 changed files with 536 additions and 235 deletions

13
Auth.md Normal file
View 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.

View File

@@ -17,6 +17,13 @@ export async function GET(
const returnUrl = request.headers.get("x-returnurl")
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) {
// Seamless login request from Current web
redirectTo = returnUrl
@@ -29,9 +36,6 @@ export async function GET(
// Make relative URL to absolute URL
if (redirectTo.startsWith("/")) {
if (!env.PUBLIC_URL) {
throw internalServerError("No value for env.PUBLIC_URL")
}
redirectTo = new URL(redirectTo, env.PUBLIC_URL).href
}
@@ -68,6 +72,14 @@ export async function GET(
const redirectUrl = new URL(redirectUrlValue)
redirectUrl.searchParams.set("returnurl", redirectTo)
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) {
console.error(
"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_redirectTo: redirectTo })
const params = isMFA
? {
ui_locales: context.params.lang,
scope: ["profile_update", "openid", "profile"].join(" "),
/**
* 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
*/
acr_values:
"urn:se:curity:authentication:otp-authenticator:OTP-Authenticator_web",
}
: {
ui_locales: context.params.lang,
scope: ["openid", "profile"].join(" "),
/**
* The `acr_values` param is used to make Curity display the proper login
* page for Scandic. Without the parameter Curity presents some choices
* to the user which we do not want.
*/
acr_values: "acr",
}
const params = {
ui_locales: context.params.lang,
scope: ["openid", "profile"].join(" "),
/**
* The `acr_values` param is used to make Curity display the proper login
* page for Scandic. Without the parameter Curity presents some choices
* to the user which we do not want.
*/
acr_values: "acr",
/**
* Both of the below two params are required to send for initiating login as well
* because user might choose to do Email link login.
* */
// The `for_origin` param is used to make Curity email login functionality working.
for_origin: env.PUBLIC_URL,
// This is new param set for differentiate between the Magic link login of New web and current web
version: "2",
}
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(
"curity",
{

View 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()
}

View File

@@ -5,6 +5,7 @@ import Script from "next/script"
import TrpcProvider from "@/lib/trpc/Provider"
import TokenRefresher from "@/components/Auth/TokenRefresher"
import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
import Footer from "@/components/Current/Footer"
import VwoScript from "@/components/Current/VwoScript"
@@ -55,6 +56,7 @@ export default async function RootLayout({
{header}
{children}
<Footer />
<TokenRefresher />
</TrpcProvider>
</ServerIntlProvider>
</body>

View File

@@ -3,6 +3,7 @@ import "@scandic-hotels/design-system/style.css"
import Script from "next/script"
import TokenRefresher from "@/components/Auth/TokenRefresher"
import AdobeScript from "@/components/Current/AdobeScript"
import Footer from "@/components/Current/Footer"
import Header from "@/components/Current/Header"
@@ -72,6 +73,7 @@ export default async function RootLayout({
/>
{children}
<Footer />
<TokenRefresher />
</ServerIntlProvider>
<Script id="page-tracking">{`
typeof _satellite !== "undefined" && _satellite.pageBottom();

143
auth.ts
View File

@@ -1,25 +1,84 @@
import NextAuth from "next-auth"
import { PRE_REFRESH_TIME_IN_SECONDS } from "@/constants/auth"
import { env } from "@/env/server"
import { LoginTypeEnum } from "./types/components/tracking"
import type { NextAuthConfig, User } from "next-auth"
import type { JWT } from "next-auth/jwt"
import type { OIDCConfig } from "next-auth/providers"
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("@")) {
return LoginTypeEnum.email
} else if (user?.login_with.toLowerCase() == LoginTypeEnum["email link"]) {
return LoginTypeEnum["email link"]
} else {
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 = {
id: "curity",
name: "Curity",
@@ -103,7 +162,7 @@ export const config = {
async authorized({ auth, request }) {
return true
},
async jwt({ account, token, trigger, user, profile }) {
async jwt({ account, session, token, trigger, user, profile }) {
const loginType = getLoginType(user)
if (trigger === "signIn" && account) {
const mfa_scope =
@@ -121,71 +180,15 @@ export const config = {
mfa_scope: mfa_scope,
mfa_expires_at: mfa_expires_at,
}
} else if (Date.now() < token.expires_at) {
return token
} else {
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,
})
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,
}
}
} else if (
token.expires_at &&
Date.now() > token.expires_at - PRE_REFRESH_TIME_IN_SECONDS * 1000 &&
session?.doRefresh
) {
return refreshTokens(token)
}
return token
},
},
// events: {

View 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
}

View File

@@ -1,12 +1,12 @@
import { amenities } from "@/constants/routes/hotelPageParams"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { ChevronRightIcon } from "@/components/Icons"
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 Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./amenitiesList.module.css"
@@ -17,15 +17,15 @@ export default async function AmenitiesList({
}: {
detailedFacilities: HotelData["data"]["attributes"]["detailedFacilities"]
}) {
const { formatMessage } = await getIntl()
const sidePeekLink = generateSidePeekLink("amenities")
const intl = await getIntl()
const sortedAmenities = detailedFacilities
.sort((a, b) => b.sortOrder - a.sortOrder)
.slice(0, 5)
const lang = getLang()
return (
<section className={styles.amenitiesContainer}>
<Subtitle type="two" color="black">
{formatMessage({ id: "At the hotel" })}
{intl.formatMessage({ id: "At the hotel" })}
</Subtitle>
<div className={styles.amenityItemList}>
{sortedAmenities.map((facility) => {
@@ -38,18 +38,15 @@ export default async function AmenitiesList({
)
})}
</div>
<Link scroll={false} href={sidePeekLink} color="burgundy" variant="icon">
{formatMessage({ id: "Show all amenities" })}
<Link
scroll={false}
href={`?s=${amenities[lang]}`}
color="burgundy"
variant="icon"
>
{intl.formatMessage({ id: "Show all amenities" })}
<ChevronRightIcon />
</Link>
<SidePeekContainer>
<SidePeekContent
contentKey={"amenities"}
title={formatMessage({ id: "Amenities" })}
>
{/* TODO: Render amenities as per the design. */}
</SidePeekContent>
</SidePeekContainer>
</section>
)
}

View File

@@ -5,6 +5,7 @@ import { getLang } from "@/i18n/serverContext"
import AmenitiesList from "./AmenitiesList"
import IntroSection from "./IntroSection"
import { Rooms } from "./Rooms"
import SidePeeks from "./SidePeeks"
import TabNavigation from "./TabNavigation"
import styles from "./hotelPage.module.css"
@@ -16,10 +17,10 @@ export default async function HotelPage() {
if (!hotelPageIdentifierData) {
return null
}
const lang = getLang()
const { attributes, roomCategories } = await serverClient().hotel.getHotel({
hotelId: hotelPageIdentifierData.hotel_page_id,
language: getLang(),
language: lang,
include: ["RoomCategories"],
})
@@ -35,6 +36,7 @@ export default async function HotelPage() {
address={attributes.address}
tripAdvisor={attributes.ratings.tripAdvisor}
/>
<SidePeeks />
<AmenitiesList detailedFacilities={attributes.detailedFacilities} />
</div>
<Rooms rooms={roomCategories} />

View File

@@ -1,3 +1,5 @@
import { about } from "@/constants/routes/hotelPageParams"
import ArrowRight from "@/components/Icons/ArrowRight"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
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 Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { IntroSectionProps } from "./types"
@@ -19,15 +22,15 @@ export default async function IntroSection({
tripAdvisor,
}: IntroSectionProps) {
const intl = await getIntl()
const { formatMessage } = intl
const { streetAddress, city } = address
const { distanceToCentre } = location
const formattedDistanceText = formatMessage(
const formattedDistanceText = intl.formatMessage(
{ id: "Distance to city centre" },
{ number: distanceToCentre }
)
const lang = getLang()
const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})`
const formattedTripAdvisorText = formatMessage(
const formattedTripAdvisorText = intl.formatMessage(
{ id: "Tripadvisor reviews" },
{ rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews }
)
@@ -37,7 +40,7 @@ export default async function IntroSection({
<div className={styles.mainContent}>
<div className={styles.titleContainer}>
<BiroScript tilted="medium" color="red">
{formatMessage({ id: "Welcome to" })}:
{intl.formatMessage({ id: "Welcome to" })}:
</BiroScript>
<Title level="h2">{hotelName}</Title>
</div>
@@ -58,14 +61,12 @@ export default async function IntroSection({
<Preamble>{hotelDescription}</Preamble>
<Link
className={styles.introLink}
target="_blank"
color="peach80"
textDecoration="underline"
variant="icon"
href="#"
href={`?s=${about[lang]}`}
>
{/*TODO: Ask content team where this should link to. */}
{formatMessage({ id: "Read more about the hotel" })}
{intl.formatMessage({ id: "Read more about the hotel" })}
<ArrowRight color="peach80" />
</Link>
</div>

View 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

View File

@@ -38,7 +38,7 @@
"title": "Sen utcheckning 1 timme, i mån av plats"
},
{
"title": "Voucher 50 kr"
"title": "Kupong 50 kr"
}
]
},
@@ -49,13 +49,13 @@
"requiredNights": 0,
"benefits": [
{
"title": "25% poängboost"
"title": "25 % poängboost"
},
{
"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": "Voucher 100 kr"
"title": "Kupong 100 kr"
},
{
"title": "Frukost 2 för 1"
@@ -83,10 +83,10 @@
"requiredNights": 0,
"benefits": [
{
"title": "50% poängboost"
"title": "50 % poängboost"
},
{
"title": "Voucher 150 kr"
"title": "Kupong 150 kr"
},
{
"title": "48 timmars rumsgaranti"
@@ -103,7 +103,7 @@
"requiredNights": 100,
"benefits": [
{
"title": "Voucher 200 kr"
"title": "Kupong 200 kr"
},
{
"title": "Spännande gåva varje år"

View File

@@ -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>
)
}

View File

@@ -1,6 +1,6 @@
"use client"
import { Children, PropsWithChildren } from "react"
import { PropsWithChildren } from "react"
import { CloseIcon } from "@/components/Icons"
import { SidePeekContentProps } from "@/components/TempDesignSystem/SidePeek/types"
@@ -8,17 +8,16 @@ import Title from "@/components/TempDesignSystem/Text/Title"
import Button from "../../Button"
import styles from "./content.module.css"
import styles from "./sidePeekItem.module.css"
export default function Content({
function SidePeekItem({
title,
children,
contentKey,
isActive = false,
onClose,
}: PropsWithChildren<SidePeekContentProps>) {
return isActive ? (
<aside className={styles.content}>
<aside className={styles.sidePeekItem}>
<header className={styles.header}>
<Title color="burgundy" textTransform="uppercase" level="h2" as="h3">
{title}
@@ -36,3 +35,5 @@ export default function Content({
</aside>
) : null
}
export default SidePeekItem

View File

@@ -1,11 +1,11 @@
.content {
.sidePeekItem {
display: grid;
grid-template-rows: min-content auto;
gap: var(--Spacing-x4);
height: 100%;
}
.content > * {
.content>* {
padding: var(--Spacing-x3) var(--Spacing-x2);
}
@@ -20,8 +20,8 @@
justify-content: space-between;
}
@media and screen (min-width: 1367px) {
.content > * {
@media screen and (min-width: 1367px) {
.content>* {
padding: var(--Spacing-x4);
}
}
}

View File

@@ -1,5 +0,0 @@
import { SidePeekContentKey } from "./types"
export const generateSidePeekLink = (key: SidePeekContentKey) => {
return `?sidepeek=${key}`
}

View File

@@ -1,36 +1,53 @@
"use client"
import { Children, cloneElement, PropsWithChildren } from "react"
import { Dialog, DialogTrigger, Modal } from "react-aria-components"
import { useIsSSR } from "@react-aria/ssr"
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"
export default function SidePeek({
import type { SidePeekProps } from "./sidePeek"
function SidePeek({
children,
onClose,
activeContent,
}: PropsWithChildren<SidePeekProps>) {
return (
handleClose,
activeSidePeek,
}: React.PropsWithChildren<SidePeekProps>) {
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>
<Modal
className={styles.sidePeek}
isOpen={!!activeContent}
onOpenChange={onClose}
<ModalOverlay
className={styles.overlay}
isOpen={!!activeSidePeek}
onOpenChange={handleClose}
isDismissable
>
<Dialog className={styles.dialog}>
{({ close }) => (
<>
{Children.map(children, (child) => {
return cloneElement(child as React.ReactElement, {
onClose: close,
})
})}
</>
)}
</Dialog>
</Modal>
<Modal className={styles.sidePeek}>
<Dialog className={styles.dialog}>{sidePeekChildren}</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
)
}
export default SidePeek

View File

@@ -22,6 +22,14 @@
height: 100%;
}
.overlay {
position: absolute;
top: 70.047px;
bottom: 0;
left: 0;
right: 0;
z-index: 99;
}
@keyframes slide-in {
from {
right: -600px;
@@ -56,4 +64,7 @@
.sidePeek[data-exiting] {
animation: slide-in 250ms reverse;
}
.overlay {
top: 0;
}
}

View File

@@ -0,0 +1,4 @@
export interface SidePeekProps {
handleClose: (isOpen: boolean) => void
activeSidePeek: string | null
}

View File

@@ -1,4 +1,4 @@
export type SidePeekContentKey = "amenities" | "read_more_about_the_hotel"
export type SidePeekContentKey = string
export type SidePeekProps = {
activeContent: string | null

View File

@@ -2,6 +2,7 @@
border-radius: var(--Corner-radius-Medium);
border-collapse: collapse;
overflow: hidden;
width: 100%;
}
.thead {

12
constants/auth.ts Normal file
View 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

View File

@@ -22,4 +22,18 @@ export const logout = {
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),
]

View 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

View File

@@ -25,7 +25,7 @@
"Close": "Schließen",
"Coming up": "Demnächst",
"Compare all levels": "Vergleichen Sie alle Levels",
"Contact us": "Kontaktiere uns",
"Contact us": "Kontaktieren Sie uns",
"Continue": "Weitermachen",
"Could not find requested resource": "Die angeforderte Ressource konnte nicht gefunden werden.",
"Country": "Land",

View File

@@ -5,7 +5,7 @@
"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 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",
"Arrival date": "Ankomstdatum",
"as of today": "från och med idag",

View File

@@ -5,6 +5,7 @@ import * as authRequired from "./middlewares/authRequired"
import * as bookingFlow from "./middlewares/bookingFlow"
import * as cmsContent from "./middlewares/cmsContent"
import * as currentWebLogin from "./middlewares/currentWebLogin"
import * as currentWebLoginEmail from "./middlewares/currentWebLoginEmail"
import * as currentWebLogout from "./middlewares/currentWebLogout"
import * as handleAuth from "./middlewares/handleAuth"
import * as myPages from "./middlewares/myPages"
@@ -31,6 +32,7 @@ export const middleware: NextMiddleware = async (request, event) => {
const middlewares = [
currentWebLogin,
currentWebLoginEmail,
currentWebLogout,
authRequired,
handleAuth,

View 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
View File

@@ -15,6 +15,7 @@
"@hookform/resolvers": "^3.3.4",
"@netlify/plugin-nextjs": "^5.1.1",
"@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",
"@t3-oss/env-nextjs": "^0.9.2",
"@tanstack/react-query": "^5.28.6",
@@ -4317,10 +4318,9 @@
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.2.tgz",
"integrity": "sha512-0gKkgDYdnq1w+ey8KzG9l+H5Z821qh9vVjztk55rUg71vTk/Eaebeir+WtzcLLwTjw3m/asIjx8Y59y1lJZhBw==",
"peer": true,
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.5.tgz",
"integrity": "sha512-xEwGKoysu+oXulibNUSkXf8itW0npHHTa6c4AyYeZIJyRoegeteYuFpZUBPtIDE8RfHdNsSmE1ssOkxRnwbkuQ==",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
@@ -4328,7 +4328,7 @@
"node": ">= 12"
},
"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": {

View File

@@ -31,6 +31,7 @@
"@hookform/resolvers": "^3.3.4",
"@netlify/plugin-nextjs": "^5.1.1",
"@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",
"@t3-oss/env-nextjs": "^0.9.2",
"@tanstack/react-query": "^5.28.6",

View File

@@ -33,7 +33,7 @@ import type {
async function getVerifiedUser({ session }: { session: Session }) {
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
}

View File

@@ -20,7 +20,7 @@ export type TrackingSDKPageData = {
export enum LoginTypeEnum {
email = "email",
"membership number" = "membership number",
// MagicLink = "magic link",
"email link" = "email link",
}
export type LoginType = keyof typeof LoginTypeEnum

2
types/jwt.d.ts vendored
View File

@@ -10,7 +10,7 @@ declare module "next-auth/jwt" {
/** Returned by the `jwt` callback and `auth`, when using JWT sessions */
interface JWT extends DefaultJWT, RefreshTokenError {
access_token: string
expires_at: number
expires_at?: number
refresh_token: string
loginType: LoginType
mfa_scope: boolean