+ )
+}
+
+const getRequestErrorBody = (
+ intl: ReturnType,
+ errorCode: RequestOtpError["errorCode"]
+) => {
+ switch (errorCode) {
+ case "TOO_MANY_REQUESTS":
+ return intl.formatMessage({
+ id: "Too many requests. Please try again later.",
+ })
+ default:
+ return intl.formatMessage({
+ id: "An error occurred while requesting a new OTP",
+ })
+ }
+}
+
+const getVerifyErrorBody = (
+ intl: ReturnType,
+ errorCode: VerifyOtpError["errorCode"]
+) => {
+ switch (errorCode) {
+ case "WRONG_OTP":
+ return intl.formatMessage({
+ id: "The code you entered is incorrect. Please try again.",
+ })
+ case "OTP_EXPIRED":
+ return intl.formatMessage({
+ id: "OTP has expired. Please try again.",
+ })
+ default:
+ return intl.formatMessage({
+ id: "An error occurred while requesting a new OTP",
+ })
+ }
+}
diff --git a/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/otp/loading.tsx b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/otp/loading.tsx
new file mode 100644
index 000000000..5b16fe51c
--- /dev/null
+++ b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/otp/loading.tsx
@@ -0,0 +1,11 @@
+import LoadingSpinner from "@/components/LoadingSpinner"
+
+import { SASModal } from "../components/SASModal"
+
+export default function Loading() {
+ return (
+
+
+
+ )
+}
diff --git a/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/otp/page.tsx b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/otp/page.tsx
new file mode 100644
index 000000000..cf7df475e
--- /dev/null
+++ b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/otp/page.tsx
@@ -0,0 +1,128 @@
+import { cookies } from "next/headers"
+import { redirect, RedirectType } from "next/navigation"
+import { z } from "zod"
+
+import { serverClient } from "@/lib/trpc/server"
+
+import { getIntl } from "@/i18n"
+import { safeTry } from "@/utils/safeTry"
+
+import { SAS_TOKEN_STORAGE_KEY } from "../sasUtils"
+import OneTimePasswordForm from "./OneTimePasswordForm"
+
+import type { LangParams, PageArgs, SearchParams } from "@/types/params"
+import type { Lang } from "@/constants/languages"
+
+const searchParamsSchema = z.object({
+ intent: z.enum(["link"]),
+ to: z.string(),
+ error: z.enum(["invalidCode"]).optional(),
+})
+
+export default async function SASxScandicOneTimePasswordPage({
+ searchParams,
+ params,
+}: PageArgs & SearchParams) {
+ const intl = await getIntl()
+ const cookieStore = cookies()
+ const tokenCookie = cookieStore.get(SAS_TOKEN_STORAGE_KEY)
+
+ const result = searchParamsSchema.safeParse(searchParams)
+ if (!result.success) {
+ throw new Error("Invalid search params")
+ }
+ const { intent, to, error } = result.data
+
+ if (!verifyTokenValidity(tokenCookie?.value)) {
+ redirect(`/${params.lang}/sas-x-scandic/login?intent=${intent}`)
+ }
+
+ const errors = {
+ invalidCode: intl.formatMessage({
+ id: "The code you’ve entered is incorrect.",
+ }),
+ }
+
+ async function handleOtpVerified({ otp }: { otp: string }) {
+ "use server"
+
+ const [data, error] = await safeTry(
+ serverClient().partner.sas.verifyOtp({ otp })
+ )
+
+ // TODO correct status?
+ // TODO handle all errors
+ // STATUS === VERIFIED => ok
+ // STATUS === ABUSED => otpRetryCount > otpMaxRetryCount
+ if (error || data?.status !== "VERIFIED") {
+ const search = new URLSearchParams({
+ ...searchParams,
+ error: "invalidCode",
+ }).toString()
+
+ redirect(`/${params.lang}/sas-x-scandic/otp?${search}`)
+ }
+
+ switch (intent) {
+ case "link":
+ return handleLinkAccount({ lang: params.lang })
+ default:
+ throw new Error("")
+ }
+ }
+
+ return (
+ (
+ {
+ id: "Please enter the code sent to in order to confirm your account linking.",
+ },
+ {
+ maskedContactInfo: () => (
+ <>
+
+ {to}
+
+ >
+ ),
+ }
+ )}
+ footnote={intl.formatMessage({
+ id: "This verifcation is needed for additional security.",
+ })}
+ otpLength={6}
+ onSubmit={handleOtpVerified}
+ error={error ? errors[error] : undefined}
+ />
+ )
+}
+
+function verifyTokenValidity(token: string | undefined) {
+ if (!token) {
+ return false
+ }
+
+ try {
+ const decoded = JSON.parse(atob(token.split(".")[1]))
+ const expiry = decoded.exp * 1000
+ return Date.now() < expiry
+ } catch (error) {
+ return false
+ }
+}
+
+async function handleLinkAccount({ lang }: { lang: Lang }) {
+ const [res, error] = await safeTry(serverClient().partner.sas.linkAccount())
+ if (!res || error) {
+ console.error("[SAS] link account error", error)
+ redirect(`/${lang}/sas-x-scandic/error?errorCode=link_error`)
+ }
+
+ console.log("[SAS] link account response", res)
+ switch (res.linkingState) {
+ case "linked":
+ redirect(`/${lang}/sas-x-scandic/link/success`, RedirectType.replace)
+ break
+ }
+}
diff --git a/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/sasUtils.ts b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/sasUtils.ts
new file mode 100644
index 000000000..383880b80
--- /dev/null
+++ b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/sasUtils.ts
@@ -0,0 +1,9 @@
+import { z } from "zod"
+
+export const SAS_TOKEN_STORAGE_KEY = "sas-x-scandic-token"
+
+// TODO nonce??
+export const stateSchema = z.object({
+ intent: z.literal("link"),
+})
+export type State = z.infer
diff --git a/app/[lang]/(partner)/layout.tsx b/app/[lang]/(partner)/layout.tsx
new file mode 100644
index 000000000..f7d11f826
--- /dev/null
+++ b/app/[lang]/(partner)/layout.tsx
@@ -0,0 +1,68 @@
+import "@/app/globals.css"
+import "@scandic-hotels/design-system/style.css"
+
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
+import Script from "next/script"
+
+import { env } from "@/env/server"
+import TrpcProvider from "@/lib/trpc/Provider"
+
+import TokenRefresher from "@/components/Auth/TokenRefresher"
+import CookieBotConsent from "@/components/CookieBot"
+import StorageCleaner from "@/components/HotelReservation/EnterDetails/StorageCleaner"
+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 { setLang } from "@/i18n/serverContext"
+
+import type { LangParams, LayoutArgs } from "@/types/params"
+
+export default async function RootLayout({
+ children,
+ params,
+}: React.PropsWithChildren>) {
+ if (!env.SAS_ENABLED) {
+ return null
+ }
+
+ setLang(params.lang)
+ preloadUserTracking()
+ const { defaultLocale, locale, messages } = await getIntl()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts
new file mode 100644
index 000000000..1cb663d96
--- /dev/null
+++ b/app/api/debug/route.ts
@@ -0,0 +1,16 @@
+import { notFound } from "next/navigation"
+import { NextResponse } from "next/server"
+
+import { env } from "@/env/server"
+
+import { auth } from "@/auth"
+
+export const GET = async () => {
+ if (env.NODE_ENV !== "development") {
+ return notFound()
+ }
+
+ const user = await auth()
+ console.log("[DEBUG] access-token", user?.token)
+ return NextResponse.json(user)
+}
diff --git a/components/HotelReservation/EnterDetails/Details/MemberPriceModal/modal.module.css b/components/HotelReservation/EnterDetails/Details/MemberPriceModal/modal.module.css
index fbbe644ba..43fdc5f70 100644
--- a/components/HotelReservation/EnterDetails/Details/MemberPriceModal/modal.module.css
+++ b/components/HotelReservation/EnterDetails/Details/MemberPriceModal/modal.module.css
@@ -21,4 +21,4 @@
.modalContent {
width: 352px;
}
-}
\ No newline at end of file
+}
diff --git a/components/Icons/ErrorCircleFilled.tsx b/components/Icons/ErrorCircleFilled.tsx
new file mode 100644
index 000000000..f4fc15ea0
--- /dev/null
+++ b/components/Icons/ErrorCircleFilled.tsx
@@ -0,0 +1,27 @@
+import { iconVariants } from "./variants"
+
+import type { IconProps } from "@/types/components/icon"
+
+export default function ErrorCircleFilledIcon({
+ className,
+ color,
+ ...props
+}: IconProps) {
+ const classNames = iconVariants({ className, color })
+ return (
+
+ )
+}
diff --git a/components/ProtectedLayout.tsx b/components/ProtectedLayout.tsx
new file mode 100644
index 000000000..c803663b4
--- /dev/null
+++ b/components/ProtectedLayout.tsx
@@ -0,0 +1,69 @@
+import { headers } from "next/headers"
+import { redirect } from "next/navigation"
+
+import { overview } from "@/constants/routes/myPages"
+import { getProfile } from "@/lib/trpc/memoizedRequests"
+
+import { auth } from "@/auth"
+import { getIntl } from "@/i18n"
+import { getLang } from "@/i18n/serverContext"
+
+export async function ProtectedLayout({ children }: React.PropsWithChildren) {
+ const intl = await getIntl()
+ const session = await auth()
+ /**
+ * Fallback to make sure every route nested in the
+ * protected route group is actually protected.
+ */
+ const h = headers()
+ const redirectTo = encodeURIComponent(
+ h.get("x-url") ?? h.get("x-pathname") ?? overview[getLang()]
+ )
+
+ const redirectURL = `/${getLang()}/login?redirectTo=${redirectTo}`
+
+ if (!session) {
+ console.log(`[layout:protected] no session, redirecting to: ${redirectURL}`)
+ redirect(redirectURL)
+ }
+
+ const user = await getProfile()
+
+ if (user && "error" in user) {
+ // redirect(redirectURL)
+ console.error("[layout:protected] error in user", user)
+ console.error(
+ "[layout:protected] full user: ",
+ JSON.stringify(user, null, 4)
+ )
+ switch (user.cause) {
+ case "unauthorized": // fall through
+ case "forbidden": // fall through
+ case "token_expired":
+ console.error(
+ `[layout:protected] user error, redirecting to: ${redirectURL}`
+ )
+ redirect(redirectURL)
+ case "notfound":
+ console.error(`[layout:protected] notfound user loading error`)
+ break
+ case "unknown":
+ console.error(`[layout:protected] unknown user loading error`)
+ break
+ default:
+ console.error(`[layout:protected] unhandled user loading error`)
+ break
+ }
+ return
{intl.formatMessage({ id: "Something went wrong!" })}
+ }
+
+ if (!user) {
+ console.error(
+ "[layout:protected] no user found, redirecting to: ",
+ redirectURL
+ )
+ redirect(redirectURL)
+ }
+
+ return children
+}
diff --git a/components/Redirect.tsx b/components/Redirect.tsx
new file mode 100644
index 000000000..04c59cedb
--- /dev/null
+++ b/components/Redirect.tsx
@@ -0,0 +1,19 @@
+"use client"
+
+import { useEffect } from "react"
+
+type RedirectProps = {
+ url: string
+ timeout?: number
+}
+export function Redirect({ url, timeout }: RedirectProps) {
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ window.location.href = url
+ }, timeout || 0)
+
+ return () => clearTimeout(timer)
+ }, [url, timeout])
+
+ return null
+}
diff --git a/env/server.ts b/env/server.ts
index cb6add00e..faa087dd4 100644
--- a/env/server.ts
+++ b/env/server.ts
@@ -157,6 +157,18 @@ export const env = createEnv({
.default("false"),
SENTRY_ENVIRONMENT: z.string().default("development"),
SENTRY_SERVER_SAMPLERATE: z.coerce.number().default(0.001),
+
+ // TODO: remove defaults for SAS value when we know that netlify has 'room' for it
+ SAS_API_ENDPOINT: z.string().default(""),
+ SAS_AUTH_ENDPOINT: z.string().default(""),
+ SAS_OCP_APIM: z.string().default(""),
+ SAS_AUTH_CLIENTID: z.string().default(""),
+ SAS_ENABLED: z
+ .string()
+ .refine((s) => s === "1" || s === "0")
+ .transform((s) => s === "1")
+ .default("0"),
+
CACHE_TIME_HOTELDATA: z
.number()
.transform(() =>
@@ -247,6 +259,13 @@ export const env = createEnv({
SHOW_SITE_WIDE_ALERT: process.env.SHOW_SITE_WIDE_ALERT,
SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
SENTRY_SERVER_SAMPLERATE: process.env.SENTRY_SERVER_SAMPLERATE,
+
+ SAS_API_ENDPOINT: process.env.SAS_API_ENDPOINT,
+ SAS_AUTH_ENDPOINT: process.env.SAS_AUTH_ENDPOINT,
+ SAS_OCP_APIM: process.env.SAS_OCP_APIM,
+ SAS_AUTH_CLIENTID: process.env.SAS_AUTH_CLIENTID,
+ SAS_ENABLED: process.env.SAS,
+
CACHE_TIME_HOTELDATA: process.env.CACHE_TIME_HOTELDATA,
CACHE_TIME_HOTELS: process.env.CACHE_TIME_HOTELS,
},
diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json
index bd81bb5e1..001fcf36c 100644
--- a/i18n/dictionaries/da.json
+++ b/i18n/dictionaries/da.json
@@ -14,6 +14,7 @@
"Accessibility": "Tilgængelighed",
"Accessibility at {hotel}": "Tilgængelighed på {hotel}",
"Accessible Room": "Tilgængelighedsrum",
+ "Accounts are already linked": "Accounts are already linked",
"Active": "Aktiv",
"Activities": "Aktiviteter",
"Add code": "Tilføj kode",
@@ -57,6 +58,7 @@
"Bed type": "Seng type",
"Bike friendly": "Cykelvenlig",
"Birth date": "Fødselsdato",
+ "Birth date is required": "Birth date is required",
"Book": "Book",
"Book a table online": "Book et bord online",
"Book parking": "Book parkering",
@@ -78,6 +80,7 @@
"Bus terminal": "Busstation",
"Business": "Forretning",
"By accepting the Terms and Conditions for Scandic Friends I understand that my personal data will be processed in accordance with Scandic's Privacy Policy.": "Ved at acceptere vilkårene og betingelserne for Scandic Friends, forstår jeg, at mine personlige oplysninger vil blive behandlet i overensstemmelse med Scandics privatlivspolitik.",
+ "By linking your accounts you accept the Scandic Friends & SAS Terms and Conditions. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.": "By linking your accounts you accept the Scandic Friends & SAS Terms and Conditions. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
"By paying with any of the payment methods available, I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.": "Ved at betale med en af de tilgængelige betalingsmetoder, accepterer jeg vilkårene for denne booking og de generelle Vilkår og betingelser, og forstår, at Scandic vil behandle min personlige data i forbindelse med denne booking i henhold til Scandics Privatlivspolitik. Jeg accepterer, at Scandic kræver et gyldigt kreditkort under min besøg i tilfælde af, at noget er tilbagebetalt.",
"By signing up you accept the Scandic Friends Terms and Conditions. Your membership is valid until further notice, and you can terminate your membership at any time by sending an email to Scandic's customer service": "Ved at tilmelde dig accepterer du Scandic Friends vilkår og betingelser. Dit medlemskab er gyldigt indtil videre, og du kan til enhver tid opsige dit medlemskab ved at sende en e-mail til Scandics kundeservice",
"Campaign": "Kampagne",
@@ -113,6 +116,7 @@
"Complete booking & go to payment": "Udfyld booking & gå til betaling",
"Complete the booking": "Fuldfør bookingen",
"Contact information": "Kontaktoplysninger",
+ "Contact our memberservice": "Contact our memberservice",
"Contact us": "Kontakt os",
"Continue": "Blive ved",
"Could not find requested resource": "Kunne ikke finde den anmodede ressource",
@@ -127,11 +131,13 @@
"Current password": "Nuværende kodeord",
"Customer service": "Kundeservice",
"Date of Birth": "Fødselsdato",
+ "Date of birth not matching": "Date of birth not matching",
"Day": "Dag",
"Description": "Beskrivelse",
"Destination": "Destination",
"Destinations & hotels": "Destinationer & hoteller",
"Details": "Detaljer",
+ "Didn't receive a code? Resend code": "Didn't receive a code? Resend code",
"Discard changes": "Kassér ændringer",
"Discard unsaved changes?": "Slette ændringer, der ikke er gemt?",
"Discover": "Opdag",
@@ -184,6 +190,7 @@
"Free parking": "Gratis parkering",
"Free rebooking": "Gratis ombooking",
"Friday": "Fredag",
+ "Friends with Benefits": "Friends with Benefits",
"From": "Fra",
"Garage": "Garage",
"Get inspired": "Bliv inspireret",
@@ -214,9 +221,11 @@
"I accept": "Jeg accepterer",
"I accept the terms and conditions": "Jeg accepterer vilkårene",
"I would like to get my booking confirmation via sms": "Jeg vil gerne få min booking bekræftelse via SMS",
+ "If you are not redirected automatically, please click here.": "If you are not redirected automatically, please click here.",
"In adults bed": "i de voksnes seng",
"In crib": "i tremmeseng",
"In extra bed": "i ekstra seng",
+ "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.": "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
"Included": "Inkluderet",
"IndoorPool": "Indendørs pool",
"Is there anything else you would like us to know before your arrival?": "Er der andet, du gerne vil have os til at vide, før din ankomst?",
@@ -244,6 +253,8 @@
"Level 7": "Niveau 7",
"Level up to unlock": "Stig i niveau for at låse op",
"Level {level}": "Niveau {level}",
+ "Link my accounts": "Link my accounts",
+ "Link your accounts": "Link your accounts",
"Location": "Beliggenhed",
"Locations": "Placeringer",
"Log in": "Log på",
@@ -346,6 +357,7 @@
"Phone is required": "Telefonnummer er påkrævet",
"Phone number": "Telefonnummer",
"Please enter a valid phone number": "Indtast venligst et gyldigt telefonnummer",
+ "Please enter the code sent to in order to confirm your account linking.": "Please enter the code sent to in order to confirm your account linking.",
"Please note that this is mandatory, and that your card will only be charged in the event of a no-show.": "Vær opmærksom på, at dette er påkrævet, og at dit kort kun vil blive opkrævet i tilfælde af en no-show.",
"Points": "Point",
"Points being calculated": "Point udregnes",
@@ -375,6 +387,8 @@
"Read more & book a table": "Read more & book a table",
"Read more about the hotel": "Læs mere om hotellet",
"Read more about wellness & exercise": "Read more about wellness & exercise",
+ "Redirecting you to SAS": "Redirecting you to SAS",
+ "Redirecting you to my pages.": "Redirecting you to my pages.",
"Reference #{bookingNr}": "Reference #{bookingNr}",
"Relax": "Slap af",
"Remove card from member profile": "Fjern kortet fra medlemsprofilen",
@@ -453,6 +467,7 @@
"Terms and conditions": "Vilkår og betingelser",
"Thank you": "Tak",
"Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please contact us.": "Tak fordi du bookede hos os! Vi glæder os til at byde dig velkommen og håber du får et behageligt ophold. Hvis du har spørgsmål eller har brug for at foretage ændringer i din reservation, bedes du kontakte os.",
+ "The code you’ve entered is incorrect.": "The code you’ve entered is incorrect.",
"The new price is": "Nyprisen er",
"The price has increased": "Prisen er steget",
"The price has increased since you selected your room.": "Prisen er steget, efter at du har valgt dit værelse.",
@@ -463,6 +478,7 @@
"Things nearby {hotelName}": "Ting i nærheden af {hotelName}",
"This room is equipped with": "Dette værelse er udstyret med",
"This room is not available": "Dette værelse er ikke tilgængeligt",
+ "This verifcation is needed for additional security.": "This verifcation is needed for additional security.",
"Thursday": "Torsdag",
"Times": "Tider",
"To get the member price {price}, log in or join when completing the booking.": "For at få medlemsprisen {price}, log ind eller tilmeld dig, når du udfylder bookingen.",
@@ -484,16 +500,22 @@
"User information": "Brugeroplysninger",
"VAT {vat}%": "Moms {vat}%",
"Valid through {expirationDate}": "Gyldig til og med {expirationDate}",
+ "Verification code": "Verification code",
"View as list": "Vis som liste",
"View as map": "Vis som kort",
+ "View your account": "View your account",
"View your booking": "Se din booking",
"Visiting address": "Besøgsadresse",
"Voucher": "Voucher",
"We could not add a card right now, please try again later.": "Vi kunne ikke tilføje et kort lige nu. Prøv venligst igen senere.",
+ "We could not connect your accounts": "We could not connect your accounts",
+ "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.": "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.",
"We couldn't find a matching location for your search.": "Vi kunne ikke finde en matchende lokation til din søgning.",
"We had an issue processing your booking. Please try again. No charges have been made.": "Vi havde et problem med at behandle din booking. Prøv venligst igen. Ingen gebyrer er blevet opkrævet.",
"We have a special gift waiting for you!": "Vi har en speciel gave, der venter på dig!",
"We look forward to your visit!": "Vi ser frem til dit besøg!",
+ "We require this additional information in order to match your Scandic account with your EuroBonus account.": "We require this additional information in order to match your Scandic account with your EuroBonus account.",
+ "We successfully connected your accounts!": "We successfully connected your accounts!",
"We're sorry": "Vi beklager",
"Wednesday": "Onsdag",
"Weekday": "Ugedag",
@@ -522,8 +544,10 @@
"You have no previous stays.": "Du har ingen tidligere ophold.",
"You have no upcoming stays.": "Du har ingen kommende ophold.",
"You have now cancelled your payment.": "Du har nu annulleret din betaling.",
+ "You must accept the terms and conditions": "You must accept the terms and conditions",
"You'll find all your gifts in 'My benefits'": "Du finder alle dine gaver i 'Mine fordele'",
"Your Challenges Conquer & Earn!": "Dine udfordringer Overvind og tjen!",
+ "Your accounts are connected": "Your accounts are connected",
"Your booking(s) is confirmed but we could not verify your membership. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay.": "Din booking er bekræftet, men vi kunne ikke verificere dit medlemskab. Hvis du har booket med et medlemstilbud, skal du enten vise dit eksisterende medlemskab ved check-in, blive medlem eller betale prisdifferencen ved check-in. Tilmelding er foretrukket online før opholdet.",
"Your card was successfully removed!": "Dit kort blev fjernet!",
"Your card was successfully saved!": "Dit kort blev gemt!",
diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json
index 5700320d2..995e9a404 100644
--- a/i18n/dictionaries/de.json
+++ b/i18n/dictionaries/de.json
@@ -14,6 +14,7 @@
"Accessibility": "Zugänglichkeit",
"Accessibility at {hotel}": "Barrierefreiheit im {hotel}",
"Accessible Room": "Barrierefreies Zimmer",
+ "Accounts are already linked": "Accounts are already linked",
"Active": "Aktiv",
"Activities": "Aktivitäten",
"Add code": "Code hinzufügen",
@@ -57,6 +58,7 @@
"Bed type": "Bettentyp",
"Bike friendly": "Fahrradfreundlich",
"Birth date": "Geburtsdatum",
+ "Birth date is required": "Birth date is required",
"Book": "Buchen",
"Book a table online": "Tisch online buchen",
"Book parking": "Parkplatz buchen",
@@ -77,6 +79,7 @@
"Breakfast selection in next step.": "Frühstücksauswahl in nächsten Schritt.",
"Business": "Geschäft",
"By accepting the Terms and Conditions for Scandic Friends I understand that my personal data will be processed in accordance with Scandic's Privacy Policy.": "Mit der Annahme der Allgemeinen Geschäftsbedingungen für Scandic Friends erkläre ich mich damit einverstanden, dass meine persönlichen Daten in Übereinstimmung mit der Datenschutzrichtlinie von Scandic verarbeitet werden.",
+ "By linking your accounts you accept the Scandic Friends & SAS Terms and Conditions. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.": "By linking your accounts you accept the Scandic Friends & SAS Terms and Conditions. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
"By paying with any of the payment methods available, I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.": "Mit der Zahlung über eine der verfügbaren Zahlungsmethoden akzeptiere ich die Buchungsbedingungen und die allgemeinen Geschäftsbedingungen und verstehe, dass Scandic meine personenbezogenen Daten im Zusammenhang mit dieser Buchung gemäß der Scandic Datenschutzrichtlinie verarbeitet. Ich akzeptiere, dass Scandic während meines Aufenthalts eine gültige Kreditkarte für eventuelle Rückerstattungen benötigt.",
"By signing up you accept the Scandic Friends Terms and Conditions. Your membership is valid until further notice, and you can terminate your membership at any time by sending an email to Scandic's customer service": "Mit Ihrer Anmeldung akzeptieren Sie die Allgemeinen Geschäftsbedingungen von Scandic Friends. Ihre Mitgliedschaft ist bis auf Weiteres gültig und Sie können sie jederzeit kündigen, indem Sie eine E-Mail an den Kundenservice von Scandic senden.",
"Campaign": "Kampagne",
@@ -112,6 +115,7 @@
"Complete booking & go to payment": "Buchung abschließen & zur Bezahlung gehen",
"Complete the booking": "Buchung abschließen",
"Contact information": "Kontaktinformationen",
+ "Contact our memberservice": "Contact our memberservice",
"Contact us": "Kontaktieren Sie uns",
"Continue": "Weitermachen",
"Could not find requested resource": "Die angeforderte Ressource konnte nicht gefunden werden.",
@@ -126,11 +130,13 @@
"Current password": "Aktuelles Passwort",
"Customer service": "Kundendienst",
"Date of Birth": "Geburtsdatum",
+ "Date of birth not matching": "Date of birth not matching",
"Day": "Tag",
"Description": "Beschreibung",
"Destination": "Bestimmungsort",
"Destinations & hotels": "Reiseziele & Hotels",
"Details": "Details",
+ "Didn't receive a code? Resend code": "Didn't receive a code? Resend code",
"Discard changes": "Änderungen verwerfen",
"Discard unsaved changes?": "Nicht gespeicherte Änderungen verwerfen?",
"Discover": "Entdecken",
@@ -183,6 +189,7 @@
"Free parking": "Kostenloses Parken",
"Free rebooking": "Kostenlose Umbuchung",
"Friday": "Freitag",
+ "Friends with Benefits": "Friends with Benefits",
"From": "Fromm",
"Garage": "Garage",
"Get inspired": "Lassen Sie sich inspieren",
@@ -213,9 +220,11 @@
"I accept": "Ich akzeptiere",
"I accept the terms and conditions": "Ich akzeptiere die Geschäftsbedingungen",
"I would like to get my booking confirmation via sms": "Ich möchte meine Buchungsbestätigung per SMS erhalten",
+ "If you are not redirected automatically, please click here.": "If you are not redirected automatically, please click here.",
"In adults bed": "Im Bett der Eltern",
"In crib": "im Kinderbett",
"In extra bed": "im zusätzlichen Bett",
+ "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.": "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
"Included": "Iinklusive",
"IndoorPool": "Innenpool",
"Is there anything else you would like us to know before your arrival?": "Gibt es noch etwas, das Sie uns vor Ihrer Ankunft mitteilen möchten?",
@@ -243,6 +252,8 @@
"Level 7": "Level 7",
"Level up to unlock": "Zum Freischalten aufsteigen",
"Level {level}": "Level {level}",
+ "Link my accounts": "Link my accounts",
+ "Link your accounts": "Link your accounts",
"Location": "Ort",
"Locations": "Orte",
"Log in": "Anmeldung",
@@ -344,6 +355,7 @@
"Phone is required": "Telefon ist erforderlich",
"Phone number": "Telefonnummer",
"Please enter a valid phone number": "Bitte geben Sie eine gültige Telefonnummer ein",
+ "Please enter the code sent to in order to confirm your account linking.": "Please enter the code sent to in order to confirm your account linking.",
"Please note that this is mandatory, and that your card will only be charged in the event of a no-show.": "Bitte beachten Sie, dass dies erforderlich ist und dass Ihr Kreditkartenkonto nur in einem No-Show-Fall belastet wird.",
"Points": "Punkte",
"Points being calculated": "Punkte werden berechnet",
@@ -373,6 +385,8 @@
"Read more & book a table": "Read more & book a table",
"Read more about the hotel": "Lesen Sie mehr über das Hotel",
"Read more about wellness & exercise": "Read more about wellness & exercise",
+ "Redirecting you to SAS": "Redirecting you to SAS",
+ "Redirecting you to my pages.": "Redirecting you to my pages.",
"Reference #{bookingNr}": "Referenz #{bookingNr}",
"Relax": "Entspannen",
"Remove card from member profile": "Karte aus dem Mitgliedsprofil entfernen",
@@ -452,6 +466,7 @@
"Terms and conditions": "Geschäftsbedingungen",
"Thank you": "Danke",
"Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please contact us.": "Vielen Dank, dass Sie bei uns gebucht haben! Wir freuen uns, Sie bei uns begrüßen zu dürfen und wünschen Ihnen einen angenehmen Aufenthalt. Wenn Sie Fragen haben oder Änderungen an Ihrer Buchung vornehmen müssen, kontaktieren Sie uns bitte..",
+ "The code you’ve entered is incorrect.": "The code you’ve entered is incorrect.",
"The new price is": "Der neue Preis beträgt",
"The price has increased": "Der Preis ist gestiegen",
"The price has increased since you selected your room.": "Der Preis ist gestiegen, nachdem Sie Ihr Zimmer ausgewählt haben.",
@@ -461,6 +476,7 @@
"Things nearby {hotelName}": "Dinge in der Nähe von {hotelName}",
"This room is equipped with": "Dieses Zimmer ist ausgestattet mit",
"This room is not available": "Dieses Zimmer ist nicht verfügbar",
+ "This verifcation is needed for additional security.": "This verifcation is needed for additional security.",
"Thursday": "Donnerstag",
"Times": "Zeiten",
"To get the member price {price}, log in or join when completing the booking.": "Um den Mitgliederpreis von {price} zu erhalten, loggen Sie sich ein oder treten Sie Scandic Friends bei, wenn Sie die Buchung abschließen.",
@@ -482,16 +498,22 @@
"User information": "Nutzerinformation",
"VAT {vat}%": "MwSt. {vat}%",
"Valid through {expirationDate}": "Gültig bis {expirationDate}",
+ "Verification code": "Verification code",
"View as list": "Als Liste anzeigen",
"View as map": "Als Karte anzeigen",
+ "View your account": "View your account",
"View your booking": "Ihre Buchung ansehen",
"Visiting address": "Besuchsadresse",
"Voucher": "Gutschein",
"We could not add a card right now, please try again later.": "Wir konnten momentan keine Karte hinzufügen. Bitte versuchen Sie es später noch einmal.",
+ "We could not connect your accounts": "We could not connect your accounts",
+ "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.": "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.",
"We couldn't find a matching location for your search.": "Wir konnten keinen passenden Standort für Ihre Suche finden.",
"We had an issue processing your booking. Please try again. No charges have been made.": "Wir hatten ein Problem beim Verarbeiten Ihrer Buchung. Bitte versuchen Sie es erneut. Es wurden keine Gebühren erhoben.",
"We have a special gift waiting for you!": "Wir haben ein besonderes Geschenk für Sie!",
"We look forward to your visit!": "Wir freuen uns auf Ihren Besuch!",
+ "We require this additional information in order to match your Scandic account with your EuroBonus account.": "We require this additional information in order to match your Scandic account with your EuroBonus account.",
+ "We successfully connected your accounts!": "We successfully connected your accounts!",
"We're sorry": "Es tut uns leid",
"Wednesday": "Mittwoch",
"Weekday": "Wochentag",
@@ -520,8 +542,10 @@
"You have no previous stays.": "Sie haben keine vorherigen Aufenthalte.",
"You have no upcoming stays.": "Sie haben keine bevorstehenden Aufenthalte.",
"You have now cancelled your payment.": "Sie haben jetzt Ihre Zahlung abgebrochen.",
+ "You must accept the terms and conditions": "You must accept the terms and conditions",
"You'll find all your gifts in 'My benefits'": "Alle Ihre Geschenke finden Sie unter „Meine Vorteile“",
"Your Challenges Conquer & Earn!": "Meistern Sie Ihre Herausforderungen und verdienen Sie Geld!",
+ "Your accounts are connected": "Your accounts are connected",
"Your booking(s) is confirmed but we could not verify your membership. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay.": "Ihre Buchung ist bestätigt, aber wir konnten Ihr Mitglied nicht verifizieren. Wenn Sie mit einem Mitgliederrabatt gebucht haben, müssen Sie entweder Ihr vorhandenes Mitgliedschaftsnummer bei der Anreise präsentieren, ein Mitglied werden oder die Preisdifferenz bei der Anreise bezahlen. Die Anmeldung ist vorzugsweise online vor der Aufenthaltsdauer erfolgreich.",
"Your card was successfully removed!": "Ihre Karte wurde erfolgreich entfernt!",
"Your card was successfully saved!": "Ihre Karte wurde erfolgreich gespeichert!",
diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json
index acb978d1d..22ac55f31 100644
--- a/i18n/dictionaries/en.json
+++ b/i18n/dictionaries/en.json
@@ -1,4 +1,5 @@
{
+ "+46 8 517 517 00": "+46 8 517 517 00",
"Included (based on availability)": "Included (based on availability)",
"Total price (incl VAT)": "Total price (incl VAT)",
"{amount}0 {currency}/night per adult": "{amount}0 {currency}/night per adult",
@@ -14,6 +15,7 @@
"Accessibility": "Accessibility",
"Accessibility at {hotel}": "Accessibility at {hotel}",
"Accessible Room": "Accessibility room",
+ "Accounts are already linked": "Accounts are already linked",
"Active": "Active",
"Activities": "Activities",
"Add code": "Add code",
@@ -49,6 +51,7 @@
"Attractions": "Attractions",
"Average price per night": "Average price per night",
"Away from elevator": "Away from elevator",
+ "Back": "Back",
"Back to scandichotels.com": "Back to scandichotels.com",
"Back to top": "Back to top",
"Bar": "Bar",
@@ -58,6 +61,7 @@
"Bed type": "Bed type",
"Bike friendly": "Bike friendly",
"Birth date": "Birth date",
+ "Birth date is required": "Birth date is required",
"Book": "Book",
"Book a table online": "Book a table online",
"Book another stay": "Book another stay",
@@ -82,6 +86,7 @@
"Bus terminal": "Bus terminal",
"Business": "Business",
"By accepting the Terms and Conditions for Scandic Friends I understand that my personal data will be processed in accordance with Scandic's Privacy Policy.": "By accepting the Terms and Conditions for Scandic Friends I understand that my personal data will be processed in accordance with Scandic's Privacy Policy.",
+ "By linking your accounts you accept the Scandic Friends & SAS Terms and Conditions. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.": "By linking your accounts you accept the Scandic Friends & SAS Terms and Conditions. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
"By paying with any of the payment methods available, I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.": "By paying with any of the payment methods available, I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.",
"By signing up you accept the Scandic Friends Terms and Conditions. Your membership is valid until further notice, and you can terminate your membership at any time by sending an email to Scandic's customer service": "By signing up you accept the Scandic Friends Terms and Conditions. Your membership is valid until further notice, and you can terminate your membership at any time by sending an email to Scandic's customer service",
"Campaign": "Campaign",
@@ -123,6 +128,7 @@
"Complete booking & go to payment": "Complete booking & go to payment",
"Complete the booking": "Complete the booking",
"Contact information": "Contact information",
+ "Contact our memberservice": "Contact our memberservice",
"Contact us": "Contact us",
"Continue": "Continue",
"Copied to clipboard": "Copied to clipboard",
@@ -139,12 +145,14 @@
"Current password": "Current password",
"Customer service": "Customer service",
"Date of Birth": "Date of Birth",
+ "Date of birth not matching": "Date of birth not matching",
"Day": "Day",
"Description": "Description",
"Destination": "Destination",
"Destinations & hotels": "Destinations & hotels",
"Details": "Details",
"Dialog": "Dialog",
+ "Didn't receive a code? Resend code": "Didn't receive a code? Resend code",
"Discard changes": "Discard changes",
"Discard unsaved changes?": "Discard unsaved changes?",
"Discover": "Discover",
@@ -200,6 +208,7 @@
"Free until": "Free until",
"Friday": "Friday",
"Friend no. {value}": "Friend no. {value}",
+ "Friends with Benefits": "Friends with Benefits",
"From": "From",
"Garage": "Garage",
"Get inspired": "Get inspired",
@@ -233,9 +242,11 @@
"I accept": "I accept",
"I accept the terms and conditions": "I accept the terms and conditions",
"I would like to get my booking confirmation via sms": "I would like to get my booking confirmation via sms",
+ "If you are not redirected automatically, please click here.": "If you are not redirected automatically, please click here.",
"In adults bed": "In adults bed",
"In crib": "In crib",
"In extra bed": "In extra bed",
+ "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.": "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
"Included": "Included",
"IndoorPool": "Indoor pool",
"Is there anything else you would like us to know before your arrival?": "Is there anything else you would like us to know before your arrival?",
@@ -263,6 +274,8 @@
"Level 7": "Level 7",
"Level up to unlock": "Level up to unlock",
"Level {level}": "Level {level}",
+ "Link my accounts": "Link my accounts",
+ "Link your accounts": "Link your accounts",
"Location": "Location",
"Locations": "Locations",
"Log in": "Log in",
@@ -377,6 +390,7 @@
"Phone is required": "Phone is required",
"Phone number": "Phone number",
"Please enter a valid phone number": "Please enter a valid phone number",
+ "Please enter the code sent to in order to confirm your account linking.": "Please enter the code sent to in order to confirm your account linking.",
"Please note that this is mandatory, and that your card will only be charged in the event of a no-show.": "Please note that this is mandatory, and that your card will only be charged in the event of a no-show.",
"Please try and change your search for this destination or see alternative hotels.": "Please try and change your search for this destination or see alternative hotels.",
"Points": "Points",
@@ -415,6 +429,8 @@
"Rebooking": "Rebooking",
"Redeem benefit": "Redeem benefit",
"Redeemed & valid through:": "Redeemed & valid through:",
+ "Redirecting you to SAS": "Redirecting you to SAS",
+ "Redirecting you to my pages.": "Redirecting you to my pages.",
"Reference #{bookingNr}": "Reference #{bookingNr}",
"Relax": "Relax",
"Remove card from member profile": "Remove card from member profile",
@@ -499,6 +515,7 @@
"Terms and conditions": "Terms and conditions",
"Thank you": "Thank you",
"Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please contact us.": "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please contact us.",
+ "The code you’ve entered is incorrect.": "The code you’ve entered is incorrect.",
"The new price is": "The new price is",
"The price has increased": "The price has increased",
"The price has increased since you selected your room.": "The price has increased since you selected your room.",
@@ -508,6 +525,7 @@
"Things nearby {hotelName}": "Things nearby {hotelName}",
"This room is equipped with": "This room is equipped with",
"This room is not available": "This room is not available",
+ "This verifcation is needed for additional security.": "This verifcation is needed for additional security.",
"Thursday": "Thursday",
"Times": "Times",
"To get the member price {price}, log in or join when completing the booking.": "To get the member price {price}, log in or join when completing the booking.",
@@ -531,19 +549,25 @@
"VAT amount": "VAT amount",
"VAT {vat}%": "VAT {vat}%",
"Valid through {expirationDate}": "Valid through {expirationDate}",
+ "Verification code": "Verification code",
"View and buy add-ons": "View and buy add-ons",
"View as list": "View as list",
"View as map": "View as map",
"View room details": "View room details",
"View terms": "View terms",
+ "View your account": "View your account",
"View your booking": "View your booking",
"Visiting address": "Visiting address",
"Voucher": "Voucher",
"We could not add a card right now, please try again later.": "We could not add a card right now, please try again later.",
+ "We could not connect your accounts": "We could not connect your accounts",
+ "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.": "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.",
"We couldn't find a matching location for your search.": "We couldn't find a matching location for your search.",
"We had an issue processing your booking. Please try again. No charges have been made.": "We had an issue processing your booking. Please try again. No charges have been made.",
"We have a special gift waiting for you!": "We have a special gift waiting for you!",
"We look forward to your visit!": "We look forward to your visit!",
+ "We require this additional information in order to match your Scandic account with your EuroBonus account.": "We require this additional information in order to match your Scandic account with your EuroBonus account.",
+ "We successfully connected your accounts!": "We successfully connected your accounts!",
"We're sorry": "We're sorry",
"Wednesday": "Wednesday",
"Weekday": "Weekday",
@@ -573,8 +597,10 @@
"You have no previous stays.": "You have no previous stays.",
"You have no upcoming stays.": "You have no upcoming stays.",
"You have now cancelled your payment.": "You have now cancelled your payment.",
+ "You must accept the terms and conditions": "You must accept the terms and conditions",
"You'll find all your gifts in 'My benefits'": "You'll find all your gifts in 'My benefits'",
"Your Challenges Conquer & Earn!": "Your Challenges Conquer & Earn!",
+ "Your accounts are connected": "Your accounts are connected",
"Your booking(s) is confirmed but we could not verify your membership. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay.": "Your booking(s) is confirmed but we could not verify your membership. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay.",
"Your card was successfully removed!": "Your card was successfully removed!",
"Your card was successfully saved!": "Your card was successfully saved!",
@@ -596,6 +622,7 @@
"monday": "monday",
"next level: {nextLevel}": "next level: {nextLevel}",
"night": "night",
+ "or": "or",
"points": "points",
"saturday": "saturday",
"sunday": "sunday",
diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json
index 91a5f6edf..075bcb38f 100644
--- a/i18n/dictionaries/fi.json
+++ b/i18n/dictionaries/fi.json
@@ -14,6 +14,7 @@
"Accessibility": "Saavutettavuus",
"Accessibility at {hotel}": "Esteettömyys {hotel}",
"Accessible Room": "Esteetön huone",
+ "Accounts are already linked": "Accounts are already linked",
"Active": "Aktiivinen",
"Activities": "Aktiviteetit",
"Add code": "Lisää koodi",
@@ -57,6 +58,7 @@
"Bed type": "Vuodetyyppi",
"Bike friendly": "Pyöräystävällinen",
"Birth date": "Syntymäaika",
+ "Birth date is required": "Birth date is required",
"Book": "Varaa",
"Book a table online": "Varaa pöytä verkossa",
"Book parking": "Varaa pysäköinti",
@@ -78,6 +80,7 @@
"Bus terminal": "Bussiasema",
"Business": "Business",
"By accepting the Terms and Conditions for Scandic Friends I understand that my personal data will be processed in accordance with Scandic's Privacy Policy.": "Kyllä, hyväksyn Scandic Friends -jäsenyyttä koskevat ehdot ja ymmärrän, että Scandic käsittelee henkilötietojani Scandicin Tietosuojaselosteen mukaisesti.",
+ "By linking your accounts you accept the Scandic Friends & SAS Terms and Conditions. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.": "By linking your accounts you accept the Scandic Friends & SAS Terms and Conditions. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
"By paying with any of the payment methods available, I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.": "Maksamalla minkä tahansa saatavilla olevan maksutavan avulla hyväksyn tämän varauksen ehdot ja yleiset ehdot ja ehtoja, ja ymmärrän, että Scandic käsittelee minun henkilötietoni tässä varauksessa mukaisesti Scandicin tietosuojavaltuuden mukaisesti. Hyväksyn myös, että Scandic vaatii validin luottokortin majoituksen ajan, jos jokin jää maksamatta.",
"By signing up you accept the Scandic Friends Terms and Conditions. Your membership is valid until further notice, and you can terminate your membership at any time by sending an email to Scandic's customer service": "Rekisteröitymällä hyväksyt Scandic Friendsin käyttöehdot. Jäsenyytesi on voimassa toistaiseksi ja voit lopettaa jäsenyytesi milloin tahansa lähettämällä sähköpostia Scandicin asiakaspalveluun",
"Campaign": "Kampanja",
@@ -113,6 +116,7 @@
"Complete booking & go to payment": "Täydennä varaus & siirry maksamaan",
"Complete the booking": "Täydennä varaus",
"Contact information": "Yhteystiedot",
+ "Contact our memberservice": "Contact our memberservice",
"Contact us": "Ota meihin yhteyttä",
"Continue": "Jatkaa",
"Could not find requested resource": "Pyydettyä resurssia ei löytynyt",
@@ -127,11 +131,13 @@
"Current password": "Nykyinen salasana",
"Customer service": "Asiakaspalvelu",
"Date of Birth": "Syntymäaika",
+ "Date of birth not matching": "Date of birth not matching",
"Day": "Päivä",
"Description": "Kuvaus",
"Destination": "Kohde",
"Destinations & hotels": "Kohteet ja hotellit",
"Details": "Tiedot",
+ "Didn't receive a code? Resend code": "Didn't receive a code? Resend code",
"Discard changes": "Hylkää muutokset",
"Discard unsaved changes?": "Hylkäätkö tallentamattomat muutokset?",
"Discover": "Löydä",
@@ -184,6 +190,7 @@
"Free parking": "Ilmainen pysäköinti",
"Free rebooking": "Ilmainen uudelleenvaraus",
"Friday": "Perjantai",
+ "Friends with Benefits": "Friends with Benefits",
"From": "From",
"Garage": "Autotalli",
"Get inspired": "Inspiroidu",
@@ -214,9 +221,11 @@
"I accept": "Hyväksyn",
"I accept the terms and conditions": "Hyväksyn käyttöehdot",
"I would like to get my booking confirmation via sms": "Haluan saada varauksen vahvistuksen SMS-viestillä",
+ "If you are not redirected automatically, please click here.": "If you are not redirected automatically, please click here.",
"In adults bed": "Aikuisten vuoteessa",
"In crib": "Pinnasängyssä",
"In extra bed": "Oma vuodepaikka",
+ "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.": "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
"Included": "Sisälly hintaan",
"IndoorPool": "Sisäuima-allas",
"Is there anything else you would like us to know before your arrival?": "Onko jotain muuta, mitä haluaisit meidän tietävän ennen saapumistasi?",
@@ -244,6 +253,8 @@
"Level 7": "Taso 7",
"Level up to unlock": "Nosta taso avataksesi lukituksen",
"Level {level}": "Taso {level}",
+ "Link my accounts": "Link my accounts",
+ "Link your accounts": "Link your accounts",
"Location": "Sijainti",
"Locations": "Sijainnit",
"Log in": "Kirjaudu sisään",
@@ -346,6 +357,7 @@
"Phone is required": "Puhelin vaaditaan",
"Phone number": "Puhelinnumero",
"Please enter a valid phone number": "Ole hyvä ja näppäile voimassaoleva puhelinnumero",
+ "Please enter the code sent to in order to confirm your account linking.": "Please enter the code sent to in order to confirm your account linking.",
"Please note that this is mandatory, and that your card will only be charged in the event of a no-show.": "Huomaa, että tämä on pakollinen, ja että maksukorttiisi kirjataan vain, jos varausmyyntiä ei tapahtu.",
"Points": "Pisteet",
"Points being calculated": "Pisteitä lasketaan",
@@ -375,6 +387,8 @@
"Read more & book a table": "Read more & book a table",
"Read more about the hotel": "Lue lisää hotellista",
"Read more about wellness & exercise": "Read more about wellness & exercise",
+ "Redirecting you to SAS": "Redirecting you to SAS",
+ "Redirecting you to my pages.": "Redirecting you to my pages.",
"Reference #{bookingNr}": "Referenssi #{bookingNr}",
"Relax": "Rentoutua",
"Remove card from member profile": "Poista kortti jäsenprofiilista",
@@ -455,6 +469,7 @@
"Terms and conditions": "Säännöt ja ehdot",
"Thank you": "Kiitos",
"Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please contact us.": "Kiitos, että teit varauksen meiltä! Toivotamme sinut tervetulleeksi ja toivomme sinulle miellyttävää oleskelua. Jos sinulla on kysyttävää tai haluat tehdä muutoksia varaukseesi, ota meihin yhteyttä.",
+ "The code you’ve entered is incorrect.": "The code you’ve entered is incorrect.",
"The new price is": "Uusi hinta on",
"The price has increased": "Hinta on noussut",
"The price has increased since you selected your room.": "Hinta on noussut, koska valitsit huoneen.",
@@ -464,6 +479,7 @@
"Things nearby {hotelName}": "Lähellä olevia asioita {hotelName}",
"This room is equipped with": "Tämä huone on varustettu",
"This room is not available": "Tämä huone ei ole käytettävissä",
+ "This verifcation is needed for additional security.": "This verifcation is needed for additional security.",
"Times": "Ajat",
"To get the member price {price}, log in or join when completing the booking.": "Jäsenhintaan saavat sisäänkirjautuneet tai liittyneet jäsenet.",
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "Varmistaaksesi varauksen, pyydämme sinua antamaan meille maksukortin tiedot. Varmista, että ei veloiteta maksusi tällä hetkellä.",
@@ -484,16 +500,22 @@
"User information": "Käyttäjän tiedot",
"VAT {vat}%": "ALV {vat}%",
"Valid through {expirationDate}": "Voimassa {expirationDate} asti",
+ "Verification code": "Verification code",
"View as list": "Näytä listana",
"View as map": "Näytä kartalla",
+ "View your account": "View your account",
"View your booking": "Näytä varauksesi",
"Visiting address": "Käyntiosoite",
"Voucher": "Ravintolakuponki",
"We could not add a card right now, please try again later.": "Emme voineet lisätä korttia juuri nyt. Yritä myöhemmin uudelleen.",
+ "We could not connect your accounts": "We could not connect your accounts",
+ "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.": "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.",
"We couldn't find a matching location for your search.": "Emme löytäneet hakuasi vastaavaa sijaintia.",
"We had an issue processing your booking. Please try again. No charges have been made.": "Meillä oli ongelma varauksen käsittelyssä. Yritä uudelleen. Ei maksuja on tehty.",
"We have a special gift waiting for you!": "Meillä on erityinen lahja odottamassa sinua!",
"We look forward to your visit!": "Odotamme innolla vierailuasi!",
+ "We require this additional information in order to match your Scandic account with your EuroBonus account.": "We require this additional information in order to match your Scandic account with your EuroBonus account.",
+ "We successfully connected your accounts!": "We successfully connected your accounts!",
"We're sorry": "Olemme pahoillamme",
"Wednesday": "Keskiviikko",
"Weekday": "Arkipäivä",
@@ -522,6 +544,8 @@
"You have no previous stays.": "Sinulla ei ole aiempia majoituksia.",
"You have no upcoming stays.": "Sinulla ei ole tulevia majoituksia.",
"You have now cancelled your payment.": "Sinut nyt peruutit maksun.",
+ "You must accept the terms and conditions": "You must accept the terms and conditions",
+ "Your accounts are connected": "Your accounts are connected",
"Your booking(s) is confirmed but we could not verify your membership. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay.": "Varauksesi on vahvistettu, mutta jäsenyytesi ei voitu vahvistaa. Jos olet bookeutunut jäsenyysalennoilla, sinun on joko esitettävä olemassa olevan jäsenyysnumero tarkistukseen, tulla jäseneksi tai maksamaan hinnan eron hotellissa. Jäsenyyden tilittäminen on suositeltavampaa tehdä verkkoon ennen majoittumista.",
"Your card was successfully removed!": "Korttisi poistettiin onnistuneesti!",
"Your card was successfully saved!": "Korttisi tallennettu onnistuneesti!",
diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json
index 5060a0731..ed501540d 100644
--- a/i18n/dictionaries/no.json
+++ b/i18n/dictionaries/no.json
@@ -14,6 +14,7 @@
"Accessibility": "Tilgjengelighet",
"Accessibility at {hotel}": "Tilgjengelighet på {hotel}",
"Accessible Room": "Tilgjengelighetsrom",
+ "Accounts are already linked": "Accounts are already linked",
"Active": "Aktiv",
"Activities": "Aktiviteter",
"Add code": "Legg til kode",
@@ -57,6 +58,7 @@
"Bed type": "Seng type",
"Bike friendly": "Sykkelvennlig",
"Birth date": "Fødselsdato",
+ "Birth date is required": "Birth date is required",
"Book": "Bestill",
"Book a table online": "Bestill bord online",
"Book parking": "Bestill parkering",
@@ -78,6 +80,7 @@
"Bus terminal": "Bussterminal",
"Business": "Forretnings",
"By accepting the Terms and Conditions for Scandic Friends I understand that my personal data will be processed in accordance with Scandic's Privacy Policy.": "Ved å akseptere vilkårene og betingelsene for Scandic Friends, er jeg inneforstått med at mine personopplysninger vil bli behandlet i samsvar med Scandics personvernpolicy.",
+ "By linking your accounts you accept the Scandic Friends & SAS Terms and Conditions. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.": "By linking your accounts you accept the Scandic Friends & SAS Terms and Conditions. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
"By paying with any of the payment methods available, I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.": "Ved å betale med en av de tilgjengelige betalingsmetodene godtar jeg vilkårene og betingelsene for denne bestillingen og de generelle vilkårene, og forstår at Scandic vil behandle mine personopplysninger i forbindelse med denne bestillingen i henhold til Scandics personvernpolicy. Jeg aksepterer at Scandic krever et gyldig kredittkort under mitt besøk i tilfelle noe blir refundert.",
"By signing up you accept the Scandic Friends Terms and Conditions. Your membership is valid until further notice, and you can terminate your membership at any time by sending an email to Scandic's customer service": "Ved å registrere deg godtar du Scandic Friends vilkår og betingelser. Medlemskapet ditt er gyldig inntil videre, og du kan si opp medlemskapet ditt når som helst ved å sende en e-post til Scandics kundeservice",
"Campaign": "Kampanje",
@@ -113,6 +116,7 @@
"Complete booking & go to payment": "Fullfør bestilling & gå til betaling",
"Complete the booking": "Fullfør reservasjonen",
"Contact information": "Kontaktinformasjon",
+ "Contact our memberservice": "Contact our memberservice",
"Contact us": "Kontakt oss",
"Continue": "Fortsette",
"Could not find requested resource": "Kunne ikke finne den forespurte ressursen",
@@ -126,11 +130,13 @@
"Current password": "Nåværende passord",
"Customer service": "Kundeservice",
"Date of Birth": "Fødselsdato",
+ "Date of birth not matching": "Date of birth not matching",
"Day": "Dag",
"Description": "Beskrivelse",
"Destination": "Destinasjon",
"Destinations & hotels": "Destinasjoner og hoteller",
"Details": "Detaljer",
+ "Didn't receive a code? Resend code": "Didn't receive a code? Resend code",
"Discard changes": "Forkaste endringer",
"Discard unsaved changes?": "Forkaste endringer som ikke er lagret?",
"Discover": "Oppdag",
@@ -183,6 +189,7 @@
"Free parking": "Gratis parkering",
"Free rebooking": "Gratis ombooking",
"Friday": "Fredag",
+ "Friends with Benefits": "Friends with Benefits",
"From": "Fra",
"Garage": "Garasje",
"Get inspired": "Bli inspirert",
@@ -213,9 +220,11 @@
"I accept": "Jeg aksepterer",
"I accept the terms and conditions": "Jeg aksepterer vilkårene",
"I would like to get my booking confirmation via sms": "Jeg vil gjerne motta bekreftelsen av bestillingen min via sms",
+ "If you are not redirected automatically, please click here.": "If you are not redirected automatically, please click here.",
"In adults bed": "i voksnes seng",
"In crib": "i sprinkelseng",
"In extra bed": "i ekstraseng",
+ "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.": "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
"Included": "Inkludert",
"IndoorPool": "Innendørs basseng",
"Is there anything else you would like us to know before your arrival?": "Er det noe annet du vil at vi skal vite før ankomsten din?",
@@ -243,6 +252,8 @@
"Level 7": "Nivå 7",
"Level up to unlock": "Nivå opp for å låse opp",
"Level {level}": "Nivå {level}",
+ "Link my accounts": "Link my accounts",
+ "Link your accounts": "Link your accounts",
"Location": "Beliggenhet",
"Locations": "Steder",
"Log in": "Logg Inn",
@@ -345,6 +356,7 @@
"Phone is required": "Telefon kreves",
"Phone number": "Telefonnummer",
"Please enter a valid phone number": "Vennligst oppgi et gyldig telefonnummer",
+ "Please enter the code sent to in order to confirm your account linking.": "Please enter the code sent to in order to confirm your account linking.",
"Please note that this is mandatory, and that your card will only be charged in the event of a no-show.": "Vær oppmerksom på at dette er påkrevd, og at ditt kredittkort kun vil bli belastet i tilfelle av en no-show.",
"Points": "Poeng",
"Points being calculated": "Poeng beregnes",
@@ -374,6 +386,8 @@
"Read more & book a table": "Read more & book a table",
"Read more about the hotel": "Les mer om hotellet",
"Read more about wellness & exercise": "Read more about wellness & exercise",
+ "Redirecting you to SAS": "Redirecting you to SAS",
+ "Redirecting you to my pages.": "Redirecting you to my pages.",
"Reference #{bookingNr}": "Referanse #{bookingNr}",
"Relax": "Slappe av",
"Remove card from member profile": "Fjern kortet fra medlemsprofilen",
@@ -453,6 +467,7 @@
"Terms and conditions": "Vilkår og betingelser",
"Thank you": "Takk",
"Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please contact us.": "Takk for at du booket hos oss! Vi ser frem til å ønske deg velkommen og håper du får et hyggelig opphold. Hvis du har spørsmål eller trenger å gjøre endringer i bestillingen din, vennligst kontakt oss.",
+ "The code you’ve entered is incorrect.": "The code you’ve entered is incorrect.",
"The new price is": "Den nye prisen er",
"The price has increased": "Prisen er steget",
"The price has increased since you selected your room.": "Prisen er steget, etter at du har valgt rommet.",
@@ -462,6 +477,7 @@
"Things nearby {hotelName}": "Ting i nærheten av {hotelName}",
"This room is equipped with": "Dette rommet er utstyrt med",
"This room is not available": "Dette rommet er ikke tilgjengelig",
+ "This verifcation is needed for additional security.": "This verifcation is needed for additional security.",
"Thursday": "Torsdag",
"Times": "Tider",
"To get the member price {price}, log in or join when completing the booking.": "For å få medlemsprisen {price}, logg inn eller bli med når du fullfører bestillingen.",
@@ -483,16 +499,22 @@
"User information": "Brukerinformasjon",
"VAT {vat}%": "mva {vat}%",
"Valid through {expirationDate}": "Gyldig til og med {expirationDate}",
+ "Verification code": "Verification code",
"View as list": "Vis som liste",
"View as map": "Vis som kart",
+ "View your account": "View your account",
"View your booking": "Se din bestilling",
"Visiting address": "Besøksadresse",
"Voucher": "Voucher",
"We could not add a card right now, please try again later.": "Vi kunne ikke legge til et kort akkurat nå. Prøv igjen senere.",
+ "We could not connect your accounts": "We could not connect your accounts",
+ "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.": "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.",
"We couldn't find a matching location for your search.": "Vi finner ikke et sted som samsvarer for søket ditt.",
"We had an issue processing your booking. Please try again. No charges have been made.": "Vi hadde et problem med å behandle din bestilling. Vær så snill å prøv igjen. Ingen gebyrer er blevet belastet.",
"We have a special gift waiting for you!": "Vi har en spesiell gave som venter på deg!",
"We look forward to your visit!": "Vi ser frem til ditt besøk!",
+ "We require this additional information in order to match your Scandic account with your EuroBonus account.": "We require this additional information in order to match your Scandic account with your EuroBonus account.",
+ "We successfully connected your accounts!": "We successfully connected your accounts!",
"We're sorry": "Vi beklager",
"Wednesday": "Onsdag",
"Weekday": "Ukedag",
@@ -521,8 +543,10 @@
"You have no previous stays.": "Du har ingen tidligere opphold.",
"You have no upcoming stays.": "Du har ingen kommende opphold.",
"You have now cancelled your payment.": "Du har nå annullerer din betaling.",
+ "You must accept the terms and conditions": "You must accept the terms and conditions",
"You'll find all your gifts in 'My benefits'": "Du finner alle gavene dine i 'Mine fordeler'",
"Your Challenges Conquer & Earn!": "Dine utfordringer Erobre og tjen!",
+ "Your accounts are connected": "Your accounts are connected",
"Your booking(s) is confirmed but we could not verify your membership. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay.": "Din bestilling er bekreftet, men vi kunne ikke verifisere medlemskapet ditt. Hvis du har booke ut med et medlemsrabatt, må du enten presentere eksisterende medlemsnummer ved check-in, bli medlem eller betale prisdifferansen ved hotellet. Registrering er foretrukket gjort online før oppholdet.",
"Your card was successfully removed!": "Kortet ditt ble fjernet!",
"Your card was successfully saved!": "Kortet ditt ble lagret!",
diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json
index 1d9c1f3c6..c5e2e1df1 100644
--- a/i18n/dictionaries/sv.json
+++ b/i18n/dictionaries/sv.json
@@ -14,6 +14,7 @@
"Accessibility": "Tillgänglighet",
"Accessibility at {hotel}": "Tillgänglighet på {hotel}",
"Accessible Room": "Tillgänglighetsrum",
+ "Accounts are already linked": "Accounts are already linked",
"Active": "Aktiv",
"Activities": "Aktiviteter",
"Add code": "Lägg till kod",
@@ -57,6 +58,7 @@
"Bed type": "Sängtyp",
"Bike friendly": "Cykelvänligt",
"Birth date": "Födelsedatum",
+ "Birth date is required": "Birth date is required",
"Book": "Boka",
"Book a table online": "Boka ett bord online",
"Book parking": "Boka parkering",
@@ -78,6 +80,7 @@
"Bus terminal": "Bussterminal",
"Business": "Business",
"By accepting the Terms and Conditions for Scandic Friends I understand that my personal data will be processed in accordance with Scandic's Privacy Policy.": "Genom att acceptera villkoren för Scandic Friends förstår jag att mina personuppgifter kommer att behandlas i enlighet med Scandics Integritetspolicy.",
+ "By linking your accounts you accept the Scandic Friends & SAS Terms and Conditions. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.": "By linking your accounts you accept the Scandic Friends & SAS Terms and Conditions. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
"By paying with any of the payment methods available, I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.": "Genom att betala med någon av de tillgängliga betalningsmetoderna accepterar jag villkoren för denna bokning och de generella Villkoren och villkoren, och förstår att Scandic kommer att behandla min personliga data i samband med denna bokning i enlighet med Scandics integritetspolicy. Jag accepterar att Scandic kräver ett giltigt kreditkort under min besök i fall att något är tillbaka betalt.",
"By signing up you accept the Scandic Friends Terms and Conditions. Your membership is valid until further notice, and you can terminate your membership at any time by sending an email to Scandic's customer service": "Genom att registrera dig accepterar du Scandic Friends Användarvillkor. Ditt medlemskap gäller tills vidare och du kan när som helst säga upp ditt medlemskap genom att skicka ett mejl till Scandics kundtjänst",
"Campaign": "Kampanj",
@@ -113,6 +116,7 @@
"Complete booking & go to payment": "Fullför bokning & gå till betalning",
"Complete the booking": "Slutför bokningen",
"Contact information": "Kontaktinformation",
+ "Contact our memberservice": "Contact our memberservice",
"Contact us": "Kontakta oss",
"Continue": "Fortsätt",
"Could not find requested resource": "Det gick inte att hitta den begärda resursen",
@@ -126,11 +130,13 @@
"Current password": "Nuvarande lösenord",
"Customer service": "Kundservice",
"Date of Birth": "Födelsedatum",
+ "Date of birth not matching": "Date of birth not matching",
"Day": "Dag",
"Description": "Beskrivning",
"Destination": "Destination",
"Destinations & hotels": "Destinationer & hotell",
"Details": "Detaljer",
+ "Didn't receive a code? Resend code": "Didn't receive a code? Resend code",
"Discard changes": "Ignorera ändringar",
"Discard unsaved changes?": "Vill du ignorera ändringar som inte har sparats?",
"Discover": "Upptäck",
@@ -183,6 +189,7 @@
"Free parking": "Gratis parkering",
"Free rebooking": "Fri ombokning",
"Friday": "Fredag",
+ "Friends with Benefits": "Friends with Benefits",
"From": "Från",
"Garage": "Garage",
"Get inspired": "Bli inspirerad",
@@ -213,9 +220,11 @@
"I accept": "Jag accepterar",
"I accept the terms and conditions": "Jag accepterar villkoren",
"I would like to get my booking confirmation via sms": "Jag vill få min bokningsbekräftelse via sms",
+ "If you are not redirected automatically, please click here.": "If you are not redirected automatically, please click here.",
"In adults bed": "I vuxens säng",
"In crib": "I spjälsäng",
"In extra bed": "Egen sängplats",
+ "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.": "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
"Included": "Inkluderad",
"IndoorPool": "Inomhuspool",
"Is there anything else you would like us to know before your arrival?": "Är det något mer du vill att vi ska veta innan din ankomst?",
@@ -243,6 +252,8 @@
"Level 7": "Nivå 7",
"Level up to unlock": "Levla upp för att låsa upp",
"Level {level}": "Nivå {level}",
+ "Link my accounts": "Link my accounts",
+ "Link your accounts": "Link your accounts",
"Location": "Plats",
"Locations": "Platser",
"Log in": "Logga in",
@@ -345,6 +356,7 @@
"Phone is required": "Telefonnummer är obligatorisk",
"Phone number": "Telefonnummer",
"Please enter a valid phone number": "Var vänlig och ange ett giltigt telefonnummer",
+ "Please enter the code sent to in order to confirm your account linking.": "Please enter the code sent to in order to confirm your account linking.",
"Please note that this is mandatory, and that your card will only be charged in the event of a no-show.": "Vänligen notera att detta är obligatoriskt, och att ditt kreditkort endast debiteras i händelse av en no-show.",
"Points": "Poäng",
"Points being calculated": "Poäng beräknas",
@@ -374,6 +386,8 @@
"Read more & book a table": "Read more & book a table",
"Read more about the hotel": "Läs mer om hotellet",
"Read more about wellness & exercise": "Read more about wellness & exercise",
+ "Redirecting you to SAS": "Redirecting you to SAS",
+ "Redirecting you to my pages.": "Redirecting you to my pages.",
"Reference #{bookingNr}": "Referens #{bookingNr}",
"Relax": "Koppla av",
"Remove card from member profile": "Ta bort kortet från medlemsprofilen",
@@ -453,6 +467,7 @@
"Terms and conditions": "Allmänna villkor",
"Thank you": "Tack",
"Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please contact us.": "Tack för att du bokar hos oss! Vi ser fram emot att välkomna dig och hoppas att du får en trevlig vistelse. Om du har några frågor eller behöver göra ändringar i din bokning, vänligen kontakta oss.",
+ "The code you’ve entered is incorrect.": "The code you’ve entered is incorrect.",
"The new price is": "Det nya priset är",
"The price has increased": "Priset har ökat",
"The price has increased since you selected your room.": "Priset har ökat sedan du valde ditt rum.",
@@ -462,6 +477,7 @@
"Things nearby {hotelName}": "Saker i närheten av {hotelName}",
"This room is equipped with": "Detta rum är utrustat med",
"This room is not available": "Detta rum är inte tillgängligt",
+ "This verifcation is needed for additional security.": "This verifcation is needed for additional security.",
"Thursday": "Torsdag",
"Times": "Tider",
"To get the member price {price}, log in or join when completing the booking.": "För att få medlemsprisen {price}, logga in eller bli medlem när du slutför bokningen.",
@@ -483,16 +499,22 @@
"User information": "Användarinformation",
"VAT {vat}%": "Moms {vat}%",
"Valid through {expirationDate}": "Gäller till och med {expirationDate}",
+ "Verification code": "Verification code",
"View as list": "Visa som lista",
"View as map": "Visa som karta",
+ "View your account": "View your account",
"View your booking": "Visa din bokning",
"Visiting address": "Besöksadress",
"Voucher": "Kupong",
"We could not add a card right now, please try again later.": "Vi kunde inte lägga till ett kort just nu, vänligen försök igen senare.",
+ "We could not connect your accounts": "We could not connect your accounts",
+ "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.": "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.",
"We couldn't find a matching location for your search.": "Vi kunde inte hitta en plats som matchade din sökning.",
"We had an issue processing your booking. Please try again. No charges have been made.": "Vi hade ett problem med att bearbeta din bokning. Vänligen försök igen. Inga avgifter har debiterats.",
"We have a special gift waiting for you!": "Vi har en speciell present som väntar på dig!",
"We look forward to your visit!": "Vi ser fram emot ditt besök!",
+ "We require this additional information in order to match your Scandic account with your EuroBonus account.": "We require this additional information in order to match your Scandic account with your EuroBonus account.",
+ "We successfully connected your accounts!": "We successfully connected your accounts!",
"We're sorry": "Vi beklagar",
"Wednesday": "Onsdag",
"Weekday": "Vardag",
@@ -521,8 +543,10 @@
"You have no previous stays.": "Du har inga tidigare vistelser.",
"You have no upcoming stays.": "Du har inga planerade resor.",
"You have now cancelled your payment.": "Du har nu avbrutit din betalning.",
+ "You must accept the terms and conditions": "You must accept the terms and conditions",
"You'll find all your gifts in 'My benefits'": "Du hittar alla dina gåvor i 'Mina förmåner'",
"Your Challenges Conquer & Earn!": "Dina utmaningar Erövra och tjäna!",
+ "Your accounts are connected": "Your accounts are connected",
"Your booking(s) is confirmed but we could not verify your membership. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay.": "Din bokning är bekräftad, men vi kunde inte verifiera ditt medlemskap. Om du har bokat med ett medlemsrabatt måste du antingen presentera ditt befintliga medlemsnummer vid check-in, bli medlem eller betala prisdifferensen vid hotell. Registrering är föredragen gjord online före vistelsen.",
"Your card was successfully removed!": "Ditt kort har tagits bort!",
"Your card was successfully saved!": "Ditt kort har sparats!",
diff --git a/middleware.ts b/middleware.ts
index 503cd22cf..cad34e08b 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -12,6 +12,7 @@ import * as currentWebLogout from "@/middlewares/currentWebLogout"
import * as dateFormat from "@/middlewares/dateFormat"
import * as handleAuth from "@/middlewares/handleAuth"
import * as myPages from "@/middlewares/myPages"
+import * as sasXScandic from "@/middlewares/sasXScandic"
import { getDefaultRequestHeaders } from "@/middlewares/utils"
import * as webView from "@/middlewares/webView"
import { findLang } from "@/utils/languages"
@@ -55,6 +56,7 @@ export const middleware: NextMiddleware = async (request, event) => {
webView,
dateFormat,
bookingFlow,
+ sasXScandic,
cmsContent,
]
diff --git a/middlewares/sasXScandic.ts b/middlewares/sasXScandic.ts
new file mode 100644
index 000000000..ba093e249
--- /dev/null
+++ b/middlewares/sasXScandic.ts
@@ -0,0 +1,22 @@
+import { type NextMiddleware,NextResponse } from "next/server"
+
+import { Lang } from "@/constants/languages"
+
+import { getDefaultRequestHeaders } from "./utils"
+
+import type { MiddlewareMatcher } from "@/types/middleware"
+
+export const middleware: NextMiddleware = async (request) => {
+ const headers = getDefaultRequestHeaders(request)
+ return NextResponse.next({
+ request: {
+ headers,
+ },
+ })
+}
+
+export const matcher: MiddlewareMatcher = (request) => {
+ return !!request.nextUrl.pathname.match(
+ new RegExp(`^/(${Object.values(Lang).join("|")})/(sas-x-scandic)/.+`)
+ )
+}
diff --git a/package-lock.json b/package-lock.json
index 8ec681ea8..9e76ae9a2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"@sentry/nextjs": "^8.41.0",
"@t3-oss/env-nextjs": "^0.9.2",
"@tanstack/react-query": "^5.28.6",
+ "@tanstack/react-query-devtools": "^5.64.2",
"@tanstack/react-table": "^8.20.5",
"@trpc/client": "^11.0.0-rc.467",
"@trpc/react-query": "^11.0.0-rc.467",
@@ -44,6 +45,7 @@
"graphql-tag": "^2.12.6",
"ics": "^3.8.1",
"immer": "10.1.1",
+ "input-otp": "^1.4.2",
"json-stable-stringify-without-jsonify": "^1.0.1",
"libphonenumber-js": "^1.10.60",
"nanoid": "^5.0.9",
@@ -61,6 +63,7 @@
"sonner": "^1.7.0",
"superjson": "^2.2.1",
"usehooks-ts": "3.1.0",
+ "uuid": "^11.0.5",
"zod": "^3.22.4",
"zustand": "^4.5.2"
},
@@ -7655,9 +7658,19 @@
}
},
"node_modules/@tanstack/query-core": {
- "version": "5.51.9",
- "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.9.tgz",
- "integrity": "sha512-HsAwaY5J19MD18ykZDS3aVVh+bAt0i7m6uQlFC2b77DLV9djo+xEN7MWQAQQTR8IM+7r/zbozTQ7P0xr0bHuew==",
+ "version": "5.64.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.64.2.tgz",
+ "integrity": "sha512-hdO8SZpWXoADNTWXV9We8CwTkXU88OVWRBcsiFrk7xJQnhm6WRlweDzMD+uH+GnuieTBVSML6xFa17C2cNV8+g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/query-devtools": {
+ "version": "5.64.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.64.2.tgz",
+ "integrity": "sha512-3DautR5UpVZdk/qNIhioZVF7g8fdQZ1U98sBEEk4Tzz3tihSBNMPgwlP40nzgbPEDBIrn/j/oyyvNBVSo083Vw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -7665,19 +7678,36 @@
}
},
"node_modules/@tanstack/react-query": {
- "version": "5.51.11",
- "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.11.tgz",
- "integrity": "sha512-4Kq2x0XpDlpvSnaLG+8pHNH60zEc3mBvb3B2tOMDjcPCi/o+Du3p/9qpPLwJOTliVxxPJAP27fuIhLrsRdCr7A==",
+ "version": "5.64.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.64.2.tgz",
+ "integrity": "sha512-3pakNscZNm8KJkxmovvtZ4RaXLyiYYobwleTMvpIGUoKRa8j8VlrQKNl5W8VUEfVfZKkikvXVddLuWMbcSCA1Q==",
"license": "MIT",
"dependencies": {
- "@tanstack/query-core": "5.51.9"
+ "@tanstack/query-core": "5.64.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
- "react": "^18.0.0"
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@tanstack/react-query-devtools": {
+ "version": "5.64.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.64.2.tgz",
+ "integrity": "sha512-+ZjJVnPzc8BUV/Eklu2k9T/IAyAyvwoCHqOaOrk2sbU33LFhM52BpX4eyENXn0bx5LwV3DJZgEQlIzucoemfGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-devtools": "5.64.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "@tanstack/react-query": "^5.64.2",
+ "react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-table": {
@@ -14040,6 +14070,16 @@
"node": ">=10"
}
},
+ "node_modules/input-otp": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
+ "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/internal-slot": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz",
@@ -22137,6 +22177,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
+ "node_modules/uuid": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
+ "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/esm/bin/uuid"
+ }
+ },
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
diff --git a/package.json b/package.json
index 45a3c811d..58e40fb29 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"@sentry/nextjs": "^8.41.0",
"@t3-oss/env-nextjs": "^0.9.2",
"@tanstack/react-query": "^5.28.6",
+ "@tanstack/react-query-devtools": "^5.64.2",
"@tanstack/react-table": "^8.20.5",
"@trpc/client": "^11.0.0-rc.467",
"@trpc/react-query": "^11.0.0-rc.467",
@@ -59,6 +60,7 @@
"graphql-tag": "^2.12.6",
"ics": "^3.8.1",
"immer": "10.1.1",
+ "input-otp": "^1.4.2",
"json-stable-stringify-without-jsonify": "^1.0.1",
"libphonenumber-js": "^1.10.60",
"nanoid": "^5.0.9",
@@ -76,6 +78,7 @@
"sonner": "^1.7.0",
"superjson": "^2.2.1",
"usehooks-ts": "3.1.0",
+ "uuid": "^11.0.5",
"zod": "^3.22.4",
"zustand": "^4.5.2"
},
diff --git a/public/_static/img/partner/sas/sas-campaign-logo.png b/public/_static/img/partner/sas/sas-campaign-logo.png
new file mode 100644
index 000000000..d86aab939
Binary files /dev/null and b/public/_static/img/partner/sas/sas-campaign-logo.png differ
diff --git a/public/_static/img/partner/sas/sas_x_scandic_airplane_window_background.jpg b/public/_static/img/partner/sas/sas_x_scandic_airplane_window_background.jpg
new file mode 100644
index 000000000..d26bcc51d
Binary files /dev/null and b/public/_static/img/partner/sas/sas_x_scandic_airplane_window_background.jpg differ
diff --git a/public/_static/img/scandic-loyalty-time.svg b/public/_static/img/scandic-loyalty-time.svg
new file mode 100644
index 000000000..77394ef3f
--- /dev/null
+++ b/public/_static/img/scandic-loyalty-time.svg
@@ -0,0 +1,323 @@
+
diff --git a/server/context.ts b/server/context.ts
index f33d6b1c9..ddf7f762f 100644
--- a/server/context.ts
+++ b/server/context.ts
@@ -2,10 +2,10 @@ import { cookies, headers } from "next/headers"
import { type Session } from "next-auth"
import { cache } from "react"
-import { Lang } from "@/constants/languages"
-
import { auth } from "@/auth"
+import type { Lang } from "@/constants/languages"
+
typeof auth
type CreateContextOptions = {
diff --git a/server/index.ts b/server/index.ts
index 6f41f4391..676958d9f 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -2,6 +2,7 @@
import { bookingRouter } from "./routers/booking"
import { contentstackRouter } from "./routers/contentstack"
import { hotelsRouter } from "./routers/hotels"
+import { partnerRouter } from "./routers/partners"
import { userRouter } from "./routers/user"
import { router } from "./trpc"
@@ -10,6 +11,7 @@ export const appRouter = router({
contentstack: contentstackRouter,
hotel: hotelsRouter,
user: userRouter,
+ partner: partnerRouter,
})
export type AppRouter = typeof appRouter
diff --git a/server/routers/contentstack/reward/utils.ts b/server/routers/contentstack/reward/utils.ts
index 2b5c746a8..f8cd7ffc3 100644
--- a/server/routers/contentstack/reward/utils.ts
+++ b/server/routers/contentstack/reward/utils.ts
@@ -11,12 +11,13 @@ import { notFound } from "@/server/errors/trpc"
import { generateLoyaltyConfigTag } from "@/utils/generateTag"
import {
-type
- CmsRewardsResponse,type
- CmsRewardsWithRedeemResponse, validateApiAllTiersSchema,
+ type CmsRewardsResponse,
+ type CmsRewardsWithRedeemResponse,
+ validateApiAllTiersSchema,
validateApiTierRewardsSchema,
validateCmsRewardsSchema,
- validateCmsRewardsWithRedeemSchema} from "./output"
+ validateCmsRewardsWithRedeemSchema,
+} from "./output"
import type { Lang } from "@/constants/languages"
diff --git a/server/routers/partners/index.ts b/server/routers/partners/index.ts
new file mode 100644
index 000000000..3f26ae6a3
--- /dev/null
+++ b/server/routers/partners/index.ts
@@ -0,0 +1,5 @@
+import { router } from "@/server/trpc"
+
+import { sasRouter } from "./sas"
+
+export const partnerRouter = router({ sas: sasRouter })
diff --git a/server/routers/partners/sas/getSasToken.ts b/server/routers/partners/sas/getSasToken.ts
new file mode 100644
index 000000000..b6de657a5
--- /dev/null
+++ b/server/routers/partners/sas/getSasToken.ts
@@ -0,0 +1,11 @@
+import { cookies } from "next/headers"
+
+import { SAS_TOKEN_STORAGE_KEY } from "@/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/sasUtils"
+
+export function getSasToken() {
+ const cookieStore = cookies()
+ const tokenCookie = cookieStore.get(SAS_TOKEN_STORAGE_KEY)
+ const sasAuthToken = tokenCookie?.value
+
+ return sasAuthToken
+}
diff --git a/server/routers/partners/sas/index.ts b/server/routers/partners/sas/index.ts
new file mode 100644
index 000000000..702acd694
--- /dev/null
+++ b/server/routers/partners/sas/index.ts
@@ -0,0 +1,7 @@
+import { router } from "@/server/trpc"
+
+import { requestOtp } from "./otp/request/requestOtp"
+import { verifyOtp } from "./otp/verify/verifyOtp"
+import { linkAccount } from "./linkAccount"
+
+export const sasRouter = router({ verifyOtp, requestOtp, linkAccount })
diff --git a/server/routers/partners/sas/linkAccount.ts b/server/routers/partners/sas/linkAccount.ts
new file mode 100644
index 000000000..59308e53d
--- /dev/null
+++ b/server/routers/partners/sas/linkAccount.ts
@@ -0,0 +1,28 @@
+import { z } from "zod"
+
+import { protectedProcedure } from "@/server/trpc"
+
+import { getSasToken } from "./getSasToken"
+
+const outputSchema = z.object({
+ linkingState: z.enum(["linked"]),
+})
+
+export const linkAccount = protectedProcedure
+ .output(outputSchema)
+ .mutation(async function ({ ctx, input }) {
+ const sasAuthToken = getSasToken()
+
+ console.log("[SAS] link account")
+ await timeout(1000)
+ //TODO: Call actual API here
+ console.log("[SAS] link account done")
+
+ return {
+ linkingState: "linked",
+ }
+ })
+
+function timeout(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/server/routers/partners/sas/otp/constants.ts b/server/routers/partners/sas/otp/constants.ts
new file mode 100644
index 000000000..40591d967
--- /dev/null
+++ b/server/routers/partners/sas/otp/constants.ts
@@ -0,0 +1,2 @@
+export const SAS_REQUEST_OTP_STATE_STORAGE_COOKIE_NAME =
+ "sas-x-scandic-request-otp-state"
diff --git a/server/routers/partners/sas/otp/getOTPState.ts b/server/routers/partners/sas/otp/getOTPState.ts
new file mode 100644
index 000000000..fdf486afc
--- /dev/null
+++ b/server/routers/partners/sas/otp/getOTPState.ts
@@ -0,0 +1,17 @@
+import { cookies } from "next/headers"
+import { z } from "zod"
+
+import { SAS_REQUEST_OTP_STATE_STORAGE_COOKIE_NAME } from "./constants"
+
+const otpStateSchema = z.object({
+ referenceId: z.string().uuid(),
+ databaseUUID: z.string().uuid(),
+})
+
+export type OtpState = z.infer
+
+export function getOTPState() {
+ const otpState = cookies().get(SAS_REQUEST_OTP_STATE_STORAGE_COOKIE_NAME)
+
+ return otpStateSchema.parse(JSON.parse(otpState?.value ?? "{}"))
+}
diff --git a/server/routers/partners/sas/otp/request/requestOtp.ts b/server/routers/partners/sas/otp/request/requestOtp.ts
new file mode 100644
index 000000000..be0434001
--- /dev/null
+++ b/server/routers/partners/sas/otp/request/requestOtp.ts
@@ -0,0 +1,116 @@
+import { TRPCError } from "@trpc/server"
+import { cookies } from "next/headers"
+import { v4 as uuidv4 } from "uuid"
+import { z } from "zod"
+
+import { env } from "@/env/server"
+import { protectedProcedure } from "@/server/trpc"
+
+import { getSasToken } from "../../getSasToken"
+import { SAS_REQUEST_OTP_STATE_STORAGE_COOKIE_NAME } from "../constants"
+import {
+ parseSASRequestOtpError,
+ type RequestOtpGeneralError,
+} from "./requestOtpError"
+
+import type { OtpState } from "../getOTPState"
+
+const inputSchema = z.object({})
+
+const outputSchema = z.object({
+ status: z.string(),
+ referenceId: z.string().uuid(),
+ databaseUUID: z.string().uuid(),
+ otpExpiration: z.number(),
+ otpReceiver: z.string(),
+})
+
+export const requestOtp = protectedProcedure
+ .input(inputSchema)
+ .output(outputSchema)
+ .mutation(async function ({ ctx, input }) {
+ const sasAuthToken = getSasToken()
+
+ if (!sasAuthToken) {
+ // TODO: Should we verify that the SAS token isn't expired?
+ throw createError("AUTH_TOKEN_NOT_FOUND")
+ }
+
+ const tokenResponse = await fetchRequestOtp({ sasAuthToken })
+ console.log(
+ "[SAS] requestOtp",
+ tokenResponse.status,
+ tokenResponse.statusText
+ )
+ if (!tokenResponse.ok) {
+ const errorBody = await tokenResponse.json()
+ console.error("[SAS] requestOtp error", errorBody)
+ throw createError(errorBody)
+ }
+
+ const parseResult = outputSchema.safeParse(await tokenResponse.json())
+ if (!parseResult.success) {
+ throw createError(parseResult.error)
+ }
+
+ setSASOtpCookie(parseResult.data)
+
+ return parseResult.data
+ })
+
+function createError(
+ errorBody:
+ | {
+ status: string
+ error: string
+ errorCode: number
+ databaseUUID: string
+ }
+ | Error
+ | RequestOtpGeneralError
+): TRPCError {
+ const errorInfo = parseSASRequestOtpError(errorBody)
+ console.error("[SAS] createError", errorInfo)
+ return new TRPCError({
+ code: "BAD_REQUEST",
+ cause: errorInfo,
+ })
+}
+
+async function fetchRequestOtp({ sasAuthToken }: { sasAuthToken: string }) {
+ const endpoint = `${env.SAS_API_ENDPOINT}/api/scandic-partnership/customer/send-otp`
+
+ console.log("[SAS]: Requesting OTP")
+
+ return await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Ocp-Apim-Subscription-Key": env.SAS_OCP_APIM,
+ Authorization: `Bearer ${sasAuthToken}`,
+ },
+ body: JSON.stringify({
+ referenceId: uuidv4(),
+ }),
+ })
+}
+
+function setSASOtpCookie({
+ referenceId,
+ databaseUUID,
+}: {
+ referenceId: string
+ databaseUUID: string
+}) {
+ cookies().set(
+ SAS_REQUEST_OTP_STATE_STORAGE_COOKIE_NAME,
+ JSON.stringify({
+ referenceId: referenceId,
+ databaseUUID: databaseUUID,
+ } satisfies OtpState),
+ {
+ httpOnly: true,
+ maxAge: 3600,
+ }
+ )
+}
diff --git a/server/routers/partners/sas/otp/request/requestOtpError.test.ts b/server/routers/partners/sas/otp/request/requestOtpError.test.ts
new file mode 100644
index 000000000..5bac6bcca
--- /dev/null
+++ b/server/routers/partners/sas/otp/request/requestOtpError.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "@jest/globals"
+
+import { parseSASRequestOtpError } from "./requestOtpError"
+
+describe("requestOtpError", () => {
+ it("parses error with invalid error code", () => {
+ const error = {
+ status: "status",
+ error: "error",
+ errorCode: "a",
+ databaseUUID: "9ffefefe-df0e-4229-9792-5ed31bef1db4",
+ }
+
+ const actual = parseSASRequestOtpError({
+ status: "status",
+ error: "error",
+ errorCode: "a" as unknown as number,
+ databaseUUID: "9ffefefe-df0e-4229-9792-5ed31bef1db4",
+ } as any)
+ expect(actual).toEqual({
+ errorCode: "UNKNOWN",
+ })
+ })
+
+ it("parses error as TOO_MANY_REQUESTS error code", () => {
+ const actual = parseSASRequestOtpError({
+ status: "status",
+ error: "error",
+ errorCode: 10,
+ databaseUUID: "9ffefefe-df0e-4229-9792-5ed31bef1db4",
+ otpExpiration: "2021-09-01T00:00:00Z",
+ })
+ expect(actual).toEqual({
+ errorCode: "TOO_MANY_REQUESTS",
+ })
+ })
+})
diff --git a/server/routers/partners/sas/otp/request/requestOtpError.ts b/server/routers/partners/sas/otp/request/requestOtpError.ts
new file mode 100644
index 000000000..8f1621c5f
--- /dev/null
+++ b/server/routers/partners/sas/otp/request/requestOtpError.ts
@@ -0,0 +1,61 @@
+import { z } from "zod"
+
+export type RequestOtpResponseError = "TOO_MANY_REQUESTS" | "UNKNOWN"
+
+const requestOtpGeneralError = z.enum([
+ "AUTH_TOKEN_EXPIRED",
+ "AUTH_TOKEN_NOT_FOUND",
+ "UNKNOWN",
+])
+export type RequestOtpGeneralError = z.infer
+
+export type RequestOtpError = {
+ errorCode: RequestOtpResponseError | RequestOtpGeneralError
+}
+export function parseSASRequestOtpError(
+ error: SasOtpRequestError | {}
+): RequestOtpError {
+ const parseResult = sasOtpRequestErrorSchema.safeParse(error)
+ if (!parseResult.success) {
+ const generalErrorResult = requestOtpGeneralError.safeParse(error)
+ if (!generalErrorResult.success) {
+ return {
+ errorCode: "UNKNOWN",
+ }
+ }
+
+ return {
+ errorCode: generalErrorResult.data,
+ }
+ }
+
+ return {
+ errorCode: getErrorCodeByNumber(parseResult.data.errorCode),
+ }
+}
+
+const SAS_REQUEST_OTP_ERROR_CODES: {
+ [key in Exclude]: number
+} = {
+ TOO_MANY_REQUESTS: 10,
+}
+
+const getErrorCodeByNumber = (number: number): RequestOtpResponseError => {
+ const v =
+ Object.entries(SAS_REQUEST_OTP_ERROR_CODES).find(
+ ([_, value]) => value === number
+ )?.[0] ?? "UNKNOWN"
+
+ console.log("[SAS] getErrorCodeByNumber", number, v)
+ return v as RequestOtpResponseError
+}
+
+const sasOtpRequestErrorSchema = z.object({
+ status: z.string(),
+ otpExpiration: z.string().datetime(),
+ error: z.string(),
+ errorCode: z.number(),
+ databaseUUID: z.string().uuid(),
+})
+
+export type SasOtpRequestError = z.infer
diff --git a/server/routers/partners/sas/otp/verify/verifyOtp.ts b/server/routers/partners/sas/otp/verify/verifyOtp.ts
new file mode 100644
index 000000000..91e7e372e
--- /dev/null
+++ b/server/routers/partners/sas/otp/verify/verifyOtp.ts
@@ -0,0 +1,96 @@
+import { TRPCError } from "@trpc/server"
+import { cookies } from "next/headers"
+import { z } from "zod"
+
+import { env } from "@/env/server"
+import { protectedProcedure } from "@/server/trpc"
+
+import { getSasToken } from "../../getSasToken"
+import { getOTPState } from "../getOTPState"
+import {
+ parseSASVerifyOtpError,
+ type VerifyOtpGeneralError,
+} from "./verifyOtpError"
+
+const inputSchema = z.object({
+ otp: z.string(),
+})
+
+const outputSchema = z.object({
+ status: z.string(), // TODO: Change to enum
+ referenceId: z.string().uuid(),
+ databaseUUID: z.string().uuid().optional(),
+})
+
+export const verifyOtp = protectedProcedure
+ .input(inputSchema)
+ .output(outputSchema)
+ .mutation(async function ({ ctx, input }) {
+ const sasAuthToken = getSasToken()
+
+ if (!sasAuthToken) {
+ // TODO: Should we verify that the SAS token isn't expired?
+ throw createError("AUTH_TOKEN_NOT_FOUND")
+ }
+
+ const verifyResponse = await fetchVerifyOtp(input)
+ console.log(
+ "[SAS] verifyOTP",
+ verifyResponse.status,
+ verifyResponse.statusText
+ )
+ if (!verifyResponse.ok) {
+ const errorBody = await verifyResponse.json()
+ console.error("[SAS] verifyOTP error", errorBody)
+ throw createError(errorBody)
+ }
+
+ console.log("[SAS] verifyOTP success")
+ const verifyData = await verifyResponse.json()
+ console.log("[SAS] verifyOTP data", verifyData)
+ const response = outputSchema.parse(verifyData)
+ console.log("[SAS] verifyOTP responding", response)
+
+ return response
+ })
+
+async function fetchVerifyOtp(input: z.infer) {
+ const sasAuthToken = getSasToken()
+ const { referenceId, databaseUUID } = getOTPState()
+
+ return await fetch(
+ `${env.SAS_API_ENDPOINT}/api/scandic-partnership/customer/verify-otp`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Ocp-Apim-Subscription-Key": env.SAS_OCP_APIM,
+ Authorization: `Bearer ${sasAuthToken}`,
+ },
+ body: JSON.stringify({
+ referenceId: referenceId,
+ otpCode: input.otp,
+ databaseUUID: databaseUUID,
+ }),
+ }
+ )
+}
+
+function createError(
+ errorBody:
+ | {
+ status: string
+ error: string
+ errorCode: number
+ databaseUUID: string
+ }
+ | Error
+ | VerifyOtpGeneralError
+): TRPCError {
+ const errorInfo = parseSASVerifyOtpError(errorBody)
+
+ return new TRPCError({
+ code: "BAD_REQUEST",
+ cause: errorInfo,
+ })
+}
diff --git a/server/routers/partners/sas/otp/verify/verifyOtpError.test.ts b/server/routers/partners/sas/otp/verify/verifyOtpError.test.ts
new file mode 100644
index 000000000..92f4caafb
--- /dev/null
+++ b/server/routers/partners/sas/otp/verify/verifyOtpError.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "@jest/globals"
+
+import { parseSASVerifyOtpError } from "./verifyOtpError"
+
+describe("verifyOtpError", () => {
+ it("parses error with invalid error code", () => {
+ const error = {
+ status: "status",
+ error: "error",
+ errorCode: "a",
+ databaseUUID: "9ffefefe-df0e-4229-9792-5ed31bef1db4",
+ }
+
+ const actual = parseSASVerifyOtpError({
+ status: "status",
+ error: "error",
+ errorCode: "a" as unknown as number,
+ databaseUUID: "9ffefefe-df0e-4229-9792-5ed31bef1db4",
+ } as any)
+ expect(actual).toEqual({
+ errorCode: "UNKNOWN",
+ })
+ })
+})
diff --git a/server/routers/partners/sas/otp/verify/verifyOtpError.ts b/server/routers/partners/sas/otp/verify/verifyOtpError.ts
new file mode 100644
index 000000000..c2d25dd84
--- /dev/null
+++ b/server/routers/partners/sas/otp/verify/verifyOtpError.ts
@@ -0,0 +1,57 @@
+import { z } from "zod"
+
+export type VerifyOtpResponseError = "OTP_EXPIRED" | "WRONG_OTP" | "UNKNOWN"
+
+const VerifyOtpGeneralError = z.enum(["AUTH_TOKEN_NOT_FOUND", "UNKNOWN"])
+export type VerifyOtpGeneralError = z.infer
+
+export type VerifyOtpError = {
+ errorCode: VerifyOtpResponseError | VerifyOtpGeneralError
+}
+export function parseSASVerifyOtpError(
+ error: SasOtpVerifyError | {}
+): VerifyOtpError {
+ const parseResult = sasOtpVerifyErrorSchema.safeParse(error)
+ if (!parseResult.success) {
+ const generalErrorResult = VerifyOtpGeneralError.safeParse(error)
+ if (!generalErrorResult.success) {
+ return {
+ errorCode: "UNKNOWN",
+ }
+ }
+
+ return {
+ errorCode: generalErrorResult.data,
+ }
+ }
+
+ return {
+ errorCode: getErrorCodeByNumber(parseResult.data.errorCode),
+ }
+}
+
+const SAS_VERIFY_OTP_ERROR_CODES: {
+ [key in Exclude]: number
+} = {
+ OTP_EXPIRED: 1,
+ WRONG_OTP: 2,
+}
+
+const getErrorCodeByNumber = (number: number): VerifyOtpResponseError => {
+ const v =
+ Object.entries(SAS_VERIFY_OTP_ERROR_CODES).find(
+ ([_, value]) => value === number
+ )?.[0] ?? "UNKNOWN"
+
+ return v as VerifyOtpResponseError
+}
+
+const sasOtpVerifyErrorSchema = z.object({
+ status: z.string(),
+ otpExpiration: z.string().datetime(),
+ error: z.string(),
+ errorCode: z.number(),
+ databaseUUID: z.string().uuid(),
+})
+
+export type SasOtpVerifyError = z.infer
diff --git a/server/trpc.ts b/server/trpc.ts
index 49f1fbcce..5751d929b 100644
--- a/server/trpc.ts
+++ b/server/trpc.ts
@@ -30,6 +30,10 @@ const t = initTRPC
...shape,
data: {
...shape.data,
+ cause:
+ error.cause instanceof ZodError
+ ? undefined
+ : JSON.parse(JSON.stringify(error.cause)),
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
diff --git a/types/components/hotelLogo.ts b/types/components/hotelLogo.ts
index 8f19490a6..616ee3703 100644
--- a/types/components/hotelLogo.ts
+++ b/types/components/hotelLogo.ts
@@ -1,4 +1,4 @@
-import { Hotel } from "@/types/hotel"
+import type { Hotel } from "@/types/hotel"
export type HotelLogoProps = {
hotelId: Hotel["operaId"]
diff --git a/types/components/hotelReservation/selectHotel/hotelCardProps.ts b/types/components/hotelReservation/selectHotel/hotelCardProps.ts
index 9880ecbfc..5aa4e1094 100644
--- a/types/components/hotelReservation/selectHotel/hotelCardProps.ts
+++ b/types/components/hotelReservation/selectHotel/hotelCardProps.ts
@@ -1,7 +1,7 @@
import {
-type
- HotelCardListingTypeEnum, type HotelData} from "./hotelCardListingProps"
-
+ type HotelCardListingTypeEnum,
+ type HotelData,
+} from "./hotelCardListingProps"
export type HotelCardProps = {
hotel: HotelData