Merged in monorepo-step-1 (pull request #1080)

Migrate to a monorepo setup - step 1

* Move web to subfolder /apps/scandic-web

* Yarn + transitive deps

- Move to yarn
- design-system package removed for now since yarn doesn't
support the parameter for token (ie project currently broken)
- Add missing transitive dependencies as Yarn otherwise
prevents these imports
- VS Code doesn't pick up TS path aliases unless you open
/apps/scandic-web instead of root (will be fixed with monorepo)

* Pin framer-motion to temporarily fix typing issue

https://github.com/adobe/react-spectrum/issues/7494

* Pin zod to avoid typ error

There seems to have been a breaking change in the types
returned by zod where error is now returned as undefined
instead of missing in the type. We should just handle this
but to avoid merge conflicts just pin the dependency for
now.

* Pin react-intl version

Pin version of react-intl to avoid tiny type issue where formatMessage
does not accept a generic any more. This will be fixed in a future
commit, but to avoid merge conflicts just pin for now.

* Pin typescript version

Temporarily pin version as newer versions as stricter and results in
a type error. Will be fixed in future commit after merge.

* Setup workspaces

* Add design-system as a monorepo package

* Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN

* Fix husky for monorepo setup

* Update netlify.toml

* Add lint script to root package.json

* Add stub readme

* Fix react-intl formatMessage types

* Test netlify.toml in root

* Remove root toml

* Update netlify.toml publish path

* Remove package-lock.json

* Update build for branch/preview builds


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions

6
apps/scandic-web/.env Normal file
View File

@@ -0,0 +1,6 @@
NEXTAUTH_DEBUG="false"
DEBUG_QUERIES="false"
SEAMLESS_ENVIRONMENT_SUBDOMAIN="www"
SEAMLESS_LOGIN="https://${SEAMLESS_ENVIRONMENT_SUBDOMAIN}.scandichotels.{topleveldomain}/updatelogin"
SEAMLESS_LOGOUT="https://${SEAMLESS_ENVIRONMENT_SUBDOMAIN}.scandichotels.{topleveldomain}/updatelogout?newweb=1"

View File

@@ -0,0 +1,65 @@
ADOBE_SCRIPT_SRC=""
API_BASEURL="https://tstapi.scandichotels.com"
CMS_ACCESS_TOKEN=""
CMS_BRANCH="development"
CMS_API_KEY=""
CMS_ENVIRONMENT="development"
CMS_PREVIEW_TOKEN=""
CMS_PREVIEW_URL=""
CMS_URL="https://eu-graphql.contentstack.com/stacks/${CMS_API_KEY}?environment=${CMS_ENVIRONMENT}"
CURITY_CLIENT_ID_SERVICE=""
CURITY_CLIENT_SECRET_SERVICE=""
CURITY_CLIENT_ID_USER=""
CURITY_CLIENT_SECRET_USER=""
CURITY_ISSUER_USER="https://testlogin.scandichotels.com"
CURITY_ISSUER_SERVICE="https://testlogin.scandichotels.com"
CYPRESS_BASE_URL="http://localhost:3000"
# See next.config.js for info
DEPLOY_PRIME_URL="http://localhost:3000"
NEXTAUTH_REDIRECT_PROXY_URL="http://localhost:3000/api/web/auth"
NEXTAUTH_SECRET=""
REVALIDATE_SECRET=""
SITEMAP_SYNC_SECRET=""
SALESFORCE_PREFERENCE_BASE_URL="https://cloud.emails.scandichotels.com/preference-center"
SEAMLESS_LOGIN_DA="http://www.example.dk/updatelogin"
SEAMLESS_LOGIN_DE="http://www.example.de/updatelogin"
SEAMLESS_LOGIN_EN="http://www.example.com/updatelogin"
SEAMLESS_LOGIN_FI="http://www.example.fi/updatelogin"
SEAMLESS_LOGIN_NO="http://www.example.no/updatelogin"
SEAMLESS_LOGIN_SV="http://www.example.se/updatelogin"
SEAMLESS_LOGOUT_DA="http://www.example.dk/updatelogout?newweb=1"
SEAMLESS_LOGOUT_DE="http://www.example.de/updatelogout?newweb=1"
SEAMLESS_LOGOUT_EN="http://www.example.com/updatelogout?newweb=1"
SEAMLESS_LOGOUT_FI="http://www.example.fi/updatelogout?newweb=1"
SEAMLESS_LOGOUT_NO="http://www.example.no/updatelogout?newweb=1"
SEAMLESS_LOGOUT_SV="http://www.example.sv/updatelogout?newweb=1"
WEBVIEW_ENCRYPTION_KEY="MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI="
BOOKING_ENCRYPTION_KEY=""
// WEB-210 NodeJS v17+ uses OPENSSL 3.0 in which the encryption algorithm “DES-ECB” is deprecated and
// to enable support NODE_OPTIONS="--openssl-legacy-provider" is required.
NODE_OPTIONS="--openssl-legacy-provider"
// The runtime execution is happening in AWS functions as per Netlify setup, the AWS functions need another variable
// OPENSSL_MODULES="/var/lang/lib/ossl-modules" to enable legacy encryption support.
OPENSSL_MODULES="/var/lang/lib/ossl-modules"
PUBLIC_URL="http://localhost:3000"
AUTH_URL="$PUBLIC_URL/api/web/auth"
NEXTAUTH_URL="$PUBLIC_URL/api/web/auth"
GOOGLE_STATIC_MAP_KEY=""
GOOGLE_STATIC_MAP_SIGNATURE_SECRET=""
GOOGLE_STATIC_MAP_ID=""
GOOGLE_DYNAMIC_MAP_ID=""
NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE="false"
ENABLE_BOOKING_FLOW="false"
ENABLE_BOOKING_WIDGET="false"
ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH="false"
SHOW_SITE_WIDE_ALERT="false"
SHOW_SIGNUP_FLOW="true"
USE_NEW_REWARDS_ENDPOINT="true"
USE_NEW_REWARD_MODEL="true"

View File

@@ -0,0 +1,57 @@
ADOBE_SCRIPT_SRC="test"
API_BASEURL="test"
CMS_ENVIRONMENT="test"
CMS_ACCESS_TOKEN="test"
AUTH_URL="test"
CMS_API_KEY="test"
CMS_PREVIEW_TOKEN="test"
CMS_PREVIEW_URL="test"
CMS_URL="test"
CMS_BRANCH="development"
CURITY_CLIENT_ID_SERVICE="test"
CURITY_CLIENT_SECRET_SERVICE="test"
CURITY_CLIENT_ID_USER="test"
CURITY_CLIENT_SECRET_USER="test"
CURITY_ISSUER_USER="test"
CURITY_ISSUER_SERVICE="test"
CYPRESS_API_BASEURL="test"
CYPRESS_CURITY_USERNAME="test"
CYPRESS_CURITY_PASSWORD="test"
CYPRESS_BASE_URL="test"
DEPLOY_PRIME_URL="test"
NEXTAUTH_SECRET="test"
PUBLIC_URL="test"
NEXTAUTH_URL="test"
REVALIDATE_SECRET="test"
SEAMLESS_LOGIN_DA="test"
SEAMLESS_LOGIN_DE="test"
SEAMLESS_LOGIN_EN="test"
SEAMLESS_LOGIN_FI="test"
SEAMLESS_LOGIN_NO="test"
SEAMLESS_LOGIN_SV="test"
SEAMLESS_LOGOUT_DA="test"
SEAMLESS_LOGOUT_DE="test"
SEAMLESS_LOGOUT_EN="test"
SEAMLESS_LOGOUT_FI="test"
SEAMLESS_LOGOUT_NO="test"
SEAMLESS_LOGOUT_SV="test"
WEBVIEW_ENCRYPTION_KEY="test"
BOOKING_ENCRYPTION_KEY="test"
GOOGLE_STATIC_MAP_KEY="test"
GOOGLE_STATIC_MAP_SIGNATURE_SECRET="test"
GOOGLE_STATIC_MAP_ID="test"
GOOGLE_DYNAMIC_MAP_ID="test"
NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE="true"
SALESFORCE_PREFERENCE_BASE_URL="test"
USE_NEW_REWARDS_ENDPOINT="true"
USE_NEW_REWARD_MODEL="true"
TZ=UTC
ENABLE_BOOKING_FLOW="false"
ENABLE_BOOKING_WIDGET="false"
ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH="false"
SHOW_SITE_WIDE_ALERT="false"
NEXT_PUBLIC_SENTRY_ENVIRONMENT="test"
NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE="0"
SITEMAP_SYNC_SECRET="test

View File

@@ -0,0 +1,77 @@
{
"extends": ["next/core-web-vitals", "plugin:import/typescript"],
"plugins": ["simple-import-sort", "@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"rules": {
"no-unused-vars": "off",
"react/function-component-definition": "error",
"simple-import-sort/imports": [
"error",
{
"groups": [
// Side effect imports.
["^\\u0000"],
// Node.js builtins.
["^node:"],
// NPM packages.
["^@?\\w"],
// Internal packages.
["^@scandic-hotels/(?!.*\u0000$).*$"],
// Local imports (lib, constants, etc.), excl. types.
[
"^@/constants/?(?!.*\u0000$).*$",
"^@/env/?(?!.*\u0000$).*$",
"^@/lib/?(?!.*\u0000$).*$",
"^@/server/?(?!.*\u0000$).*$",
"^@/stores/?(?!.*\u0000$).*$"
],
// Local imports (the rest), excl. types.
["^@/(?!(types|.*\u0000$)).*$"],
// Parent imports. Put `..` last.
// Other relative imports. Put same-folder imports and `.` last.
[
"^\\.\\.(?!/?$)",
"^\\.\\./?$",
"^\\./(?=.*/)(?!/?$)",
"^\\.(?!/?$)",
"^\\./?$"
],
// Style imports.
["^(?!\\u0000).+\\.s?css$"],
// Node.js builtins and NPM packages type imports.
["^node:.*\\u0000$", "^@?\\w.*\\u0000$"],
// Local type imports.
[
"^@scandichotels/.*\\u0000$",
"^@/types/.*",
"^@/.*\\u0000$",
"^[^.].*\\u0000$",
"^\\..*\\u0000$"
]
]
}
],
"simple-import-sort/exports": "error",
"import/first": "error",
"import/newline-after-import": "error",
"import/no-duplicates": [
"error",
{
"prefer-inline": true
}
],
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
"args": "all",
"argsIgnorePattern": "^_",
"caughtErrors": "all",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"ignoreRestSiblings": true
}
]
}
}

60
apps/scandic-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,60 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
certificates
# Local Netlify folder
.netlify
#vscode
.vscode/
#cursor
.cursorrules
# localfile with all the CSS variables exported from design system
variables.css
# Sentry Config File
.env.sentry-build-plugin
# Yarn
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.yarn/releases

View File

@@ -0,0 +1,14 @@
# Directories
public/_static
public/*.svg
# Files
app/core.css
app/scandic.css
.env
.env.*
.prettierignore
netlify.toml
package.json
package-lock.json
.gitignore

13
apps/scandic-web/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

@@ -0,0 +1,28 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
- Firt, make sure you have built the `design-system` once:
```bash
yarn workspace @scandic-hotels/design-system build
```
- Create a `.env.local` file in the root of the `scandic-web` directory with the keys from the `.env.example` file.
- Then run the development server:
```bash
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!

View File

@@ -0,0 +1,162 @@
import { BedTypeEnum } from "@/constants/booking"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import type {
DetailsSchema,
RoomPrice,
RoomRate,
SignedInDetailsSchema,
} from "@/types/components/hotelReservation/enterDetails/details"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { CurrencyEnum } from "@/types/enums/currency"
import { PackageTypeEnum } from "@/types/enums/packages"
export const booking: SelectRateSearchParams = {
city: "Stockholm",
hotelId: "811",
fromDate: "2030-01-01",
toDate: "2030-01-03",
rooms: [
{
adults: 2,
roomTypeCode: "SKS",
rateCode: "",
counterRateCode: "",
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
packages: [RoomPackageCodeEnum.PET_ROOM],
},
{
adults: 2,
roomTypeCode: "SKS",
rateCode: "",
counterRateCode: "",
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
packages: [RoomPackageCodeEnum.PET_ROOM],
},
],
}
export const breakfastPackage: BreakfastPackage = {
code: "BRF1",
description: "Breakfast with reservation",
localPrice: { currency: "SEK", price: "99", totalPrice: "99" },
requestedPrice: {
currency: "EUR",
price: "9",
totalPrice: "9",
},
packageType: PackageTypeEnum.BreakfastAdult as const,
}
export const roomRate: RoomRate = {
memberRate: {
rateCode: "PLSA2BEU",
localPrice: {
pricePerNight: 1508,
pricePerStay: 1508,
currency: CurrencyEnum.SEK,
},
requestedPrice: {
pricePerNight: 132,
pricePerStay: 132,
currency: CurrencyEnum.EUR,
},
oldRateCode: "",
rate: "",
},
publicRate: {
rateCode: "SAVEEU",
localPrice: {
pricePerNight: 1525,
pricePerStay: 1525,
currency: CurrencyEnum.SEK,
},
requestedPrice: {
pricePerNight: 133,
pricePerStay: 133,
currency: CurrencyEnum.EUR,
},
oldRateCode: "",
rate: "",
},
}
export const roomPrice: RoomPrice = {
perNight: {
local: {
currency: "SEK",
price: 1525,
},
requested: {
currency: "EUR",
price: 133,
},
},
perStay: {
local: {
currency: "SEK",
price: 1525,
},
requested: {
currency: "EUR",
price: 133,
},
},
}
export const bedType: { [x: string]: BedTypeSelection } = {
king: {
type: BedTypeEnum.King,
description: "King-size bed",
value: "SKS",
size: {
min: 180,
max: 200,
},
extraBed: undefined,
},
queen: {
type: BedTypeEnum.Queen,
description: "Queen-size bed",
value: "QZ",
size: {
min: 160,
max: 200,
},
extraBed: undefined,
},
single: {
type: BedTypeEnum.Single,
description: "Single bed",
size: {
max: 140,
min: 100,
},
value: "CSR",
extraBed: undefined,
},
}
export const guestDetailsNonMember: DetailsSchema = {
join: false,
countryCode: "SE",
email: "tester@testersson.com",
firstName: "Test",
lastName: "Testersson",
phoneNumber: "72727272",
}
export const guestDetailsMember: SignedInDetailsSchema = {
join: false,
countryCode: "SE",
email: "tester@testersson.com",
firstName: "Test",
lastName: "Testersson",
phoneNumber: "72727272",
zipCode: "12345",
dateOfBirth: "1999-01-01",
membershipNo: "12421412211212",
}

View File

@@ -0,0 +1,224 @@
"use server"
import { z } from "zod"
import { ApiLang } from "@/constants/languages"
import * as api from "@/lib/api"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { protectedServerActionProcedure } from "@/server/trpc"
import { editProfileSchema } from "@/components/Forms/Edit/Profile/schema"
import { countriesMap } from "@/components/TempDesignSystem/Form/Country/countries"
import { getIntl } from "@/i18n"
import { getMembership } from "@/utils/user"
import { phoneValidator } from "@/utils/zod/phoneValidator"
import { Status } from "@/types/components/myPages/myProfile/edit"
const editProfilePayload = z
.object({
address: z.object({
city: z.string().optional(),
countryCode: z.nativeEnum(countriesMap),
streetAddress: z.string().optional(),
zipCode: z.string().min(1, { message: "Zip code is required" }),
}),
dateOfBirth: z.string(),
email: z.string().email(),
language: z.nativeEnum(ApiLang),
newPassword: z.string().optional(),
password: z.string().optional(),
phoneNumber: phoneValidator("Phone is required"),
})
.transform((data) => {
if (!data.password || !data.newPassword) {
delete data.password
delete data.newPassword
}
if (data.phoneNumber) {
data.phoneNumber = data.phoneNumber.replaceAll(" ", "").trim()
}
return data
})
export const editProfile = protectedServerActionProcedure
.input(editProfileSchema)
.mutation(async function ({ ctx, input }) {
const intl = await getIntl()
const payload = editProfilePayload.safeParse(input)
if (!payload.success) {
console.error(
"editProfile payload validation error",
JSON.stringify({
query: input,
error: payload.error,
})
)
return {
data: input,
issues: payload.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
message: intl.formatMessage({
id: "An error occured when trying to update profile.",
}),
status: Status.error,
}
}
const profile = await getProfile()
if (!profile || "error" in profile) {
console.error(
"editProfile profile fetch error",
JSON.stringify({
query: input,
error: profile?.error,
})
)
return {
data: input,
issues: [],
message: intl.formatMessage({
id: "An error occured when trying to update profile.",
}),
status: Status.error,
}
}
const body: Partial<z.infer<typeof editProfilePayload>> = {}
Object.keys(payload.data).forEach((key) => {
const typedKey = key as unknown as keyof z.infer<
typeof editProfilePayload
>
if (typedKey === "password" || typedKey === "newPassword") {
body[typedKey] = payload.data[typedKey]
return
}
if (typedKey === "address") {
if (
(payload.data.address.city === profile.address.city ||
(!payload.data.address.city && !profile.address.city)) &&
(payload.data.address.countryCode === profile.address.countryCode ||
(!payload.data.address.countryCode &&
!profile.address.countryCode)) &&
(payload.data.address.streetAddress ===
profile.address.streetAddress ||
(!payload.data.address.streetAddress &&
!profile.address.streetAddress)) &&
(payload.data.address.zipCode === profile.address.zipCode ||
(!payload.data.address.zipCode && !profile.address.zipCode))
) {
// untouched - noop
} else {
/** API clears all other fields not sent in address payload */
body.address = payload.data.address
}
return
}
// Handle case where dateOfBirth is missing in profile and not updated in form
if (
typedKey === "dateOfBirth" &&
!profile.dateOfBirth &&
payload.data.dateOfBirth === "1900-01-01"
) {
return
}
if (payload.data[typedKey] !== profile[typedKey]) {
// @ts-ignore
body[typedKey] = payload.data[typedKey]
}
})
if (Object.keys(body).length === 0) {
return {
data: input,
message: intl.formatMessage({ id: "Successfully updated profile!" }),
status: Status.success,
}
} else {
const membership = getMembership(profile.memberships)
console.log(
`[edit profile: ${membership?.membershipNumber}] body keys: ${JSON.stringify(Object.keys(body))}`
)
}
const apiResponse = await api.patch(api.endpoints.v1.Profile.profile, {
body,
cache: "no-store",
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
})
if (!apiResponse.ok) {
const text = await apiResponse.text()
console.error(
"editProfile api patch error",
JSON.stringify({
query: input,
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
error: text,
},
})
)
return {
data: input,
issues: [],
message: intl.formatMessage({
id: "An error occured when trying to update profile.",
}),
status: Status.error,
}
}
const json = await apiResponse.json()
if (json.errors?.length) {
json.errors.forEach((error: any) => {
console.warn(
"editProfile api patch errors (not aborting)",
JSON.stringify({
query: input,
error,
})
)
})
}
const validatedData = editProfileSchema.safeParse(json.data.attributes)
if (!validatedData.success) {
console.error(
"editProfile validation error",
JSON.stringify({
query: input,
error: validatedData.error,
})
)
return {
data: input,
issues: validatedData.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
message: intl.formatMessage({
id: "An error occured when trying to update profile.",
}),
status: Status.error,
}
}
return {
data: validatedData.data,
message: intl.formatMessage({ id: "Successfully updated profile!" }),
status: Status.success,
}
})

View File

@@ -0,0 +1,3 @@
import { ProtectedLayout } from "@/components/ProtectedLayout"
export default ProtectedLayout

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -0,0 +1,115 @@
import { type NextRequest, NextResponse } from "next/server"
import { AuthError } from "next-auth"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import { internalServerError } from "@/server/errors/next"
import { getPublicURL } from "@/server/utils"
import { signOut } from "@/auth"
export async function GET(
request: NextRequest,
context: { params: { lang: Lang } }
) {
const publicURL = getPublicURL(request)
let redirectTo: string = ""
const returnUrl = request.headers.get("x-returnurl")
const isSeamless = request.headers.get("x-logout-source") === "seamless"
console.log(
`[logout] source: ${request.headers.get("x-logout-source") || "normal"}`
)
const redirectToSearchParamValue =
request.nextUrl.searchParams.get("redirectTo")
const redirectToFallback = "/"
if (isSeamless) {
if (returnUrl) {
redirectTo = returnUrl
} else {
console.log(
`[login] missing returnUrl, using fallback: ${redirectToFallback}`
)
redirectTo = redirectToFallback
}
} else {
redirectTo = redirectToSearchParamValue || redirectToFallback
// Make relative URL to absolute URL
if (redirectTo.startsWith("/")) {
console.log(`[logout] make redirectTo absolute, from ${redirectTo}`)
redirectTo = new URL(redirectTo, publicURL).href
console.log(`[logout] make redirectTo absolute, to ${redirectTo}`)
}
try {
// Initiate the seamless logout flow
let redirectUrlValue
switch (context.params.lang) {
case Lang.da:
redirectUrlValue = env.SEAMLESS_LOGOUT_DA
break
case Lang.de:
redirectUrlValue = env.SEAMLESS_LOGOUT_DE
break
case Lang.en:
redirectUrlValue = env.SEAMLESS_LOGOUT_EN
break
case Lang.fi:
redirectUrlValue = env.SEAMLESS_LOGOUT_FI
break
case Lang.no:
redirectUrlValue = env.SEAMLESS_LOGOUT_NO
break
case Lang.sv:
redirectUrlValue = env.SEAMLESS_LOGOUT_SV
break
}
const redirectUrl = new URL(redirectUrlValue)
console.log(
`[logout] creating redirect to seamless logout: ${redirectUrl}`
)
redirectTo = redirectUrl.toString()
} catch (e) {
console.error(
"Unable to create URL for seamless logout, proceeding without it."
)
console.error(e)
}
}
try {
redirectTo = `${env.CURITY_ISSUER_USER}/authn/authenticate/logout?redirect_uri=${encodeURIComponent(redirectTo)}`
console.log(`[logout] final redirectUrl: ${redirectTo}`)
console.log({ logout_env: process.env })
/**
* Passing `redirect: false` to `signOut` will return a result object
* instead of automatically redirecting inside of `signOut`.
* https://github.com/nextauthjs/next-auth/blob/3c035ec/packages/next-auth/src/lib/actions.ts#L104
*/
const redirectUrlObj = await signOut({
redirectTo,
redirect: false,
})
if (redirectUrlObj) {
console.log(`[logout] redirecting to: ${redirectUrlObj.redirect}`)
return NextResponse.redirect(redirectUrlObj.redirect)
} else {
console.error(`[logout] missing redirectUrlObj reponse from signOut()`)
}
} catch (error) {
if (error instanceof AuthError) {
console.log({ signOutAuthError: error })
} else {
console.log({ signOutError: error })
}
}
return internalServerError()
}

View File

@@ -0,0 +1,33 @@
"use client"
import * as Sentry from "@sentry/nextjs"
import { useEffect } from "react"
import { useIntl } from "react-intl"
export default function Error({
error,
}: {
error: Error & { digest?: string }
}) {
const intl = useIntl()
useEffect(() => {
if (!error) return
console.error({ breadcrumbsError: error })
Sentry.captureException(error)
}, [error])
return (
<p>
<strong>
{intl.formatMessage(
{ id: "Breadcrumbs failed for this page ({errorId})" },
{
errorId: `${error.digest}@${Date.now()}`,
}
)}
</strong>
</p>
)
}

View File

@@ -0,0 +1,15 @@
import { Suspense } from "react"
import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import type { LangParams, PageArgs } from "@/types/params"
import { PageContentTypeEnum } from "@/types/requests/contentType"
export default function AllBreadcrumbs({}: PageArgs<LangParams>) {
return (
<Suspense fallback={<BreadcrumbsSkeleton />}>
<Breadcrumbs variant={PageContentTypeEnum.accountPage} />
</Suspense>
)
}

View File

@@ -0,0 +1,3 @@
export default function DefaultMyPages() {
return null
}

View File

@@ -0,0 +1,33 @@
"use client"
import * as Sentry from "@sentry/nextjs"
import { useEffect } from "react"
import { useIntl } from "react-intl"
export default function Error({
error,
}: {
error: Error & { digest?: string }
}) {
const intl = useIntl()
useEffect(() => {
if (!error) return
console.error(error)
Sentry.captureException(error)
}, [error])
return (
<p>
<strong>
{intl.formatMessage(
{ id: "Error loading this page ({errorId})" },
{
errorId: `${error.digest}@${Date.now()}`,
}
)}
</strong>
</p>
)
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner />
}

View File

@@ -0,0 +1,18 @@
.blocks {
display: grid;
gap: var(--Spacing-x5);
max-width: var(--max-width-page);
align-content: flex-start;
}
@media screen and (min-width: 768px) {
.blocks {
gap: var(--Spacing-x7);
}
}
@media screen and (min-width: 1367px) {
.blocks {
gap: var(--Spacing-x7);
}
}

View File

@@ -0,0 +1,50 @@
import { Suspense } from "react"
import { serverClient } from "@/lib/trpc/server"
import Blocks from "@/components/Blocks"
import SectionHeader from "@/components/Section/Header"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import styles from "./page.module.css"
import type { LangParams, PageArgs } from "@/types/params"
export { generateMetadata } from "@/utils/generateMetadata"
export default async function MyPages({}: PageArgs<
LangParams & { path: string[] }
>) {
const accountPageRes = await serverClient().contentstack.accountPage.get()
const intl = await getIntl()
if (!accountPageRes) {
return null
}
const { tracking, accountPage } = accountPageRes
const { heading, preamble, content } = accountPage
return (
<>
<main className={styles.blocks}>
<SectionHeader
title={heading}
preamble={preamble}
headingAs="h1"
headingLevel="h1"
/>
{content?.length ? (
<Blocks blocks={content} />
) : (
<p>{intl.formatMessage({ id: "No content published" })}</p>
)}
</main>
<Suspense fallback={null}>
<TrackingSDK pageData={tracking} />
</Suspense>
</>
)
}

View File

@@ -0,0 +1,26 @@
.layout {
display: grid;
font-family: var(--typography-Body-Regular-fontFamily);
grid-template-rows: auto 1fr;
min-height: 100dvh;
max-width: var(--max-width-page);
margin: 0 auto;
width: 100%;
}
.container {
background-color: var(--Base-Background-Primary-Normal);
}
.content {
display: grid;
padding-bottom: var(--Spacing-x9);
position: relative;
}
@media screen and (min-width: 1367px) {
.content {
gap: var(--Spacing-x5);
grid-template-columns: max(340px) 1fr;
}
}

View File

@@ -0,0 +1,30 @@
import { Suspense } from "react"
import Sidebar from "@/components/MyPages/Sidebar"
import SidebarNavigationSkeleton from "@/components/MyPages/Sidebar/SidebarNavigationSkeleton"
import Surprises from "@/components/MyPages/Surprises"
import styles from "./layout.module.css"
export default async function MyPagesLayout({
breadcrumbs,
children,
}: React.PropsWithChildren<{
breadcrumbs: React.ReactNode
}>) {
return (
<div className={styles.container}>
<section className={styles.layout}>
{breadcrumbs}
<section className={styles.content}>
<Suspense fallback={<SidebarNavigationSkeleton />}>
<Sidebar />
</Suspense>
{children}
</section>
</section>
<Surprises />
</div>
)
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -0,0 +1,12 @@
.container {
background-color: var(--Main-Grey-White);
border-radius: var(--Corner-radius-Large);
display: grid;
gap: var(--Spacing-x3);
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x4);
}
@media screen and (min-width: 768px) {
.container {
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x4);
}
}

View File

@@ -0,0 +1,19 @@
import { getProfile } from "@/lib/trpc/memoizedRequests"
import Form from "@/components/Forms/Edit/Profile"
import styles from "./page.module.css"
export default async function EditProfileSlot() {
const user = await getProfile()
if (!user || "error" in user) {
return null
}
return (
<>
<div className={styles.container}>
<Form user={user} />
</div>
</>
)
}

View File

@@ -0,0 +1,3 @@
export default function ProfileLayout({ children }: React.PropsWithChildren) {
return <main>{children}</main>
}

View File

@@ -0,0 +1,27 @@
import { Suspense } from "react"
import { serverClient } from "@/lib/trpc/server"
import Profile from "@/components/MyPages/myprofile/profile/profile"
import TrackingSDK from "@/components/TrackingSDK"
import type { LangParams, PageArgs } from "@/types/params"
export { generateMetadata } from "@/utils/generateMetadata"
export default async function ProfilePage({}: PageArgs<LangParams>) {
const accountPage = await serverClient().contentstack.accountPage.get()
if (!accountPage) {
return null
}
return (
<>
<Profile />
<Suspense fallback={null}>
<TrackingSDK pageData={accountPage.tracking} />
</Suspense>
</>
)
}

View File

@@ -0,0 +1,27 @@
import { Suspense } from "react"
import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import type { ContentTypeParams, LangParams, PageArgs } from "@/types/params"
import { PageContentTypeEnum } from "@/types/requests/contentType"
const IGNORED_CONTENT_TYPES = [
PageContentTypeEnum.hotelPage,
PageContentTypeEnum.destinationCityPage,
PageContentTypeEnum.destinationCountryPage,
]
export default function PageBreadcrumbs({
params,
}: PageArgs<LangParams & ContentTypeParams>) {
if (IGNORED_CONTENT_TYPES.includes(params.contentType)) {
return null
}
return (
<Suspense fallback={<BreadcrumbsSkeleton />}>
<Breadcrumbs variant={params.contentType} />
</Suspense>
)
}

View File

@@ -0,0 +1,11 @@
import { env } from "@/env/server"
import CurrentLoadingSpinner from "@/components/Current/LoadingSpinner"
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
if (env.HIDE_FOR_NEXT_RELEASE) {
return <CurrentLoadingSpinner />
}
return <LoadingSpinner />
}

View File

@@ -0,0 +1,18 @@
import { setPreviewData } from "@/lib/previewContext"
import InitLivePreview from "@/components/LivePreview"
import type { PageArgs, UIDParams } from "@/types/params"
export default function PreviewPage({
searchParams,
params,
}: PageArgs<UIDParams, URLSearchParams>) {
const shouldInitializePreview = searchParams.isPreview === "true"
if (searchParams.live_preview) {
setPreviewData({ hash: searchParams.live_preview, uid: params.uid })
}
return shouldInitializePreview ? <InitLivePreview /> : null
}

View File

@@ -0,0 +1,10 @@
.layout {
display: grid;
font-family: var(--typography-Body-Regular-fontFamily);
grid-template-rows: auto 1fr;
position: relative;
}
.container {
background-color: var(--Base-Background-Primary-Normal);
}

View File

@@ -0,0 +1,29 @@
import styles from "./layout.module.css"
import type {
ContentTypeParams,
LangParams,
LayoutArgs,
UIDParams,
} from "@/types/params"
export default function ContentTypeLayout({
breadcrumbs,
preview,
children,
}: React.PropsWithChildren<
LayoutArgs<LangParams & ContentTypeParams & UIDParams> & {
breadcrumbs: React.ReactNode
preview: React.ReactNode
}
>) {
return (
<div className={styles.container}>
<section className={styles.layout}>
{preview}
{breadcrumbs}
{children}
</section>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -0,0 +1,98 @@
import { headers } from "next/headers"
import { notFound, redirect } from "next/navigation"
import { overview } from "@/constants/routes/myPages"
import { isSignupPage } from "@/constants/routes/signup"
import { env } from "@/env/server"
import { getHotelPage } from "@/lib/trpc/memoizedRequests"
import { auth } from "@/auth"
import DestinationCityPage from "@/components/ContentType/DestinationPage/DestinationCityPage"
import DestinationCountryPage from "@/components/ContentType/DestinationPage/DestinationCountryPage"
import DestinationOverviewPage from "@/components/ContentType/DestinationPage/DestinationOverviewPage"
import HotelPage from "@/components/ContentType/HotelPage"
import HotelSubpage from "@/components/ContentType/HotelSubpage"
import LoyaltyPage from "@/components/ContentType/LoyaltyPage"
import StartPage from "@/components/ContentType/StartPage"
import CollectionPage from "@/components/ContentType/StaticPages/CollectionPage"
import ContentPage from "@/components/ContentType/StaticPages/ContentPage"
import { getLang } from "@/i18n/serverContext"
import { isValidSession } from "@/utils/session"
import type {
ContentTypeParams,
LangParams,
PageArgs,
UIDParams,
} from "@/types/params"
import { PageContentTypeEnum } from "@/types/requests/contentType"
export { generateMetadata } from "@/utils/generateMetadata"
export default async function ContentTypePage({
params,
searchParams,
}: PageArgs<
LangParams & ContentTypeParams & UIDParams,
{ subpage?: string; filterFromUrl?: string }
>) {
const pathname = headers().get("x-pathname") || ""
switch (params.contentType) {
case PageContentTypeEnum.collectionPage:
return <CollectionPage />
case PageContentTypeEnum.contentPage: {
const isSignupRoute = isSignupPage(pathname)
if (isSignupRoute) {
if (!env.SHOW_SIGNUP_FLOW) {
return notFound()
}
const session = await auth()
if (isValidSession(session)) {
redirect(overview[getLang()])
}
}
return <ContentPage />
}
case PageContentTypeEnum.loyaltyPage:
return <LoyaltyPage />
case PageContentTypeEnum.destinationOverviewPage:
return <DestinationOverviewPage />
case PageContentTypeEnum.destinationCountryPage:
return <DestinationCountryPage />
case PageContentTypeEnum.destinationCityPage:
const filterFromUrl = searchParams.filterFromUrl
return <DestinationCityPage filterFromUrl={filterFromUrl} />
case PageContentTypeEnum.hotelPage:
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
const hotelPageData = await getHotelPage()
if (hotelPageData) {
if (searchParams.subpage) {
return (
<HotelSubpage
hotelId={hotelPageData.hotel_page_id}
subpage={searchParams.subpage}
/>
)
}
return <HotelPage hotelId={hotelPageData.hotel_page_id} />
}
notFound()
case PageContentTypeEnum.startPage:
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <StartPage />
default:
const type: never = params.contentType
console.error(`Unsupported content type given: ${type}`)
notFound()
}
}

View File

@@ -0,0 +1,14 @@
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import BookingConfirmation from "@/components/HotelReservation/BookingConfirmation"
import type { LangParams, PageArgs } from "@/types/params"
export default async function BookingConfirmationPage({
searchParams,
}: PageArgs<LangParams, { confirmationNumber: string }>) {
void getBookingConfirmation(searchParams.confirmationNumber)
return (
<BookingConfirmation confirmationNumber={searchParams.confirmationNumber} />
)
}

View File

@@ -0,0 +1,3 @@
.layout {
background-color: var(--Base-Background-Primary-Normal);
}

View File

@@ -0,0 +1,9 @@
import styles from "./layout.module.css"
import type { LangParams, LayoutArgs } from "@/types/params"
export default function PaymentCallbackLayout({
children,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
return <div className={styles.layout}>{children}</div>
}

View File

@@ -0,0 +1,97 @@
import { redirect } from "next/navigation"
import {
BOOKING_CONFIRMATION_NUMBER,
MEMBERSHIP_FAILED_ERROR,
PaymentErrorCodeEnum,
} from "@/constants/booking"
import {
bookingConfirmation,
details,
} from "@/constants/routes/hotelReservation"
import { serverClient } from "@/lib/trpc/server"
import PaymentCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback"
import type { LangParams, PageArgs } from "@/types/params"
export default async function PaymentCallbackPage({
params,
searchParams,
}: PageArgs<
LangParams,
{
status: "error" | "success" | "cancel"
confirmationNumber?: string
hotel?: string
}
>) {
console.log(`[payment-callback] callback started`)
const lang = params.lang
const status = searchParams.status
const confirmationNumber = searchParams.confirmationNumber
if (status === "success" && confirmationNumber) {
const bookingStatus = await serverClient().booking.status({
confirmationNumber,
})
const membershipFailedError = bookingStatus.errors.find(
(e) => e.errorCode === MEMBERSHIP_FAILED_ERROR
)
const errorParam = membershipFailedError
? `&errorCode=${membershipFailedError.errorCode}`
: ""
const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${confirmationNumber}${errorParam}`
console.log(`[payment-callback] redirecting to: ${confirmationUrl}`)
redirect(confirmationUrl)
}
const returnUrl = details(lang)
const searchObject = new URLSearchParams()
let errorMessage = undefined
if (confirmationNumber) {
try {
const bookingStatus = await serverClient().booking.status({
confirmationNumber,
})
// TODO: how to handle errors for multiple rooms?
const error = bookingStatus.errors.find((e) => e.errorCode)
errorMessage =
error?.description ??
`No error message found for booking ${confirmationNumber}, status: ${status}`
searchObject.set(
"errorCode",
error
? error.errorCode.toString()
: PaymentErrorCodeEnum.Failed.toString()
)
} catch {
console.error(
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
)
if (status === "cancel") {
searchObject.set("errorCode", PaymentErrorCodeEnum.Cancelled.toString())
}
if (status === "error") {
searchObject.set("errorCode", PaymentErrorCodeEnum.Failed.toString())
errorMessage = `Failed to get booking status for ${confirmationNumber}, status: ${status}`
}
}
}
return (
<PaymentCallback
returnUrl={returnUrl.toString()}
searchObject={searchObject}
status={status}
errorMessage={errorMessage}
/>
)
}

View File

@@ -0,0 +1,11 @@
.layout {
min-height: 100dvh;
background-color: var(--Base-Background-Primary-Normal);
position: relative;
}
@media screen and (min-width: 768px) {
.layout {
z-index: 0;
}
}

View File

@@ -0,0 +1,7 @@
import styles from "./layout.module.css"
export default function HotelReservationLayout({
children,
}: React.PropsWithChildren) {
return <div className={styles.layout}>{children}</div>
}

View File

@@ -0,0 +1,6 @@
.main {
display: grid;
background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh;
position: relative;
}

View File

@@ -0,0 +1,30 @@
import { Suspense } from "react"
import { SelectHotelMapContainer } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer"
import { SelectHotelMapContainerSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainerSkeleton"
import { MapContainer } from "@/components/MapContainer"
import styles from "./page.module.css"
import type { AlternativeHotelsSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SelectHotelMapPage({
searchParams,
}: PageArgs<LangParams, AlternativeHotelsSearchParams>) {
return (
<div className={styles.main}>
<MapContainer>
<Suspense
key={searchParams.hotel}
fallback={<SelectHotelMapContainerSkeleton />}
>
<SelectHotelMapContainer
searchParams={searchParams}
isAlternativeHotels
/>
</Suspense>
</MapContainer>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { Suspense } from "react"
import SelectHotel from "@/components/HotelReservation/SelectHotel"
import { SelectHotelSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelSkeleton"
import type { AlternativeHotelsSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { LangParams, PageArgs } from "@/types/params"
export default async function AlternativeHotelsPage({
params,
searchParams,
}: PageArgs<LangParams, AlternativeHotelsSearchParams>) {
const roomKey = Object.keys(searchParams)
.filter((key) => key.startsWith("room["))
.map((key) => searchParams[key])
.join("-")
return (
<Suspense
key={`${searchParams.hotel}-${searchParams.fromDate}-${searchParams.toDate}-${roomKey}`}
fallback={<SelectHotelSkeleton />}
>
<SelectHotel
params={params}
searchParams={searchParams}
isAlternativeHotels
/>
</Suspense>
)
}

View File

@@ -0,0 +1,44 @@
.container {
display: grid;
gap: var(--Spacing-x3) var(--Spacing-x9);
}
.content {
width: var(--max-width-page);
margin: var(--Spacing-x3) auto 0;
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
}
.header {
padding-bottom: var(--Spacing-x3);
}
.summary {
position: sticky;
bottom: 0;
left: 0;
right: 0;
}
@media screen and (min-width: 1367px) {
.container {
grid-template-columns: 1fr 340px;
grid-template-rows: auto 1fr;
width: var(--max-width-page);
margin: var(--Spacing-x5) auto 0;
}
.content {
width: 100%;
margin: var(--Spacing-x3) 0 0;
}
.summary {
position: static;
display: grid;
grid-column: 2/3;
grid-row: 1/-1;
}
}

View File

@@ -0,0 +1,259 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import {
getBreakfastPackages,
getHotel,
getPackages,
getProfileSafely,
getSelectedRoomAvailability,
} from "@/lib/trpc/memoizedRequests"
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
import Details from "@/components/HotelReservation/EnterDetails/Details"
import HotelHeader from "@/components/HotelReservation/EnterDetails/Header"
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop"
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
import { generateChildrenString } from "@/components/HotelReservation/utils"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { convertSearchParamsToObj } from "@/utils/url"
import styles from "./page.module.css"
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import { type SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { StepEnum } from "@/types/enums/step"
import type { LangParams, PageArgs } from "@/types/params"
import type { Packages } from "@/types/requests/packages"
export interface RoomData {
bedTypes?: BedTypeSelection[]
mustBeGuaranteed?: boolean
breakfastIncluded?: boolean
packages: Packages | null
cancellationText: string
rateDetails: string[]
roomType: string
roomRate: RoomRate
}
export default async function DetailsPage({
params: { lang },
searchParams,
}: PageArgs<LangParams, SelectRateSearchParams>) {
const intl = await getIntl()
const selectRoomParams = new URLSearchParams(searchParams)
const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams)
void getProfileSafely()
const breakfastInput = {
adults: 1,
fromDate: booking.fromDate,
hotelId: booking.hotelId,
toDate: booking.toDate,
}
const breakfastPackages = await getBreakfastPackages(breakfastInput)
const roomsData: RoomData[] = []
for (let room of booking.rooms) {
const childrenAsString =
room.childrenInRoom && generateChildrenString(room.childrenInRoom)
const selectedRoomAvailabilityInput = {
adults: room.adults,
children: childrenAsString,
hotelId: booking.hotelId,
packageCodes: room.packages,
rateCode: room.rateCode,
roomStayStartDate: booking.fromDate,
roomStayEndDate: booking.toDate,
roomTypeCode: room.roomTypeCode,
}
const packages = room.packages
? await getPackages({
adults: room.adults,
children: room.childrenInRoom?.length,
endDate: booking.toDate,
hotelId: booking.hotelId,
packageCodes: room.packages,
startDate: booking.fromDate,
lang,
})
: null
const roomAvailability = await getSelectedRoomAvailability(
selectedRoomAvailabilityInput //
)
if (!roomAvailability) {
continue // TODO: handle no room availability
}
roomsData.push({
bedTypes: roomAvailability.bedTypes,
packages,
mustBeGuaranteed: roomAvailability.mustBeGuaranteed,
breakfastIncluded: roomAvailability.breakfastIncluded,
cancellationText: roomAvailability.cancellationText,
rateDetails: roomAvailability.rateDetails ?? [],
roomType: roomAvailability.selectedRoom.roomType,
roomRate: {
memberRate: roomAvailability?.memberRate,
publicRate: roomAvailability.publicRate,
},
})
}
const isCardOnlyPayment = roomsData.some((room) => room?.mustBeGuaranteed)
const hotelData = await getHotel({
hotelId: booking.hotelId,
isCardOnlyPayment,
language: lang,
})
const user = await getProfileSafely()
// const userTrackingData = await getUserTracking()
if (!hotelData || !roomsData) {
return notFound()
}
// const arrivalDate = new Date(booking.fromDate)
// const departureDate = new Date(booking.toDate)
const hotelAttributes = hotelData.hotel
// TODO: add tracking
// const initialHotelsTrackingData: TrackingSDKHotelInfo = {
// searchTerm: searchParams.city,
// arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
// departureDate: format(departureDate, "yyyy-MM-dd"),
// noOfAdults: adults,
// noOfChildren: childrenInRoom?.length,
// ageOfChildren: childrenInRoom?.map((c) => c.age).join(","),
// childBedPreference: childrenInRoom
// ?.map((c) => ChildBedMapEnum[c.bed])
// .join("|"),
// noOfRooms: 1, // // TODO: Handle multiple rooms
// duration: differenceInCalendarDays(departureDate, arrivalDate),
// leadTime: differenceInCalendarDays(arrivalDate, new Date()),
// searchType: "hotel",
// bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
// country: hotelAttributes?.address.country,
// hotelID: hotelAttributes?.operaId,
// region: hotelAttributes?.address.city,
// }
const showBreakfastStep = Boolean(
breakfastPackages?.length && !roomsData[0]?.breakfastIncluded
)
return (
<EnterDetailsProvider
booking={booking}
showBreakfastStep={showBreakfastStep}
roomsData={roomsData}
searchParamsStr={selectRoomParams.toString()}
user={user}
vat={hotelAttributes.vat}
>
<main>
<HotelHeader hotelData={hotelData} />
<div className={styles.container}>
<div className={styles.content}>
{roomsData.map((room, idx) => (
<section key={idx}>
{roomsData.length > 1 && (
<header className={styles.header}>
<Title level="h2" as="h4">
{intl.formatMessage({ id: "Room" })} {idx + 1}
</Title>
</header>
)}
<SelectedRoom
hotelId={booking.hotelId}
roomType={room.roomType}
roomTypeCode={booking.rooms[idx].roomTypeCode}
rateDescription={room.cancellationText}
roomIndex={idx}
searchParamsStr={selectRoomParams.toString()}
/>
{room.bedTypes ? (
<SectionAccordion
header={intl.formatMessage({ id: "Select bed" })}
label={intl.formatMessage({ id: "Request bedtype" })}
step={StepEnum.selectBed}
roomIndex={idx}
>
<BedType bedTypes={room.bedTypes} roomIndex={idx} />
</SectionAccordion>
) : null}
{showBreakfastStep ? (
<SectionAccordion
header={intl.formatMessage({ id: "Food options" })}
label={intl.formatMessage({
id: "Select breakfast options",
})}
step={StepEnum.breakfast}
roomIndex={idx}
>
<Breakfast packages={breakfastPackages!} roomIndex={idx} />
</SectionAccordion>
) : null}
<SectionAccordion
header={intl.formatMessage({ id: "Details" })}
step={StepEnum.details}
label={intl.formatMessage({ id: "Enter your details" })}
roomIndex={idx}
>
<Details
user={idx === 0 ? user : null}
memberPrice={{
currency:
room?.roomRate.memberRate?.localPrice.currency ?? "", // TODO: how to handle undefined,
price:
room?.roomRate.memberRate?.localPrice.pricePerNight ??
0, // TODO: how to handle undefined,
}}
roomIndex={idx}
/>
</SectionAccordion>
</section>
))}
<Suspense>
<Payment
user={user}
otherPaymentOptions={
hotelAttributes.merchantInformationData
.alternatePaymentOptions
}
supportedCards={hotelAttributes.merchantInformationData.cards}
mustBeGuaranteed={isCardOnlyPayment}
/>
</Suspense>
</div>
<aside className={styles.summary}>
<MobileSummary
isMember={!!user}
breakfastIncluded={roomsData[0]?.breakfastIncluded ?? false}
/>
<DesktopSummary
isMember={!!user}
breakfastIncluded={roomsData[0]?.breakfastIncluded ?? false}
/>
</aside>
</div>
</main>
</EnterDetailsProvider>
)
}

View File

@@ -0,0 +1,3 @@
.layout {
background-color: var(--Base-Background-Primary-Normal);
}

View File

@@ -0,0 +1,16 @@
import SidePeek from "@/components/HotelReservation/SidePeek"
import styles from "./layout.module.css"
import type { LangParams, LayoutArgs } from "@/types/params"
export default function HotelReservationLayout({
children,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
return (
<div className={styles.layout}>
{children}
<SidePeek />
</div>
)
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -0,0 +1,9 @@
.page {
background-color: var(--Base-Background-Primary-Normal);
min-height: 50dvh;
max-width: var(--max-width-page);
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,38 @@
import { Suspense } from "react"
import { env } from "@/env/server"
import TrackingSDK from "@/components/TrackingSDK"
import styles from "./page.module.css"
import {
TrackingChannelEnum,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type { LangParams, PageArgs } from "@/types/params"
export default function HotelReservationPage({ params }: PageArgs<LangParams>) {
if (!env.ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH) {
return null
}
const pageTrackingData: TrackingSDKPageData = {
pageId: "hotelreservation",
domainLanguage: params.lang,
channel: TrackingChannelEnum["hotelreservation"],
pageName: "hotelreservation",
siteSections: "hotelreservation",
pageType: "hotelreservationstartpage",
siteVersion: "new-web",
}
return (
<div className={styles.page}>
New booking flow! Please report errors/issues in Slack.
<Suspense fallback={null}>
<TrackingSDK pageData={pageTrackingData} />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,11 @@
.layout {
min-height: 100dvh;
background-color: var(--Base-Background-Primary-Normal);
position: relative;
}
@media screen and (min-width: 768px) {
.layout {
z-index: 0;
}
}

View File

@@ -0,0 +1,9 @@
import styles from "./layout.module.css"
import type { LangParams, LayoutArgs } from "@/types/params"
export default function HotelReservationLayout({
children,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
return <div className={styles.layout}>{children}</div>
}

View File

@@ -0,0 +1,6 @@
.main {
display: grid;
background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh;
position: relative;
}

View File

@@ -0,0 +1,30 @@
import stringify from "json-stable-stringify-without-jsonify"
import { Suspense } from "react"
import { SelectHotelMapContainer } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer"
import { SelectHotelMapContainerSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainerSkeleton"
import { MapContainer } from "@/components/MapContainer"
import styles from "./page.module.css"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SelectHotelMapPage({
searchParams,
}: PageArgs<LangParams, SelectHotelSearchParams>) {
const suspenseKey = stringify(searchParams)
return (
<div className={styles.main}>
<MapContainer>
<Suspense
key={suspenseKey}
fallback={<SelectHotelMapContainerSkeleton />}
>
<SelectHotelMapContainer searchParams={searchParams} />
</Suspense>
</MapContainer>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import stringify from "json-stable-stringify-without-jsonify"
import { Suspense } from "react"
import SelectHotel from "@/components/HotelReservation/SelectHotel"
import { SelectHotelSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelSkeleton"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SelectHotelPage({
params,
searchParams,
}: PageArgs<LangParams, SelectHotelSearchParams>) {
const suspenseKey = stringify(searchParams)
return (
<Suspense key={suspenseKey} fallback={<SelectHotelSkeleton />}>
<SelectHotel params={params} searchParams={searchParams} />
</Suspense>
)
}

View File

@@ -0,0 +1,135 @@
import { getHotel } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import { getLang } from "@/i18n/serverContext"
import type {
AlternativeHotelsAvailabilityInput,
AvailabilityInput,
} from "@/types/components/hotelReservation/selectHotel/availabilityInput"
import type {
HotelData,
NullableHotelData,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { CategorizedFilters } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
import type { DetailedFacility } from "@/types/hotel"
import type { HotelsAvailabilityItem } from "@/types/trpc/routers/hotel/availability"
const hotelSurroundingsFilterNames = [
"Hotel surroundings",
"Hotel omgivelser",
"Hotelumgebung",
"Hotellia lähellä",
"Hotellomgivelser",
"Omgivningar",
]
const hotelFacilitiesFilterNames = [
"Hotel facilities",
"Hotellfaciliteter",
"Hotelfaciliteter",
"Hotel faciliteter",
"Hotel-Infos",
"Hotellin palvelut",
]
export async function fetchAvailableHotels(
input: AvailabilityInput
): Promise<NullableHotelData[]> {
const availableHotels =
await serverClient().hotel.availability.hotelsByCity(input)
if (!availableHotels) return []
return enhanceHotels(availableHotels)
}
export async function fetchBookingCodeAvailableHotels(
input: AvailabilityInput
): Promise<NullableHotelData[]> {
const availableHotels =
await serverClient().hotel.availability.hotelsByCityWithBookingCode(input)
if (!availableHotels) return []
return enhanceHotels(availableHotels)
}
export async function fetchAlternativeHotels(
hotelId: string,
input: AlternativeHotelsAvailabilityInput
): Promise<NullableHotelData[]> {
const alternativeHotelIds = await serverClient().hotel.nearbyHotelIds({
hotelId,
})
if (!alternativeHotelIds) return []
const availableHotels =
await serverClient().hotel.availability.hotelsByHotelIds({
...input,
hotelIds: alternativeHotelIds,
})
if (!availableHotels) return []
return enhanceHotels(availableHotels)
}
async function enhanceHotels(hotels: {
availability: HotelsAvailabilityItem[]
}) {
const language = getLang()
const hotelFetchers = hotels.availability.map(async (hotel) => {
const hotelData = await getHotel({
hotelId: hotel.hotelId.toString(),
isCardOnlyPayment: false,
language,
})
if (!hotelData) return { hotelData: null, price: hotel.productType }
return {
hotelData: hotelData.hotel,
price: hotel.productType,
}
})
return await Promise.all(hotelFetchers)
}
export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
if (hotels.length === 0)
return { facilityFilters: [], surroundingsFilters: [] }
const filters = hotels.flatMap((hotel) => {
if (!hotel.hotelData) return []
return hotel.hotelData.detailedFacilities
})
const uniqueFilterIds = [...new Set(filters.map((filter) => filter.id))]
const filterList: DetailedFacility[] = uniqueFilterIds
.map((filterId) => filters.find((filter) => filter.id === filterId))
.filter((filter): filter is DetailedFacility => filter !== undefined)
.sort((a, b) => b.sortOrder - a.sortOrder)
return filterList.reduce<CategorizedFilters>(
(acc, filter) => {
if (filter.filter && hotelSurroundingsFilterNames.includes(filter.filter))
return {
facilityFilters: acc.facilityFilters,
surroundingsFilters: [...acc.surroundingsFilters, filter],
}
if (filter.filter && hotelFacilitiesFilterNames.includes(filter.filter))
return {
facilityFilters: [...acc.facilityFilters, filter],
surroundingsFilters: acc.surroundingsFilters,
}
return acc
},
{ facilityFilters: [], surroundingsFilters: [] }
)
}

View File

@@ -0,0 +1,54 @@
import { beforeAll, describe, expect, it } from "@jest/globals"
import { getValidFromDate, getValidToDate } from "./getValidDates"
const NOW = new Date("2020-10-01T00:00:00Z")
describe("getValidFromDate", () => {
beforeAll(() => {
jest.useFakeTimers({ now: NOW })
})
afterAll(() => {
jest.useRealTimers()
})
describe("getValidFromDate", () => {
it("returns today when empty string is provided", () => {
const actual = getValidFromDate("")
expect(actual.toISOString()).toBe("2020-10-01T00:00:00.000Z")
})
it("returns today when undefined is provided", () => {
const actual = getValidFromDate(undefined)
expect(actual.toISOString()).toBe("2020-10-01T00:00:00.000Z")
})
it("returns given date in utc", () => {
const actual = getValidFromDate("2024-01-01")
expect(actual.toISOString()).toBe("2024-01-01T00:00:00.000Z")
})
})
describe("getValidToDate", () => {
it("returns day after fromDate when empty string is provided", () => {
const actual = getValidToDate("", NOW)
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
})
it("returns day after fromDate when undefined is provided", () => {
const actual = getValidToDate(undefined, NOW)
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
})
it("returns given date in utc", () => {
const actual = getValidToDate("2024-01-01", NOW)
expect(actual.toISOString()).toBe("2024-01-01T00:00:00.000Z")
})
it("fallsback to day after fromDate when given date is before fromDate", () => {
const actual = getValidToDate("2020-09-30", NOW)
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
})
})
})

View File

@@ -0,0 +1,55 @@
import { dt } from "@/lib/dt"
import type { Dayjs } from "dayjs"
/**
* Get valid dates from stringFromDate and stringToDate making sure that they are not in the past and chronologically correct
* @example const { fromDate, toDate} = getValidDates("2021-01-01", "2021-01-02")
*/
export function getValidDates(
stringFromDate: string | undefined,
stringToDate: string | undefined
): { fromDate: Dayjs; toDate: Dayjs } {
const fromDate = getValidFromDate(stringFromDate)
const toDate = getValidToDate(stringToDate, fromDate)
return { fromDate, toDate }
}
/**
* Get valid fromDate from stringFromDate making sure that it is not in the past
*/
export function getValidFromDate(stringFromDate: string | undefined): Dayjs {
const now = dt().utc()
if (!stringFromDate) {
return now
}
const toDate = dt(stringFromDate)
const yesterday = now.subtract(1, "day")
if (!toDate.isAfter(yesterday)) {
return now
}
return toDate
}
/**
* Get valid toDate from stringToDate making sure that it is after fromDate
*/
export function getValidToDate(
stringToDate: string | undefined,
fromDate: Dayjs | Date
): Dayjs {
const tomorrow = dt().utc().add(1, "day")
if (!stringToDate) {
return tomorrow
}
const toDate = dt(stringToDate)
if (toDate.isAfter(fromDate)) {
return toDate
}
return tomorrow
}

View File

@@ -0,0 +1,30 @@
import stringify from "json-stable-stringify-without-jsonify"
import { Suspense } from "react"
import SelectRate from "@/components/HotelReservation/SelectRate"
import { HotelInfoCardSkeleton } from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/RoomsContainer/RoomsContainerSkeleton"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SelectRatePage({
params,
searchParams,
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
const suspenseKey = stringify(searchParams)
return (
<Suspense
key={suspenseKey}
fallback={
<>
<HotelInfoCardSkeleton />
<RoomsContainerSkeleton />
</>
}
>
<SelectRate params={params} searchParams={searchParams} />
</Suspense>
)
}

View File

@@ -0,0 +1,107 @@
import { notFound } from "next/navigation"
import { getLocations } from "@/lib/trpc/memoizedRequests"
import { generateChildrenString } from "@/components/HotelReservation/utils"
import { convertSearchParamsToObj, type SelectHotelParams } from "@/utils/url"
import type {
AlternativeHotelsSearchParams,
SelectHotelSearchParams,
} from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type {
Child,
SelectRateSearchParams,
} from "@/types/components/hotelReservation/selectRate/selectRate"
import {
type HotelLocation,
isHotelLocation,
type Location,
} from "@/types/trpc/routers/hotel/locations"
interface HotelSearchDetails<T> {
city: Location | null
hotel: HotelLocation | null
selectHotelParams: SelectHotelParams<T> & { city: string | undefined }
adultsInRoom: number[]
childrenInRoomString?: string
childrenInRoom?: Child[]
bookingCode?: string
}
export async function getHotelSearchDetails<
T extends
| SelectHotelSearchParams
| SelectRateSearchParams
| AlternativeHotelsSearchParams,
>(
{
searchParams,
}: {
searchParams: T & {
[key: string]: string
}
},
isAlternativeHotels?: boolean
): Promise<HotelSearchDetails<T> | null> {
const selectHotelParams = convertSearchParamsToObj<T>(searchParams)
const locations = await getLocations()
if (!locations || "error" in locations) return null
const hotel =
("hotelId" in selectHotelParams &&
(locations.data.find(
(location) =>
isHotelLocation(location) &&
"operaId" in location &&
location.operaId === selectHotelParams.hotelId
) as HotelLocation | undefined)) ||
null
if (isAlternativeHotels && !hotel) {
return notFound()
}
const cityName = isAlternativeHotels
? hotel?.relationships.city.name
: "city" in selectHotelParams
? (selectHotelParams.city as string | undefined)
: undefined
const city =
(typeof cityName === "string" &&
locations.data.find(
(location) => location.name.toLowerCase() === cityName.toLowerCase()
)) ||
null
if (!city && !hotel) return notFound()
if (isAlternativeHotels && (!city || !hotel)) return notFound()
let adultsInRoom: number[] = []
let childrenInRoomString: HotelSearchDetails<T>["childrenInRoomString"] =
undefined
let childrenInRoom: HotelSearchDetails<T>["childrenInRoom"] = undefined
const { rooms } = selectHotelParams
if (rooms && rooms.length > 0) {
adultsInRoom = rooms.map((room) => room.adults ?? 0)
childrenInRoomString = rooms[0].childrenInRoom
? generateChildrenString(rooms[0].childrenInRoom)
: undefined // TODO: Handle multiple rooms
childrenInRoom = rooms[0].childrenInRoom // TODO: Handle multiple rooms
}
return {
city,
hotel,
selectHotelParams: { city: cityName, ...selectHotelParams },
adultsInRoom,
childrenInRoomString,
childrenInRoom,
bookingCode: selectHotelParams.bookingCode ?? undefined,
}
}

View File

@@ -0,0 +1,27 @@
# Booking flow
The booking flow is the user journey of booking one or more rooms at our
hotels. Everything from choosing the date to payment and confirmation is
part of the booking flow.
## Booking widget
On most of the pages on the website we have a booking widget. This is where
the user starts the booking flow, by filling the form and submit. If they
entered a city as the destination they will land on the select hotel page
and if they entered a specific hotel they will land on the select rate page.
## Select hotel
Lists available hotels based on the search criteria. When the user selects
a hotel they land on the select rate page.
## Select rate, room, breakfast etc
This is a page with an accordion like design, but every accordion is handled
as its own page with its own URL.
## State management
The state, like search parameters and selected alternatives, is kept
throughout the booking flow in the URL.

View File

@@ -0,0 +1,12 @@
import { notFound } from "next/navigation"
import { env } from "@/env/server"
export default function HotelReservationLayout({
children,
}: React.PropsWithChildren) {
if (!env.ENABLE_BOOKING_FLOW) {
return notFound()
}
return <>{children}</>
}

View File

@@ -0,0 +1,14 @@
import SidePeek from "@/components/HotelReservation/SidePeek"
import type { LangParams, LayoutArgs } from "@/types/params"
export default function HotelReservationLayout({
children,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
return (
<div>
{children}
<SidePeek />
</div>
)
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -0,0 +1,16 @@
import { Suspense } from "react"
import { MyStay } from "@/components/HotelReservation/MyStay"
import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkeleton"
import type { LangParams, PageArgs } from "@/types/params"
export default async function MyStayPage({
params,
}: PageArgs<LangParams & { refId: string }>) {
return (
<Suspense fallback={<MyStaySkeleton />}>
<MyStay reservationId={params.refId} />
</Suspense>
)
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -0,0 +1,182 @@
import { type NextRequest, NextResponse } from "next/server"
import { AuthError } from "next-auth"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import { internalServerError } from "@/server/errors/next"
import { getPublicURL } from "@/server/utils"
import { signIn } from "@/auth"
export async function GET(
request: NextRequest,
context: { params: { lang: Lang } }
) {
const publicURL = getPublicURL(request)
let redirectHeaders: Headers | undefined = undefined
let redirectTo: string
const returnUrl = request.headers.get("x-returnurl")
const isSeamless = request.headers.get("x-login-source") === "seamless"
const isMFA = request.headers.get("x-login-source") === "mfa"
const isSeamlessMagicLink =
request.headers.get("x-login-source") === "seamless-magiclink"
console.log(
`[login] source: ${request.headers.get("x-login-source") || "normal"}`
)
const redirectToCookieValue = request.cookies.get("redirectTo")?.value // Cookie gets set by authRequired middleware
const redirectToSearchParamValue =
request.nextUrl.searchParams.get("redirectTo")
const redirectToFallback = "/"
console.log(`[login] redirectTo cookie value: ${redirectToCookieValue}`)
console.log(
`[login] redirectTo search param value: ${redirectToSearchParamValue}`
)
if (isSeamless || isSeamlessMagicLink || isMFA) {
if (returnUrl) {
redirectTo = returnUrl
} else {
console.log(
`[login] missing returnUrl, using fallback: ${redirectToFallback}`
)
redirectTo = redirectToFallback
}
} else {
redirectTo =
redirectToCookieValue || redirectToSearchParamValue || redirectToFallback
// Make relative URL to absolute URL
if (redirectTo.startsWith("/")) {
console.log(`[login] make redirectTo absolute, from ${redirectTo}`)
redirectTo = new URL(redirectTo, publicURL).href
console.log(`[login] make redirectTo absolute, to ${redirectTo}`)
}
// Clean up cookie from authRequired middleware
redirectHeaders = new Headers()
redirectHeaders.append(
"set-cookie",
"redirectTo=; Expires=Thu, 01 Jan 1970 00:00:00 UTC; Path=/; HttpOnly; SameSite=Lax"
)
try {
// Initiate the seamless login flow
let redirectUrlValue
switch (context.params.lang) {
case Lang.da:
redirectUrlValue = env.SEAMLESS_LOGIN_DA
break
case Lang.de:
redirectUrlValue = env.SEAMLESS_LOGIN_DE
break
case Lang.en:
redirectUrlValue = env.SEAMLESS_LOGIN_EN
break
case Lang.fi:
redirectUrlValue = env.SEAMLESS_LOGIN_FI
break
case Lang.no:
redirectUrlValue = env.SEAMLESS_LOGIN_NO
break
case Lang.sv:
redirectUrlValue = env.SEAMLESS_LOGIN_SV
break
}
const redirectUrl = new URL(redirectUrlValue)
console.log(`[login] creating redirect to seamless login: ${redirectUrl}`)
redirectUrl.searchParams.set("returnurl", redirectTo)
console.log(
`[login] returnurl for seamless login: ${redirectUrl.searchParams.get("returnurl")}`
)
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(
"[login] unable to create URL for seamless login, proceeding without it.",
e
)
}
}
try {
console.log(`[login] final redirectUrl: ${redirectTo}`)
console.log({ login_env: process.env })
/** Record<string, any> is next-auth typings */
const params: Record<string, any> = {
ui_locales: context.params.lang,
scope: ["openid", "profile", "booking", "profile_link"],
/**
* 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: "urn:com:scandichotels:scandic",
/**
* 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: publicURL,
// 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.push("profile_update")
/**
* 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:com:scandichotels:scandic-otp"
} else if (isSeamlessMagicLink) {
params.acr_values = "urn:com:scandichotels:scandic-email"
}
params.scope = params.scope.join(" ")
/**
* 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
*/
const redirectUrl = await signIn(
"curity",
{
redirectTo,
redirect: false,
},
params
)
if (redirectUrl) {
const redirectOpts = {
headers: redirectHeaders,
}
console.log(`[login] redirecting to: ${redirectUrl}`, redirectOpts)
return NextResponse.redirect(redirectUrl, redirectOpts)
} else {
console.error(`[login] missing redirectUrl reponse from signIn()`)
}
} catch (error) {
if (error instanceof AuthError) {
console.error({ signInAuthError: error })
} else {
console.error({ signInError: error })
}
}
return internalServerError()
}

View File

@@ -0,0 +1,93 @@
import { type NextRequest, NextResponse } from "next/server"
import { AuthError } from "next-auth"
import { badRequest, internalServerError } from "@/server/errors/next"
import { getPublicURL } from "@/server/utils"
import { signIn } from "@/auth"
import type { Lang } from "@/constants/languages"
export async function GET(
request: NextRequest,
context: { params: { lang: Lang } }
) {
const publicURL = getPublicURL(request)
const loginKey = request.nextUrl.searchParams.get("loginKey")
if (!loginKey) {
console.log(
`[verifymagiclink] missing required loginKey, aborting bad request`
)
return badRequest()
}
let redirectTo: string
console.log(`[verifymagiclink] verifying callback`)
const redirectToCookieValue = request.cookies.get(
"magicLinkRedirectTo"
)?.value // Set redirect url from the magicLinkRedirect Cookie which is set when intiating login
const redirectToFallback = "/"
console.log(
`[verifymagiclink] magicLinkRedirectTo cookie value: ${redirectToCookieValue}`
)
redirectTo = redirectToCookieValue || redirectToFallback
// Make relative URL to absolute URL
if (redirectTo.startsWith("/")) {
console.log(
`[verifymagiclink] make redirectTo absolute, from ${redirectTo}`
)
redirectTo = new URL(redirectTo, publicURL).href
console.log(`[verifymagiclink] make redirectTo absolute, to ${redirectTo}`)
}
// Update Seamless login url as Magic link login has a different authenticator in Curity
redirectTo = redirectTo.replace("updatelogin", "updateloginemail")
try {
console.log(`[verifymagiclink] final redirectUrl: ${redirectTo}`)
/**
* 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
*/
const redirectUrl = await signIn(
"curity",
{
redirectTo,
redirect: false,
},
{
ui_locales: context.params.lang,
scope: ["openid", "profile"].join(" "),
loginKey: loginKey,
for_origin: publicURL,
acr_values: "urn:com:scandichotels:scandic-email",
version: "2",
}
)
if (redirectUrl) {
console.log(`[verifymagiclink] redirecting to: ${redirectUrl}`)
return NextResponse.redirect(redirectUrl)
} else {
console.error(
`[verifymagiclink] missing redirectUrl reponse from signIn()`
)
}
} catch (error) {
if (error instanceof AuthError) {
console.error({ signInAuthError: error })
} else {
console.error({ signInError: error })
}
}
return internalServerError()
}

View File

@@ -0,0 +1 @@
export { default } from "../page"

View File

@@ -0,0 +1 @@
export { default } from "../page"

View File

@@ -0,0 +1,39 @@
import { env } from "@/env/server"
import { getHotel, getHotelPage } from "@/lib/trpc/memoizedRequests"
import BookingWidget, { preload } from "@/components/BookingWidget"
import { getLang } from "@/i18n/serverContext"
import type { BookingWidgetSearchData } from "@/types/components/bookingWidget"
import type { ContentTypeParams, PageArgs } from "@/types/params"
import { PageContentTypeEnum } from "@/types/requests/contentType"
export default async function BookingWidgetPage({
params,
searchParams,
}: PageArgs<ContentTypeParams, BookingWidgetSearchData>) {
if (!env.ENABLE_BOOKING_WIDGET) {
return null
}
preload()
if (params.contentType === PageContentTypeEnum.hotelPage) {
const hotelPageData = await getHotelPage()
const hotelData = await getHotel({
hotelId: hotelPageData?.hotel_page_id || "",
language: getLang(),
isCardOnlyPayment: false,
})
const hotelPageParams = {
hotel: hotelData?.hotel?.id || "",
city: hotelData?.hotel?.cityName || "",
}
return <BookingWidget bookingWidgetSearchParams={hotelPageParams} />
}
return <BookingWidget bookingWidgetSearchParams={searchParams} />
}

View File

@@ -0,0 +1 @@
export { default } from "../page"

View File

@@ -0,0 +1,3 @@
export default function ConfirmedBookingSlot() {
return null
}

View File

@@ -0,0 +1,11 @@
import { env } from "@/env/server"
import { BookingWidgetSkeleton } from "@/components/BookingWidget/Client"
export default function LoadingBookingWidget() {
if (!env.ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH) {
return null
}
return <BookingWidgetSkeleton />
}

View File

@@ -0,0 +1,18 @@
import { env } from "@/env/server"
import BookingWidget, { preload } from "@/components/BookingWidget"
import type { BookingWidgetSearchData } from "@/types/components/bookingWidget"
import type { LangParams, PageArgs } from "@/types/params"
export default async function BookingWidgetPage({
searchParams,
}: PageArgs<LangParams, BookingWidgetSearchData>) {
if (!env.ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH) {
return null
}
preload()
return <BookingWidget bookingWidgetSearchParams={searchParams} />
}

View File

@@ -0,0 +1,4 @@
.container {
height: 76px;
width: 100%;
}

View File

@@ -0,0 +1,11 @@
import { env } from "@/env/server"
import { BookingWidgetSkeleton } from "@/components/BookingWidget/Client"
export default function LoadingBookingWidget() {
if (!env.ENABLE_BOOKING_WIDGET) {
return null
}
return <BookingWidgetSkeleton />
}

View File

@@ -0,0 +1,18 @@
import { env } from "@/env/server"
import BookingWidget, { preload } from "@/components/BookingWidget"
import type { BookingWidgetSearchData } from "@/types/components/bookingWidget"
import type { PageArgs } from "@/types/params"
export default async function BookingWidgetPage({
searchParams,
}: PageArgs<{}, BookingWidgetSearchData>) {
if (!env.ENABLE_BOOKING_WIDGET) {
return null
}
preload()
return <BookingWidget bookingWidgetSearchParams={searchParams} />
}

View File

@@ -0,0 +1,25 @@
.layout {
display: grid;
font-family: var(--typography-Body-Regular-fontFamily);
grid-template-rows: auto 1fr;
min-height: 100dvh;
}
.content {
display: grid;
padding-bottom: 7.7rem;
padding-left: 0;
padding-right: 0;
position: relative;
}
@media screen and (min-width: 1367px) {
.content {
gap: 10rem;
grid-template-columns: 25rem 1fr;
padding-bottom: 17.5rem;
padding-left: 2.4rem;
padding-right: 2.4rem;
padding-top: 5.8rem;
}
}

View File

@@ -0,0 +1,67 @@
"use client"
import * as Sentry from "@sentry/nextjs"
import { useParams, useRouter, useSearchParams } from "next/navigation"
import { startTransition, useEffect, useRef } from "react"
import { useIntl } from "react-intl"
import { login } from "@/constants/routes/handleAuth"
import { SESSION_EXPIRED } from "@/server/errors/trpc"
import styles from "./error.module.css"
import type { LangParams } from "@/types/params"
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
const intl = useIntl()
const params = useParams<LangParams>()
const router = useRouter()
const searchParams = useSearchParams()
const currentSearchParamsRef = useRef<string>()
const isFirstLoadRef = useRef<boolean>(true)
useEffect(() => {
if (!error) return
console.error(error)
if (error.message === SESSION_EXPIRED) {
const loginUrl = login[params.lang]
window.location.assign(loginUrl)
return
}
Sentry.captureException(error)
}, [error, params.lang])
useEffect(() => {
// This is to reset the error and refresh the page when the search params change, to support the booking widget that is using router.push to navigate to the booking flow page
const currentSearchParams = searchParams.toString()
if (
currentSearchParamsRef.current !== currentSearchParams &&
!isFirstLoadRef.current
) {
startTransition(() => {
reset()
router.refresh()
})
}
isFirstLoadRef.current = false
currentSearchParamsRef.current = currentSearchParams
}, [searchParams, reset, router])
return (
<section className={styles.layout}>
<div className={styles.content}>
{intl.formatMessage({ id: "Something went wrong!" })}
</div>
</section>
)
}

View File

@@ -0,0 +1,75 @@
import "@/app/globals.css"
import "@scandic-hotels/design-system/style.css"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import Script from "next/script"
import { SessionProvider } from "next-auth/react"
import TrpcProvider from "@/lib/trpc/Provider"
import { SessionRefresher } from "@/components/Auth/TokenRefresher"
import CookieBotConsent from "@/components/CookieBot"
import Footer from "@/components/Footer"
import Header from "@/components/Header"
import StorageCleaner from "@/components/HotelReservation/EnterDetails/StorageCleaner"
import SitewideAlert from "@/components/SitewideAlert"
import { ToastHandler } from "@/components/TempDesignSystem/Toasts"
import { preloadUserTracking } from "@/components/TrackingSDK"
import AdobeSDKScript from "@/components/TrackingSDK/AdobeSDKScript"
import GTMScript from "@/components/TrackingSDK/GTMScript"
import RouterTracking from "@/components/TrackingSDK/RouterTracking"
import { getIntl } from "@/i18n"
import ServerIntlProvider from "@/i18n/Provider"
import type { LangParams, LayoutArgs } from "@/types/params"
export default async function RootLayout({
bookingwidget,
children,
}: React.PropsWithChildren<
LayoutArgs<LangParams> & {
bookingwidget: React.ReactNode
}
>) {
preloadUserTracking()
const { defaultLocale, locale, messages } = await getIntl()
return (
<>
<head>
<AdobeSDKScript />
<GTMScript />
<Script
strategy="beforeInteractive"
data-blockingmode="auto"
data-cbid="6d539de8-3e67-4f0f-a0df-8cef9070f712"
data-culture="@cultureCode"
id="Cookiebot"
src="https://consent.cookiebot.com/uc.js"
/>
<Script id="ensure-adobeDataLayer">{`
window.adobeDataLayer = window.adobeDataLayer || []
`}</Script>
</head>
<body>
<SessionProvider basePath="/api/web/auth">
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
<TrpcProvider>
<RouterTracking />
<SitewideAlert />
<Header />
{bookingwidget}
{children}
<Footer />
<ToastHandler />
<SessionRefresher />
<StorageCleaner />
<CookieBotConsent />
<ReactQueryDevtools initialIsOpen={false} />
</TrpcProvider>
</ServerIntlProvider>
</SessionProvider>
</body>
</>
)
}

View File

@@ -0,0 +1,7 @@
import NotFound from "@/components/Current/NotFound"
import type { LangParams, PageArgs } from "@/types/params"
export default function NotFoundPage({}: PageArgs<LangParams>) {
return <NotFound />
}

View File

@@ -0,0 +1,3 @@
.layout {
font-family: var(--typography-Body-Regular-fontFamily);
}

View File

@@ -0,0 +1,13 @@
import styles from "./page.module.css"
import type { LangParams, LayoutArgs, StatusParams } from "@/types/params"
export default async function MiddlewareError({
params,
}: LayoutArgs<LangParams & StatusParams>) {
return (
<div className={styles.layout}>
Middleware error {params.lang}: {params.status}
</div>
)
}

View File

@@ -0,0 +1,5 @@
import NotFound from "@/components/Current/NotFound"
export default function NotFoundPage() {
return <NotFound />
}

View File

@@ -0,0 +1,7 @@
import Header from "@/components/Current/Header"
import type { LangParams, PageArgs } from "@/types/params"
export default async function HeaderPage({}: PageArgs<LangParams>) {
return <Header />
}

View File

@@ -0,0 +1,19 @@
"use client"
import * as Sentry from "@sentry/nextjs"
import { useEffect } from "react"
export default function Error({
error,
}: {
error: Error & { digest?: string }
}) {
useEffect(() => {
if (!error) return
console.error(error)
Sentry.captureException(error)
}, [error])
return null
}

View File

@@ -0,0 +1,65 @@
import { notFound } from "next/navigation"
import { GetCurrentBlockPage } from "@/lib/graphql/Query/Current/CurrentBlockPage.graphql"
import { GetCurrentBlockPageTrackingData } from "@/lib/graphql/Query/Current/CurrentBlockPageTrackingData.graphql"
import { request } from "@/lib/graphql/request"
import ContentPage from "@/components/Current/ContentPage"
import Tracking from "@/components/Current/Tracking"
import type { LangParams, PageArgs, UriParams } from "@/types/params"
import type { GetCurrentBlockPageData } from "@/types/requests/currentBlockPage"
import type { TrackingData } from "@/types/requests/trackingData"
export default async function CurrentContentPage({
params,
searchParams,
}: PageArgs<LangParams, UriParams>) {
try {
if (!searchParams.uri) {
throw new Error("Bad URI")
}
const response = await request<GetCurrentBlockPageData>(
GetCurrentBlockPage,
{
locale: params.lang,
url: searchParams.uri,
},
{ cache: "no-store" }
)
if (!response.data?.all_current_blocks_page?.total) {
console.log("#### DATA ####")
console.log(response.data)
console.log("SearchParams URI: ", searchParams.uri)
throw new Error("Not found")
}
// This is currently to be considered a temporary solution to provide the tracking with a few values in english to align with existing reports
const pageDataForTracking = await request<TrackingData>(
GetCurrentBlockPageTrackingData,
{ uid: response.data.all_current_blocks_page.items[0].system.uid },
{ cache: "no-store" }
)
const pageData = response.data.all_current_blocks_page.items[0]
const trackingData = {
lang: params.lang,
publishedDate: pageData.system.updated_at,
createdDate: pageData.system.created_at,
pageId: pageData.system.uid,
englishUrl: pageDataForTracking.data?.current_blocks_page.url,
}
return (
<>
<ContentPage data={response.data} />
<Tracking pageData={trackingData} />
</>
)
} catch {
return notFound()
}
}

View File

@@ -0,0 +1,74 @@
import "@/app/globals.css"
import "@scandic-hotels/design-system/style.css"
import Script from "next/script"
import TokenRefresher from "@/components/Auth/TokenRefresher"
import CookieBotConsent from "@/components/CookieBot"
import AdobeScript from "@/components/Current/AdobeScript"
import Footer from "@/components/Current/Footer"
import LangPopup from "@/components/Current/LangPopup"
import SkipToMainContent from "@/components/SkipToMainContent"
import RouterTracking from "@/components/TrackingSDK/RouterTracking"
import { getIntl } from "@/i18n"
import ServerIntlProvider from "@/i18n/Provider"
import type { Metadata } from "next"
import type { LangParams, LayoutArgs } from "@/types/params"
export const fetchCache = "default-no-store"
export const metadata: Metadata = {
title: "Scandic Hotels",
}
export default async function RootLayout({
children,
params,
header,
}: React.PropsWithChildren<
LayoutArgs<LangParams> & { header: React.ReactNode }
>) {
const { defaultLocale, locale, messages } = await getIntl()
return (
<html lang={params.lang}>
<head>
{/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href="/_static/css/core.css" />
{/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href="/_static/css/scandic.css" />
<Script
strategy="beforeInteractive"
data-blockingmode="auto"
data-cbid="6d539de8-3e67-4f0f-a0df-8cef9070f712"
data-culture={params.lang}
id="Cookiebot"
src="https://consent.cookiebot.com/uc.js"
/>
<Script id="ensure-datalayer">{`
window.datalayer = window.datalayer || {}
`}</Script>
<AdobeScript />
</head>
<body className="theme-00Corecolours theme-X0Oldcorecolours">
<LangPopup />
<SkipToMainContent />
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
<RouterTracking />
{header}
{children}
<Footer />
<TokenRefresher />
<CookieBotConsent />
</ServerIntlProvider>
<Script id="page-tracking">{`
if (typeof _satellite !== "undefined" && typeof _satellite.pageBottom === "function") {
_satellite.pageBottom();
}
`}</Script>
</body>
</html>
)
}

View File

@@ -0,0 +1,5 @@
import NotFound from "@/components/Current/NotFound"
export default function NotFoundPage() {
return <NotFound />
}

View File

@@ -0,0 +1,3 @@
import { ProtectedLayout } from "@/components/ProtectedLayout"
export default ProtectedLayout

View File

@@ -0,0 +1,11 @@
import LoadingSpinner from "@/components/LoadingSpinner"
import { SASModal } from "./sas-x-scandic/components/SASModal"
export default function Loading() {
return (
<SASModal>
<LoadingSpinner />
</SASModal>
)
}

View File

@@ -0,0 +1,118 @@
import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { z } from "zod"
import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server"
import { safeTry } from "@/utils/safeTry"
import { SAS_TOKEN_STORAGE_KEY, stateSchema } from "../sasUtils"
import type { NextRequest } from "next/server"
const searchParamsSchema = z.object({
code: z.string(),
state: z.string(),
})
const tokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z.number(),
token_type: z.literal("Bearer"),
})
export async function GET(
request: NextRequest,
{ params }: { params: { lang: string } }
) {
const { lang } = params
const result = searchParamsSchema.safeParse({
code: request.nextUrl.searchParams.get("code"),
state: request.nextUrl.searchParams.get("state"),
})
if (!result.success) {
console.error("[SAS] Invalid search params", result.error)
redirect(`/${lang}/sas-x-scandic/error?errorCode=invalid_query`)
}
const { code, state } = result.data
const tokenResponse = await fetch(
new URL("oauth/token", env.SAS_AUTH_ENDPOINT),
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: new URL(
`/${lang}/sas-x-scandic/callback`,
new URL(env.PUBLIC_URL)
).toString(),
client_id: env.SAS_AUTH_CLIENTID,
}),
}
)
if (!tokenResponse.ok) {
const error = await tokenResponse.text()
console.error("[SAS] Failed to get token", error)
redirect(`/${lang}/sas-x-scandic/error?errorCode=token_error`)
}
const tokenData = tokenResponseSchema.parse(await tokenResponse.json())
const stateResult = stateSchema.safeParse(
JSON.parse(decodeURIComponent(state))
)
if (!stateResult.success) {
redirect(`/${lang}/sas-x-scandic/error?errorCode=invalid_state`)
}
const cookieStore = cookies()
cookieStore.set(SAS_TOKEN_STORAGE_KEY, tokenData.access_token, {
maxAge: 3600,
httpOnly: true,
})
if (
stateResult.data.intent === "link" ||
stateResult.data.intent === "unlink"
) {
const [data, error] = await safeTry(
serverClient().partner.sas.requestOtp({})
)
if (!data || error) {
console.error("[SAS] Failed to request OTP", error)
redirect(`/${lang}/sas-x-scandic/error`)
}
switch (data.status) {
case "ABUSED":
redirect(`/${params.lang}/sas-x-scandic/error?errorCode=tooManyCodes`)
case "NOTSENT":
redirect(`/${params.lang}/sas-x-scandic/error`)
case "NULL":
case "RETRY":
case "EXPIRED":
// These errors should never happen for request, but according to the API spec they can
throw new Error(`Unhandled request OTP status ${data.status}`)
}
console.log("[SAS] Request OTP response", data)
const otpUrl = new URL(
`/${lang}/sas-x-scandic/otp`,
new URL(env.PUBLIC_URL)
)
otpUrl.searchParams.set("intent", stateResult.data.intent)
otpUrl.searchParams.set("to", data.otpReceiver)
redirect(otpUrl.toString())
}
redirect(`/${lang}/sas-x-scandic/error?errorCode=unknown_intent`)
}

View File

@@ -0,0 +1,40 @@
"use client"
import Link from "next/link"
import { useIntl } from "react-intl"
import { scandicXSAS } from "@/constants/routes/myPages"
import ErrorCircleFilledIcon from "@/components/Icons/ErrorCircleFilled"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import useLang from "@/hooks/useLang"
import { SASModal, SASModalContactBlock, SASModalDivider } from "./SASModal"
export function AlreadyLinkedError() {
const intl = useIntl()
const lang = useLang()
return (
<SASModal>
<ErrorCircleFilledIcon height={64} width={64} color="red" />
<Title as="h2" level="h1" textAlign="center" textTransform="regular">
{intl.formatMessage({ id: "Accounts are already linked" })}
</Title>
<Body textAlign="center">
{/* TODO copy */}
{intl.formatMessage({
id: "We could not connect your accounts to give you access. Please contact us and well help you resolve this issue.",
})}
</Body>
<Button theme="base" asChild>
<Link href={scandicXSAS[lang]}>
{intl.formatMessage({ id: "View your account" })}
</Link>
</Button>
<SASModalDivider />
<SASModalContactBlock />
</SASModal>
)
}

View File

@@ -0,0 +1,40 @@
"use client"
import { Link } from "react-aria-components"
import { useIntl } from "react-intl"
import { profile } from "@/constants/routes/myPages"
import ErrorCircleFilledIcon from "@/components/Icons/ErrorCircleFilled"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import useLang from "@/hooks/useLang"
import { SASModal, SASModalContactBlock, SASModalDivider } from "./SASModal"
export function DateOfBirthError() {
const intl = useIntl()
const lang = useLang()
return (
<SASModal>
<ErrorCircleFilledIcon height={64} width={64} color="red" />
<Title as="h2" level="h1" textAlign="center" textTransform="regular">
{intl.formatMessage({ id: "Date of birth not matching" })}
</Title>
<Body textAlign="center">
{/* TODO copy */}
{intl.formatMessage({
id: "We could not connect your accounts to give you access. Please contact us and well help you resolve this issue.",
})}
</Body>
<Button theme="base">
<Link href={profile[lang]}>
{intl.formatMessage({ id: "View your account" })}
</Link>
</Button>
<SASModalDivider />
<SASModalContactBlock />
</SASModal>
)
}

View File

@@ -0,0 +1,28 @@
"use client"
import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import { GenericError } from "./GenericError"
export function FailedAttemptsError() {
const intl = useIntl()
return (
<GenericError
title={intl.formatMessage({ id: "Too many failed attempts" })}
variant="info"
>
<Body textAlign="center">
{intl.formatMessage({
id: "Please wait 1 hour before trying again.",
})}
</Body>
<Button theme="base" disabled>
{intl.formatMessage({ id: "Send new code" })}
</Button>
</GenericError>
)
}

View File

@@ -0,0 +1,39 @@
"use client"
import Image from "next/image"
import ErrorCircleFilledIcon from "@/components/Icons/ErrorCircleFilled"
import Title from "@/components/TempDesignSystem/Text/Title"
import { SASModal } from "./SASModal"
import type { ReactNode } from "react"
export function GenericError({
title,
variant = "error",
children,
}: {
title: ReactNode
variant?: "error" | "info"
children: ReactNode
}) {
return (
<SASModal>
{variant === "error" ? (
<ErrorCircleFilledIcon height={64} width={64} color="red" />
) : (
<Image
src="/_static/img/scandic-loyalty-time.svg"
alt=""
width={140}
height={110}
style={{ marginTop: 16 }}
/>
)}
<Title as="h3" level="h1" textAlign="center" textTransform="regular">
{title}
</Title>
{children}
</SASModal>
)
}

View File

@@ -0,0 +1,45 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x3);
background-color: white;
width: 100%;
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x4);
text-align: center;
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
margin-top: auto;
@media screen and (min-width: 768px) {
& {
border-radius: var(--Corner-radius-Medium);
margin-top: initial;
width: 560px;
}
}
}
.divider {
width: 100%;
position: relative;
& > span {
position: relative;
padding: 0 var(--Spacing-x2);
background-color: white;
}
&::before {
position: absolute;
bottom: calc(50% - 1px);
content: "";
display: block;
height: 1px;
width: 100%;
background-color: var(--Base-Border-Subtle);
}
}
.contactBlockTitle {
margin-bottom: var(--Spacing-x1);
}

View File

@@ -0,0 +1,53 @@
"use client"
import { useIntl } from "react-intl"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./SASModal.module.css"
export function SASModal({ children }: { children: React.ReactNode }) {
return <section className={styles.container}>{children}</section>
}
export function SASModalDivider() {
const intl = useIntl()
return (
<div className={styles.divider}>
<Body asChild color="uiTextPlaceholder">
<span>{intl.formatMessage({ id: "or" })}</span>
</Body>
</div>
)
}
export function SASModalContactBlock() {
const intl = useIntl()
const phone = intl.formatMessage({ id: "+46 8 517 517 00" })
return (
<div style={{ display: "flex", flexDirection: "column" }}>
<Title
level="h4"
as="h3"
textTransform="regular"
className={styles.contactBlockTitle}
>
{intl.formatMessage({ id: "Contact our memberservice" })}
</Title>
<Link
href={`tel:${phone.replaceAll(" ", "")}`}
textDecoration="underline"
>
{phone}
</Link>
<Link href="mailto:member@scandichotels.com" textDecoration="underline">
member@scandichotels.com
</Link>
</div>
)
}

View File

@@ -0,0 +1,28 @@
"use client"
import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import { GenericError } from "./GenericError"
export function TooManyCodesError() {
const intl = useIntl()
return (
<GenericError
title={intl.formatMessage({ id: "Youve requested too many codes" })}
variant="info"
>
<Body textAlign="center">
{intl.formatMessage({
id: "Please wait 1 hour before trying again.",
})}
</Body>
<Button theme="base" disabled>
{intl.formatMessage({ id: "Send new code" })}
</Button>
</GenericError>
)
}

Some files were not shown because too many files have changed in this diff Show More