Merged in feature/sas-login (pull request #1256)
First steps towards the SAS partnership * otp flow now pretends to do the linking * Update LinkAccountForm header * Update redirect times * Clean up comments * Set maxAge on sas cookies * make all SAS routes protected * Merge remote-tracking branch 'refs/remotes/origin/feature/sas-login' into feature/sas-login * Require auth for sas link flow * Fix resend otp * Add error support to OneTimePasswordForm * Add Sentry to SAS error boundary * Move SAS_REQUEST_OTP_STATE_STORAGE_COOKIE_NAME * Add missing translations * Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/sas-login * Merge branch 'feature/sas-login' of bitbucket.org:scandic-swap/web into feature/sas-login * Add TooManyCodesError component * Refactor GenericError to support new errors * Add FailedAttemptsError * remove removed component <VWOScript/> * Merge branch 'feature/sas-login' of bitbucket.org:scandic-swap/web into feature/sas-login * remove local cookie-bot reference * Fix sas campaign logo scaling * feature toggle the SAS stuff * Merge branch 'feature/sas-login' of bitbucket.org:scandic-swap/web into feature/sas-login * fix: use env vars for SAS endpoints Approved-by: Linus Flood
This commit is contained in:
@@ -1,71 +1,3 @@
|
||||
import { headers } from "next/headers"
|
||||
import { redirect } from "next/navigation"
|
||||
import { ProtectedLayout } from "@/components/ProtectedLayout"
|
||||
|
||||
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 default 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 <p>{intl.formatMessage({ id: "Something went wrong!" })}</p>
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
console.error(
|
||||
"[layout:protected] no user found, redirecting to: ",
|
||||
redirectURL
|
||||
)
|
||||
redirect(redirectURL)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
export default ProtectedLayout
|
||||
|
||||
3
app/[lang]/(partner)/(sas)/(protected)/layout.tsx
Normal file
3
app/[lang]/(partner)/(sas)/(protected)/layout.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ProtectedLayout } from "@/components/ProtectedLayout"
|
||||
|
||||
export default ProtectedLayout
|
||||
@@ -0,0 +1,105 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { redirect } from "next/navigation"
|
||||
import { z } from "zod"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { safeTry } from "@/utils/safeTry"
|
||||
|
||||
import { SAS_TOKEN_STORAGE_KEY, stateSchema } from "../sasUtils"
|
||||
|
||||
import type { NextRequest } from "next/server"
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
code: z.string(),
|
||||
state: z.string(),
|
||||
})
|
||||
const tokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
token_type: z.literal("Bearer"),
|
||||
})
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { lang: string } }
|
||||
) {
|
||||
const { lang } = params
|
||||
|
||||
const result = searchParamsSchema.safeParse({
|
||||
code: request.nextUrl.searchParams.get("code"),
|
||||
state: request.nextUrl.searchParams.get("state"),
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
console.error("[SAS] Invalid search params", result.error)
|
||||
redirect(`/${lang}/sas-x-scandic/error?errorCode=invalid_query`)
|
||||
}
|
||||
const { code, state } = result.data
|
||||
|
||||
const tokenResponse = await fetch(
|
||||
new URL("oauth/token", env.SAS_AUTH_ENDPOINT),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: new URL(
|
||||
`/${lang}/sas-x-scandic/callback`,
|
||||
new URL(env.PUBLIC_URL)
|
||||
).toString(),
|
||||
client_id: env.SAS_AUTH_CLIENTID,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.text()
|
||||
console.error("[SAS] Failed to get token", error)
|
||||
redirect(`/${lang}/sas-x-scandic/error?errorCode=token_error`)
|
||||
}
|
||||
|
||||
const tokenData = tokenResponseSchema.parse(await tokenResponse.json())
|
||||
|
||||
const stateResult = stateSchema.safeParse(
|
||||
JSON.parse(decodeURIComponent(state))
|
||||
)
|
||||
if (!stateResult.success) {
|
||||
redirect(`/${lang}/sas-x-scandic/error?errorCode=invalid_state`)
|
||||
}
|
||||
|
||||
const cookieStore = cookies()
|
||||
cookieStore.set(SAS_TOKEN_STORAGE_KEY, tokenData.access_token, {
|
||||
maxAge: 3600,
|
||||
httpOnly: true,
|
||||
})
|
||||
|
||||
if (stateResult.data.intent === "link") {
|
||||
const [data, error] = await safeTry(
|
||||
serverClient().partner.sas.requestOtp({})
|
||||
)
|
||||
// status: 'SENT' => OK
|
||||
if (!data || error) {
|
||||
//TODO: Check what error we get
|
||||
console.error("[SAS] Failed to request OTP", error)
|
||||
redirect(`/${lang}/sas-x-scandic/error`)
|
||||
}
|
||||
|
||||
console.log("[SAS] Request OTP response", data)
|
||||
|
||||
const otpUrl = new URL(
|
||||
`/${lang}/sas-x-scandic/otp`,
|
||||
new URL(env.PUBLIC_URL)
|
||||
)
|
||||
otpUrl.searchParams.set("intent", stateResult.data.intent)
|
||||
otpUrl.searchParams.set("to", data.otpReceiver)
|
||||
|
||||
redirect(otpUrl.toString())
|
||||
}
|
||||
|
||||
redirect(`/${lang}/sas-x-scandic/error?errorCode=unknown_intent`)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
import Link from "next/link"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import ErrorCircleFilledIcon from "@/components/Icons/ErrorCircleFilled"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import { SASModal, SASModalContactBlock, SASModalDivider } from "./SASModal"
|
||||
|
||||
export function AlreadyLinkedError() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<SASModal>
|
||||
<ErrorCircleFilledIcon height={64} width={64} color="red" />
|
||||
<Title as="h2" level="h1" textAlign="center" textTransform="regular">
|
||||
{intl.formatMessage({ id: "Accounts are already linked" })}
|
||||
</Title>
|
||||
<Body textAlign="center">
|
||||
{/* TODO copy */}
|
||||
{intl.formatMessage({
|
||||
id: "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.",
|
||||
})}
|
||||
</Body>
|
||||
{/* TODO link to SASxScandic page on My Pages */}
|
||||
<Button theme="base" asChild>
|
||||
<Link href="#">{intl.formatMessage({ id: "View your account" })}</Link>
|
||||
</Button>
|
||||
<SASModalDivider />
|
||||
<SASModalContactBlock />
|
||||
</SASModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import ErrorCircleFilledIcon from "@/components/Icons/ErrorCircleFilled"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import { SASModal, SASModalContactBlock, SASModalDivider } from "./SASModal"
|
||||
|
||||
export function DateOfBirthError() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<SASModal>
|
||||
<ErrorCircleFilledIcon height={64} width={64} color="red" />
|
||||
<Title as="h2" level="h1" textAlign="center" textTransform="regular">
|
||||
{intl.formatMessage({ id: "Date of birth not matching" })}
|
||||
</Title>
|
||||
<Body textAlign="center">
|
||||
{/* TODO copy */}
|
||||
{intl.formatMessage({
|
||||
id: "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.",
|
||||
})}
|
||||
</Body>
|
||||
{/* TODO link to where? */}
|
||||
<Button theme="base">
|
||||
{intl.formatMessage({ id: "View your account" })}
|
||||
</Button>
|
||||
<SASModalDivider />
|
||||
<SASModalContactBlock />
|
||||
</SASModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { GenericError } from "./GenericError"
|
||||
|
||||
export function FailedAttemptsError() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<GenericError
|
||||
title={intl.formatMessage({ id: "Too many failed attempts" })}
|
||||
variant="info"
|
||||
>
|
||||
<Body textAlign="center">
|
||||
{intl.formatMessage({
|
||||
id: "Please wait 1 hour before trying again.",
|
||||
})}
|
||||
</Body>
|
||||
<Button theme="base" disabled>
|
||||
{intl.formatMessage({ id: "Send new code" })}
|
||||
</Button>
|
||||
</GenericError>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
import Image from "next/image"
|
||||
|
||||
import ErrorCircleFilledIcon from "@/components/Icons/ErrorCircleFilled"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import { SASModal } from "./SASModal"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
export function GenericError({
|
||||
title,
|
||||
variant = "error",
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode
|
||||
variant?: "error" | "info"
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<SASModal>
|
||||
{variant === "error" ? (
|
||||
<ErrorCircleFilledIcon height={64} width={64} color="red" />
|
||||
) : (
|
||||
<Image
|
||||
src="/_static/img/scandic-loyalty-time.svg"
|
||||
alt=""
|
||||
width={140}
|
||||
height={110}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
<Title as="h3" level="h1" textAlign="center" textTransform="regular">
|
||||
{title}
|
||||
</Title>
|
||||
{children}
|
||||
</SASModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x3);
|
||||
background-color: white;
|
||||
width: 100%;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x4);
|
||||
text-align: center;
|
||||
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
|
||||
margin-top: auto;
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
& {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
margin-top: initial;
|
||||
width: 560px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
& > span {
|
||||
position: relative;
|
||||
padding: 0 var(--Spacing-x2);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
bottom: calc(50% - 1px);
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background-color: var(--Base-Border-Subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.contactBlockTitle {
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./SASModal.module.css"
|
||||
|
||||
export function SASModal({ children }: { children: React.ReactNode }) {
|
||||
return <section className={styles.container}>{children}</section>
|
||||
}
|
||||
|
||||
export function SASModalDivider() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.divider}>
|
||||
<Body asChild color="uiTextPlaceholder">
|
||||
<span>{intl.formatMessage({ id: "or" })}</span>
|
||||
</Body>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SASModalContactBlock() {
|
||||
const intl = useIntl()
|
||||
|
||||
const phone = intl.formatMessage({ id: "+46 8 517 517 00" })
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<Title
|
||||
level="h4"
|
||||
as="h3"
|
||||
textTransform="regular"
|
||||
className={styles.contactBlockTitle}
|
||||
>
|
||||
{intl.formatMessage({ id: "Contact our memberservice" })}
|
||||
</Title>
|
||||
<Link
|
||||
href={`tel:${phone.replaceAll(" ", "")}`}
|
||||
textDecoration="underline"
|
||||
>
|
||||
{phone}
|
||||
</Link>
|
||||
<Link href="mailto:member@scandichotels.com" textDecoration="underline">
|
||||
member@scandichotels.com
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { GenericError } from "./GenericError"
|
||||
|
||||
export function TooManyCodesError() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<GenericError
|
||||
title={intl.formatMessage({ id: "You’ve requested too many codes" })}
|
||||
variant="info"
|
||||
>
|
||||
<Body textAlign="center">
|
||||
{intl.formatMessage({
|
||||
id: "Please wait 1 hour before trying again.",
|
||||
})}
|
||||
</Body>
|
||||
<Button theme="base" disabled>
|
||||
{intl.formatMessage({ id: "Send new code" })}
|
||||
</Button>
|
||||
</GenericError>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import * as Sentry from "@sentry/nextjs"
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { GenericError } from "./components/GenericError"
|
||||
import { SASModalContactBlock } from "./components/SASModal"
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) return
|
||||
|
||||
console.error(error)
|
||||
Sentry.captureException(error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<GenericError
|
||||
title={intl.formatMessage({
|
||||
id: "We could not connect your accounts",
|
||||
})}
|
||||
>
|
||||
<Body textAlign="center">
|
||||
{intl.formatMessage({
|
||||
id: "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.",
|
||||
})}
|
||||
</Body>
|
||||
<SASModalContactBlock />
|
||||
</GenericError>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import { DateOfBirthError } from "../components/DateOfBirthError"
|
||||
import { GenericError } from "../components/GenericError"
|
||||
import { SASModalContactBlock } from "../components/SASModal"
|
||||
|
||||
import type { LangParams, PageArgs, SearchParams } from "@/types/params"
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
params,
|
||||
}: PageArgs<LangParams> & SearchParams<{ errorCode?: "dateOfBirthMismatch" }>) {
|
||||
const intl = await getIntl()
|
||||
|
||||
if (searchParams.errorCode === "dateOfBirthMismatch") {
|
||||
return <DateOfBirthError />
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericError
|
||||
title={intl.formatMessage({
|
||||
id: "We could not connect your accounts",
|
||||
})}
|
||||
>
|
||||
<Body textAlign="center">
|
||||
{intl.formatMessage({
|
||||
id: "We could not connect your accounts to give you access. Please contact us and we’ll help you resolve this issue.",
|
||||
})}
|
||||
</Body>
|
||||
<SASModalContactBlock />
|
||||
</GenericError>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.layout {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
height: 100vh;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: 80px 1fr;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: white;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
align-items: center;
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
display: flex;
|
||||
font-size: var(--Spacing-x2);
|
||||
gap: var(--Spacing-x1);
|
||||
|
||||
.long {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.backLink {
|
||||
.short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.long {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ArrowLeft } from "react-feather"
|
||||
|
||||
import { overview as profileOverview } from "@/constants/routes/myPages"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import { ProtectedLayout } from "@/components/ProtectedLayout"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import { getIntl } from "@/i18n"
|
||||
import background from "@/public/_static/img/partner/sas/sas_x_scandic_airplane_window_background.jpg"
|
||||
|
||||
import styles from "./layout.module.css"
|
||||
|
||||
import type { PropsWithChildren } from "react"
|
||||
|
||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
export default async function SasXScandicLayout({
|
||||
children,
|
||||
params,
|
||||
}: PropsWithChildren<LayoutArgs<LangParams>>) {
|
||||
const intl = await getIntl()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.layout}
|
||||
style={{ backgroundImage: `url(${background.src})` }}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
{/* TODO should this link to my-pages sas page? */}
|
||||
<Link className={styles.backLink} href={profileOverview[params.lang]}>
|
||||
<ArrowLeft height={20} width={20} />
|
||||
<span className={styles.long}>
|
||||
{intl.formatMessage({ id: "Back to Scandichotels.com" })}
|
||||
</span>
|
||||
<span className={styles.short}>
|
||||
{intl.formatMessage({ id: "Back" })}
|
||||
</span>
|
||||
</Link>
|
||||
<MainMenuLogo />
|
||||
</header>
|
||||
<section className={styles.content}>{children}</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function MainMenuLogo() {
|
||||
const intl = await getIntl()
|
||||
|
||||
return <Logo alt={intl.formatMessage({ id: "Back to scandichotels.com" })} />
|
||||
}
|
||||
|
||||
function Logo({ alt }: { alt: string }) {
|
||||
return (
|
||||
<Image
|
||||
alt={alt}
|
||||
className={styles.logo}
|
||||
height={22}
|
||||
src="/_static/img/scandic-logotype.svg"
|
||||
priority
|
||||
width={103}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { type ReactNode, useTransition } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import DateSelect from "@/components/TempDesignSystem/Form/Date"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./link-sas.module.css"
|
||||
|
||||
type LinkAccountForm = {
|
||||
dateOfBirth: string | null
|
||||
termsAndConditions: boolean
|
||||
}
|
||||
export function LinkAccountForm({
|
||||
initialDateOfBirth,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialDateOfBirth: string | null
|
||||
onSubmit: (dateOfBirth: string) => Promise<void>
|
||||
}) {
|
||||
let [isPending, startTransition] = useTransition()
|
||||
const intl = useIntl()
|
||||
const form = useForm<LinkAccountForm>({
|
||||
defaultValues: {
|
||||
dateOfBirth: initialDateOfBirth,
|
||||
termsAndConditions: false,
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
if (!data.dateOfBirth || !data.termsAndConditions) return
|
||||
|
||||
await onSubmit(data.dateOfBirth)
|
||||
})
|
||||
})
|
||||
|
||||
const dateOfBirth = form.watch("dateOfBirth")
|
||||
const termsAndConditions = form.watch("termsAndConditions")
|
||||
const disableSubmit = !dateOfBirth || !termsAndConditions
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<div className={styles.titles}>
|
||||
<Image
|
||||
alt={"Scandic ❤️ SAS"}
|
||||
height={25}
|
||||
width={182}
|
||||
src="/_static/img/partner/sas/sas-campaign-logo.png"
|
||||
/>
|
||||
<Title level="h3" textTransform="regular">
|
||||
{intl.formatMessage({ id: "Link your accounts" })}
|
||||
</Title>
|
||||
</div>
|
||||
<div className={styles.dateSelect}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "Birth date",
|
||||
})}
|
||||
</Body>
|
||||
<DateSelect
|
||||
name="dateOfBirth"
|
||||
registerOptions={{
|
||||
required: {
|
||||
value: true,
|
||||
message: intl.formatMessage({ id: "Birth date is required" }),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Caption textAlign="left">
|
||||
{intl.formatMessage({
|
||||
id: "We require this additional information in order to match your Scandic account with your EuroBonus account.",
|
||||
})}
|
||||
</Caption>
|
||||
<div className={styles.termsAndConditions}>
|
||||
<Checkbox
|
||||
name="termsAndConditions"
|
||||
registerOptions={{
|
||||
required: {
|
||||
value: true,
|
||||
message: intl.formatMessage({
|
||||
id: "You must accept the terms and conditions",
|
||||
}),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body>
|
||||
{intl.formatMessage({ id: "I accept the terms and conditions" })}
|
||||
</Body>
|
||||
</Checkbox>
|
||||
<Body className={styles.termsDescription}>
|
||||
{intl.formatMessage<ReactNode>(
|
||||
{
|
||||
id: "By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
|
||||
},
|
||||
{
|
||||
sasScandicTermsAndConditionsLink: (str) => (
|
||||
<Link
|
||||
// TODO correct link
|
||||
href={"#"}
|
||||
weight="bold"
|
||||
variant="default"
|
||||
textDecoration="underline"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.ctaContainer}>
|
||||
<Button
|
||||
theme="base"
|
||||
fullWidth
|
||||
className={styles.ctaButton}
|
||||
type="submit"
|
||||
disabled={isPending || disableSubmit}
|
||||
>
|
||||
{intl.formatMessage({ id: "Link my accounts" })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
.titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.dateSelect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.termsAndConditions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.termsDescription {
|
||||
margin-left: calc(var(--Spacing-x4) + var(--Spacing-x-half));
|
||||
}
|
||||
|
||||
.ctaContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3) 0;
|
||||
width: calc(100% + var(--Spacing-x3) + var(--Spacing-x3));
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.ctaButton {
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import React from "react"
|
||||
|
||||
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { AlreadyLinkedError } from "../components/AlreadyLinkedError"
|
||||
import { SASModal } from "../components/SASModal"
|
||||
import { LinkAccountForm } from "./LinkAccountForm"
|
||||
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function SASxScandicLinkPage({
|
||||
params,
|
||||
}: PageArgs<LangParams>) {
|
||||
const profile = await getProfileSafely()
|
||||
|
||||
// TODO actually check if profile is already linked
|
||||
const alreadyLinked = false
|
||||
|
||||
async function handleLinkAccount(dateOfBirth: string) {
|
||||
"use server"
|
||||
|
||||
if (dateOfBirth !== profile?.dateOfBirth) {
|
||||
// TODO update users date of birth here
|
||||
console.log("updating date of birth")
|
||||
}
|
||||
|
||||
redirect(`/${params.lang}/sas-x-scandic/login?intent=link`)
|
||||
}
|
||||
|
||||
return alreadyLinked ? (
|
||||
<AlreadyLinkedError />
|
||||
) : (
|
||||
<SASModal>
|
||||
<LinkAccountForm
|
||||
initialDateOfBirth={profile?.dateOfBirth ?? null}
|
||||
onSubmit={handleLinkAccount}
|
||||
/>
|
||||
</SASModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react"
|
||||
|
||||
import CheckCircle from "@/components/Icons/CheckCircle"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import { SASModal } from "../../components/SASModal"
|
||||
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function SASxScandicLinkPage({
|
||||
params,
|
||||
}: PageArgs<LangParams>) {
|
||||
const intl = await getIntl()
|
||||
|
||||
return (
|
||||
<SASModal>
|
||||
{/* <Redirect
|
||||
url={`INSERT CORRECT URL HERE`}
|
||||
timeout={3000}
|
||||
/> */}
|
||||
<CheckCircle height={64} width={64} color="uiSemanticSuccess" />
|
||||
<Title as="h2" level="h1" textAlign="center">
|
||||
{intl.formatMessage({ id: "Your accounts are connected" })}
|
||||
</Title>
|
||||
<div>
|
||||
<Body textAlign="center">
|
||||
{intl.formatMessage({
|
||||
id: "We successfully connected your accounts!",
|
||||
})}
|
||||
</Body>
|
||||
<Body textAlign="center">
|
||||
{intl.formatMessage({
|
||||
id: "Redirecting you to my pages.",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
</SASModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import React from "react"
|
||||
import { z } from "zod"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import { Redirect } from "@/components/Redirect"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import { SASModal } from "../components/SASModal"
|
||||
|
||||
import type { LangParams, PageArgs, SearchParams } from "@/types/params"
|
||||
import type { State } from "../sasUtils"
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
intent: z.enum(["link"]),
|
||||
})
|
||||
export default async function SASxScandicLoginPage({
|
||||
searchParams,
|
||||
params,
|
||||
}: PageArgs<LangParams> & SearchParams) {
|
||||
const result = searchParamsSchema.safeParse(searchParams)
|
||||
if (!result.success) {
|
||||
// TOOD where to redirect?
|
||||
redirect(`/${params.lang}/sas-x-scandic/link`)
|
||||
}
|
||||
const parsedParams = result.data
|
||||
|
||||
const intl = await getIntl()
|
||||
const redirectUri = new URL(
|
||||
"/en/sas-x-scandic/callback",
|
||||
env.PUBLIC_URL
|
||||
).toString()
|
||||
|
||||
const state: State = { intent: parsedParams.intent }
|
||||
const urlState = encodeURIComponent(JSON.stringify(state))
|
||||
const clientId = env.SAS_AUTH_CLIENTID
|
||||
const sasLoginHostname = env.SAS_AUTH_ENDPOINT
|
||||
const audience = "eb-partner-api"
|
||||
// TODO check if this is correct scopes
|
||||
const scope = encodeURIComponent("openid profile email")
|
||||
|
||||
const loginLink = `${sasLoginHostname}/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${urlState}&audience=${audience}`
|
||||
|
||||
return (
|
||||
<SASModal>
|
||||
<Redirect url={loginLink} timeout={3000} />
|
||||
<Image
|
||||
src="/_static/img/scandic-loyalty-time.svg"
|
||||
alt=""
|
||||
width="140"
|
||||
height="110"
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
<Title as="h2" level="h1" textTransform="regular">
|
||||
{intl.formatMessage({ id: "Redirecting you to SAS" })}
|
||||
</Title>
|
||||
<Body textAlign="center">
|
||||
{intl.formatMessage({
|
||||
id: "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
|
||||
})}
|
||||
</Body>
|
||||
<Footnote textAlign="center">
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
|
||||
},
|
||||
{
|
||||
loginLink: (str) => (
|
||||
<Link
|
||||
href={loginLink}
|
||||
color="red"
|
||||
variant="default"
|
||||
size="tiny"
|
||||
textDecoration="underline"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Footnote>
|
||||
</SASModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
.container-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x3);
|
||||
background-color: white;
|
||||
width: 100%;
|
||||
padding: var(--Spacing-x3);
|
||||
text-align: center;
|
||||
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
|
||||
margin-top: auto;
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
& {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
margin-top: initial;
|
||||
width: 512px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.otp-container {
|
||||
display: flex;
|
||||
|
||||
gap: var(--Spacing-x1);
|
||||
@media screen and (min-width: 768px) {
|
||||
& {
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
&.error .slot {
|
||||
border: 1px solid var(--UI-Text-Error);
|
||||
}
|
||||
}
|
||||
|
||||
.slot {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: content-box;
|
||||
width: 34px;
|
||||
height: 0px;
|
||||
padding: var(--Spacing-x3) 0;
|
||||
font-family: var(--typography-Body-Regular-fontFamily);
|
||||
border: 1px solid var(--Base-Border-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
text-align: center;
|
||||
|
||||
&.active {
|
||||
border: 1px solid var(--UI-Text-Active);
|
||||
outline: 1px solid var(--UI-Text-Active);
|
||||
}
|
||||
}
|
||||
|
||||
.caret {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: blink 1s infinite;
|
||||
|
||||
& .child {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background-color: var(--UI-Text-Active);
|
||||
}
|
||||
}
|
||||
|
||||
.disabled-link {
|
||||
cursor: default;
|
||||
color: var(--UI-Text-Disabled);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client"
|
||||
|
||||
import { cx } from "class-variance-authority"
|
||||
import { OTPInput, type SlotProps } from "input-otp"
|
||||
import { type ReactNode, useState, useTransition } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import ErrorCircleFilledIcon from "@/components/Icons/ErrorCircleFilled"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
|
||||
import { GenericError } from "../components/GenericError"
|
||||
import { SASModal, SASModalContactBlock } from "../components/SASModal"
|
||||
import Loading from "./loading"
|
||||
|
||||
import styles from "./OneTimePasswordForm.module.css"
|
||||
|
||||
import type { RequestOtpError } from "@/server/routers/partners/sas/otp/request/requestOtpError"
|
||||
import type { VerifyOtpError } from "@/server/routers/partners/sas/otp/verify/verifyOtpError"
|
||||
|
||||
export default function OneTimePasswordForm({
|
||||
heading,
|
||||
ingress,
|
||||
footnote,
|
||||
otpLength,
|
||||
onSubmit,
|
||||
error,
|
||||
}: {
|
||||
heading: string
|
||||
ingress: string | ReactNode
|
||||
footnote?: string | ReactNode
|
||||
otpLength: number
|
||||
onSubmit: (args: { otp: string }) => Promise<void>
|
||||
error?: ReactNode
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [disableResend, setDisableResend] = useState(false)
|
||||
const intl = useIntl()
|
||||
const [otp, setOtp] = useState("")
|
||||
|
||||
const requestOtp = trpc.partner.sas.requestOtp.useMutation({})
|
||||
|
||||
if (requestOtp.isPending || isPending) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
if (requestOtp.isError) {
|
||||
const cause = requestOtp.error?.data?.cause as RequestOtpError
|
||||
|
||||
const title = intl.formatMessage({ id: "Error requesting OTP" })
|
||||
const body = getRequestErrorBody(intl, cause?.errorCode)
|
||||
|
||||
return (
|
||||
<GenericError title={title}>
|
||||
<Body textAlign="center">{body}</Body>
|
||||
<SASModalContactBlock />
|
||||
</GenericError>
|
||||
)
|
||||
}
|
||||
|
||||
function handleRequestNewOtp(event: React.MouseEvent) {
|
||||
event.preventDefault()
|
||||
if (disableResend) return
|
||||
|
||||
setOtp("")
|
||||
requestOtp.reset()
|
||||
requestOtp.mutate({})
|
||||
setDisableResend(true)
|
||||
|
||||
setTimeout(() => {
|
||||
setDisableResend(false)
|
||||
}, 15_000)
|
||||
}
|
||||
|
||||
function handleOTPEntered(otp: string) {
|
||||
startTransition(async () => {
|
||||
await onSubmit({ otp })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SASModal>
|
||||
<Subtitle textAlign={"center"}>{heading}</Subtitle>
|
||||
<div>
|
||||
<Body textAlign={"center"}>{ingress}</Body>
|
||||
</div>
|
||||
|
||||
<OTPInput
|
||||
value={otp}
|
||||
onChange={setOtp}
|
||||
maxLength={otpLength}
|
||||
inputMode="numeric"
|
||||
onComplete={(otp) => {
|
||||
handleOTPEntered(otp)
|
||||
}}
|
||||
containerClassName={cx(styles["otp-container"], {
|
||||
[styles.error]: Boolean(error),
|
||||
})}
|
||||
render={({ slots }) => (
|
||||
<>
|
||||
{slots.map((slot, idx) => (
|
||||
<Slot key={idx} {...slot} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<div className={styles["error-message"]}>
|
||||
<ErrorCircleFilledIcon height={20} width={20} color="red" />
|
||||
<Caption color="red">{error}</Caption>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Footnote>{footnote}</Footnote>
|
||||
<Footnote>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "Didn't receive a code? <resendOtpLink>Resend code</resendOtpLink>",
|
||||
},
|
||||
{
|
||||
resendOtpLink: (str) => (
|
||||
<Link
|
||||
href="#"
|
||||
onClick={handleRequestNewOtp}
|
||||
color="red"
|
||||
variant="default"
|
||||
size="tiny"
|
||||
className={disableResend ? styles["disabled-link"] : ""}
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Footnote>
|
||||
</div>
|
||||
</SASModal>
|
||||
)
|
||||
}
|
||||
|
||||
function Slot(props: SlotProps) {
|
||||
return (
|
||||
<div className={`${styles.slot} ${props.isActive ? styles.active : ""}`}>
|
||||
{props.char !== null && <div>{props.char}</div>}
|
||||
{props.hasFakeCaret && <FakeCaret />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FakeCaret() {
|
||||
return (
|
||||
<div className={styles.caret}>
|
||||
<div className={styles.child} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getRequestErrorBody = (
|
||||
intl: ReturnType<typeof useIntl>,
|
||||
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<typeof useIntl>,
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import { SASModal } from "../components/SASModal"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<SASModal>
|
||||
<LoadingSpinner />
|
||||
</SASModal>
|
||||
)
|
||||
}
|
||||
@@ -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<LangParams> & 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 (
|
||||
<OneTimePasswordForm
|
||||
heading={intl.formatMessage({ id: "Verification code" })}
|
||||
ingress={intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "Please enter the code sent to <maskedContactInfo></maskedContactInfo> in order to confirm your account linking.",
|
||||
},
|
||||
{
|
||||
maskedContactInfo: () => (
|
||||
<>
|
||||
<br />
|
||||
<strong>{to}</strong>
|
||||
<br />
|
||||
</>
|
||||
),
|
||||
}
|
||||
)}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<typeof stateSchema>
|
||||
68
app/[lang]/(partner)/layout.tsx
Normal file
68
app/[lang]/(partner)/layout.tsx
Normal file
@@ -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<LayoutArgs<LangParams>>) {
|
||||
if (!env.SAS_ENABLED) {
|
||||
return null
|
||||
}
|
||||
|
||||
setLang(params.lang)
|
||||
preloadUserTracking()
|
||||
const { defaultLocale, locale, messages } = await getIntl()
|
||||
|
||||
return (
|
||||
<html lang={params.lang}>
|
||||
<head>
|
||||
<AdobeSDKScript />
|
||||
<GTMScript />
|
||||
<Script
|
||||
strategy="beforeInteractive"
|
||||
data-blockingmode="auto"
|
||||
data-cbid="6d539de8-3e67-4f0f-a0df-8cef9070f712"
|
||||
data-culture="@cultureCode"
|
||||
id="Cookiebot"
|
||||
src="https://consent.cookiebot.com/uc.js"
|
||||
/>
|
||||
<Script id="ensure-adobeDataLayer">{`
|
||||
window.adobeDataLayer = window.adobeDataLayer || []
|
||||
`}</Script>
|
||||
</head>
|
||||
<body>
|
||||
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
|
||||
<TrpcProvider>
|
||||
<RouterTracking />
|
||||
{children}
|
||||
<ToastHandler />
|
||||
<TokenRefresher />
|
||||
<StorageCleaner />
|
||||
<CookieBotConsent />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</TrpcProvider>
|
||||
</ServerIntlProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
16
app/api/debug/route.ts
Normal file
16
app/api/debug/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -21,4 +21,4 @@
|
||||
.modalContent {
|
||||
width: 352px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
components/Icons/ErrorCircleFilled.tsx
Normal file
27
components/Icons/ErrorCircleFilled.tsx
Normal file
@@ -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 (
|
||||
<svg
|
||||
className={classNames}
|
||||
fill="none"
|
||||
height="65"
|
||||
viewBox="0 0 65 65"
|
||||
width="65"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M32.492 45.5C33.2078 45.5 33.8101 45.2579 34.299 44.7737C34.7879 44.2895 35.0324 43.6895 35.0324 42.9737C35.0324 42.2579 34.7902 41.6556 34.306 41.1667C33.8218 40.6778 33.2218 40.4333 32.506 40.4333C31.7902 40.4333 31.1879 40.6754 30.699 41.1597C30.2101 41.6438 29.9657 42.2438 29.9657 42.9597C29.9657 43.6754 30.2078 44.2778 30.692 44.7667C31.1762 45.2556 31.7762 45.5 32.492 45.5ZM32.5324 35.1667C33.2212 35.1667 33.8101 34.9222 34.299 34.4333C34.7879 33.9444 35.0324 33.3556 35.0324 32.6667V21.9333C35.0324 21.2444 34.7879 20.6556 34.299 20.1667C33.8101 19.6778 33.2212 19.4333 32.5324 19.4333C31.8435 19.4333 31.2546 19.6778 30.7657 20.1667C30.2768 20.6556 30.0324 21.2444 30.0324 21.9333V32.6667C30.0324 33.3556 30.2768 33.9444 30.7657 34.4333C31.2546 34.9222 31.8435 35.1667 32.5324 35.1667ZM32.499 58.5C28.9032 58.5 25.5239 57.8164 22.3614 56.4491C19.1987 55.0819 16.4477 53.2263 14.1082 50.8825C11.7688 48.5386 9.91569 45.787 8.54902 42.6275C7.18236 39.4681 6.49902 36.0922 6.49902 32.5C6.49902 28.9041 7.18265 25.5249 8.54989 22.3623C9.91713 19.1997 11.7727 16.4487 14.1166 14.1092C16.4604 11.7697 19.2121 9.91667 22.3715 8.55C25.531 7.18333 28.9068 6.5 32.499 6.5C36.0949 6.5 39.4741 7.18362 42.6367 8.55087C45.7993 9.91811 48.5504 11.7737 50.8898 14.1175C53.2293 16.4614 55.0824 19.213 56.449 22.3725C57.8157 25.5319 58.499 28.9078 58.499 32.5C58.499 36.0959 57.8154 39.4751 56.4482 42.6377C55.0809 45.8003 53.2254 48.5513 50.8815 50.8908C48.5376 53.2303 45.786 55.0833 42.6266 56.45C39.4671 57.8167 36.0912 58.5 32.499 58.5Z"
|
||||
fill="#26201E"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
69
components/ProtectedLayout.tsx
Normal file
69
components/ProtectedLayout.tsx
Normal file
@@ -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 <p>{intl.formatMessage({ id: "Something went wrong!" })}</p>
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
console.error(
|
||||
"[layout:protected] no user found, redirecting to: ",
|
||||
redirectURL
|
||||
)
|
||||
redirect(redirectURL)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
19
components/Redirect.tsx
Normal file
19
components/Redirect.tsx
Normal file
@@ -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
|
||||
}
|
||||
19
env/server.ts
vendored
19
env/server.ts
vendored
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Ved at acceptere <termsAndConditionsLink>vilkårene og betingelserne for Scandic Friends</termsAndConditionsLink>, forstår jeg, at mine personlige oplysninger vil blive behandlet i overensstemmelse med <privacyPolicy>Scandics privatlivspolitik</privacyPolicy>.",
|
||||
"By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. 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 <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. 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 <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. 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 <termsAndConditionsLink>Vilkår og betingelser</termsAndConditionsLink>, og forstår, at Scandic vil behandle min personlige data i forbindelse med denne booking i henhold til <privacyPolicyLink>Scandics Privatlivspolitik</privacyPolicyLink>. 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 <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. 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 <termsAndConditionsLink>vilkår og betingelser</termsAndConditionsLink>. 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? <resendOtpLink>Resend code</resendOtpLink>": "Didn't receive a code? <resendOtpLink>Resend code</resendOtpLink>",
|
||||
"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 <loginLink>click here</loginLink>.": "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
|
||||
"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 <maskedContactInfo></maskedContactInfo> in order to confirm your account linking.": "Please enter the code sent to <maskedContactInfo></maskedContactInfo> 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 <emailLink>contact us.</emailLink>": "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 <emailLink>kontakte os.</emailLink>",
|
||||
"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 <span>{price}</span>, log in or join when completing the booking.": "For at få medlemsprisen <span>{price}</span>, 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!",
|
||||
|
||||
@@ -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 <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Mit der Annahme der <termsAndConditionsLink>Allgemeinen Geschäftsbedingungen für Scandic Friends</termsAndConditionsLink> erkläre ich mich damit einverstanden, dass meine persönlichen Daten in Übereinstimmung mit der <privacyPolicy>Datenschutzrichtlinie von Scandic verarbeitet werden</privacyPolicy>.",
|
||||
"By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. 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 <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. 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 <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. 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 <termsAndConditionsLink>Geschäftsbedingungen</termsAndConditionsLink> und verstehe, dass Scandic meine personenbezogenen Daten im Zusammenhang mit dieser Buchung gemäß der <privacyPolicyLink>Scandic Datenschutzrichtlinie</privacyPolicyLink> 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 <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. 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 <termsAndConditionsLink>Allgemeinen Geschäftsbedingungen</termsAndConditionsLink> 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? <resendOtpLink>Resend code</resendOtpLink>": "Didn't receive a code? <resendOtpLink>Resend code</resendOtpLink>",
|
||||
"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 <loginLink>click here</loginLink>.": "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
|
||||
"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 <maskedContactInfo></maskedContactInfo> in order to confirm your account linking.": "Please enter the code sent to <maskedContactInfo></maskedContactInfo> 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 <emailLink>contact us.</emailLink>": "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, <emailLink>kontaktieren Sie uns bitte.</emailLink>.",
|
||||
"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 <span>{price}</span>, log in or join when completing the booking.": "Um den Mitgliederpreis von <span>{price}</span> 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!",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"+46 8 517 517 00": "+46 8 517 517 00",
|
||||
"<b>Included</b> (based on availability)": "<b>Included</b> (based on availability)",
|
||||
"<b>Total price</b> (incl VAT)": "<b>Total price</b> (incl VAT)",
|
||||
"<strikethrough>{amount}</strikethrough> <free>0 {currency}</free>/night per adult": "<strikethrough>{amount}</strikethrough> <free>0 {currency}</free>/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 <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.",
|
||||
"By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. 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 <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. 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 <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. 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 <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. 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 <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. 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 <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. 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? <resendOtpLink>Resend code</resendOtpLink>": "Didn't receive a code? <resendOtpLink>Resend code</resendOtpLink>",
|
||||
"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 <loginLink>click here</loginLink>.": "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
|
||||
"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 <maskedContactInfo></maskedContactInfo> in order to confirm your account linking.": "Please enter the code sent to <maskedContactInfo></maskedContactInfo> 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 <emailLink>contact us.</emailLink>": "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 <emailLink>contact us.</emailLink>",
|
||||
"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 <span>{price}</span>, log in or join when completing the booking.": "To get the member price <span>{price}</span>, 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",
|
||||
|
||||
@@ -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 <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Kyllä, <termsAndConditionsLink>hyväksyn Scandic Friends -jäsenyyttä</termsAndConditionsLink> koskevat ehdot ja ymmärrän, että Scandic käsittelee henkilötietojani <privacyPolicy>Scandicin Tietosuojaselosteen mukaisesti</privacyPolicy>.",
|
||||
"By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. 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 <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. 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 <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. 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 <termsAndConditionsLink>ehdot ja ehtoja</termsAndConditionsLink>, ja ymmärrän, että Scandic käsittelee minun henkilötietoni tässä varauksessa mukaisesti <privacyPolicyLink>Scandicin tietosuojavaltuuden</privacyPolicyLink> mukaisesti. Hyväksyn myös, että Scandic vaatii validin luottokortin majoituksen ajan, jos jokin jää maksamatta.",
|
||||
"By signing up you accept the Scandic Friends <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. 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 <termsAndConditionsLink>käyttöehdot</termsAndConditionsLink>. 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? <resendOtpLink>Resend code</resendOtpLink>": "Didn't receive a code? <resendOtpLink>Resend code</resendOtpLink>",
|
||||
"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 <loginLink>click here</loginLink>.": "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
|
||||
"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 <maskedContactInfo></maskedContactInfo> in order to confirm your account linking.": "Please enter the code sent to <maskedContactInfo></maskedContactInfo> 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 <emailLink>contact us.</emailLink>": "Kiitos, että teit varauksen meiltä! Toivotamme sinut tervetulleeksi ja toivomme sinulle miellyttävää oleskelua. Jos sinulla on kysyttävää tai haluat tehdä muutoksia varaukseesi, <emailLink>ota meihin yhteyttä.</emailLink>",
|
||||
"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 <span>{price}</span>, 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!",
|
||||
|
||||
@@ -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 <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Ved å akseptere <termsAndConditionsLink>vilkårene og betingelsene for Scandic Friends</termsAndConditionsLink>, er jeg inneforstått med at mine personopplysninger vil bli behandlet i samsvar med <privacyPolicy>Scandics personvernpolicy</privacyPolicy>.",
|
||||
"By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. 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 <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. 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 <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. 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 <termsAndConditionsLink>vilkårene</termsAndConditionsLink>, og forstår at Scandic vil behandle mine personopplysninger i forbindelse med denne bestillingen i henhold til <privacyPolicyLink> Scandics personvernpolicy</privacyPolicyLink>. 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 <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. 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 <termsAndConditionsLink>vilkår og betingelser</termsAndConditionsLink>. 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? <resendOtpLink>Resend code</resendOtpLink>": "Didn't receive a code? <resendOtpLink>Resend code</resendOtpLink>",
|
||||
"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 <loginLink>click here</loginLink>.": "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
|
||||
"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 <maskedContactInfo></maskedContactInfo> in order to confirm your account linking.": "Please enter the code sent to <maskedContactInfo></maskedContactInfo> 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 <emailLink>contact us.</emailLink>": "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 <emailLink>kontakt oss.</emailLink>",
|
||||
"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 <span>{price}</span>, log in or join when completing the booking.": "For å få medlemsprisen <span>{price}</span>, 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!",
|
||||
|
||||
@@ -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 <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Genom att acceptera <termsAndConditionsLink>villkoren för Scandic Friends</termsAndConditionsLink> förstår jag att mina personuppgifter kommer att behandlas i enlighet med <privacyPolicy>Scandics Integritetspolicy</privacyPolicy>.",
|
||||
"By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. 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 <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. 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 <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. 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 <termsAndConditionsLink>Villkoren och villkoren</termsAndConditionsLink>, och förstår att Scandic kommer att behandla min personliga data i samband med denna bokning i enlighet med <privacyPolicyLink>Scandics integritetspolicy</privacyPolicyLink>. 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 <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. 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 <termsAndConditionsLink>Användarvillkor</termsAndConditionsLink>. 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? <resendOtpLink>Resend code</resendOtpLink>": "Didn't receive a code? <resendOtpLink>Resend code</resendOtpLink>",
|
||||
"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 <loginLink>click here</loginLink>.": "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
|
||||
"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 <maskedContactInfo></maskedContactInfo> in order to confirm your account linking.": "Please enter the code sent to <maskedContactInfo></maskedContactInfo> 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 <emailLink>contact us.</emailLink>": "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 <emailLink>kontakta oss.</emailLink>",
|
||||
"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 <span>{price}</span>, log in or join when completing the booking.": "För att få medlemsprisen <span>{price}</span>, 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!",
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
22
middlewares/sasXScandic.ts
Normal file
22
middlewares/sasXScandic.ts
Normal file
@@ -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)/.+`)
|
||||
)
|
||||
}
|
||||
69
package-lock.json
generated
69
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
BIN
public/_static/img/partner/sas/sas-campaign-logo.png
Normal file
BIN
public/_static/img/partner/sas/sas-campaign-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
323
public/_static/img/scandic-loyalty-time.svg
Normal file
323
public/_static/img/scandic-loyalty-time.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 259 KiB |
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
5
server/routers/partners/index.ts
Normal file
5
server/routers/partners/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { router } from "@/server/trpc"
|
||||
|
||||
import { sasRouter } from "./sas"
|
||||
|
||||
export const partnerRouter = router({ sas: sasRouter })
|
||||
11
server/routers/partners/sas/getSasToken.ts
Normal file
11
server/routers/partners/sas/getSasToken.ts
Normal file
@@ -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
|
||||
}
|
||||
7
server/routers/partners/sas/index.ts
Normal file
7
server/routers/partners/sas/index.ts
Normal file
@@ -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 })
|
||||
28
server/routers/partners/sas/linkAccount.ts
Normal file
28
server/routers/partners/sas/linkAccount.ts
Normal file
@@ -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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
2
server/routers/partners/sas/otp/constants.ts
Normal file
2
server/routers/partners/sas/otp/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const SAS_REQUEST_OTP_STATE_STORAGE_COOKIE_NAME =
|
||||
"sas-x-scandic-request-otp-state"
|
||||
17
server/routers/partners/sas/otp/getOTPState.ts
Normal file
17
server/routers/partners/sas/otp/getOTPState.ts
Normal file
@@ -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<typeof otpStateSchema>
|
||||
|
||||
export function getOTPState() {
|
||||
const otpState = cookies().get(SAS_REQUEST_OTP_STATE_STORAGE_COOKIE_NAME)
|
||||
|
||||
return otpStateSchema.parse(JSON.parse(otpState?.value ?? "{}"))
|
||||
}
|
||||
116
server/routers/partners/sas/otp/request/requestOtp.ts
Normal file
116
server/routers/partners/sas/otp/request/requestOtp.ts
Normal file
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
61
server/routers/partners/sas/otp/request/requestOtpError.ts
Normal file
61
server/routers/partners/sas/otp/request/requestOtpError.ts
Normal file
@@ -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<typeof requestOtpGeneralError>
|
||||
|
||||
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<RequestOtpResponseError, "UNKNOWN">]: 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<typeof sasOtpRequestErrorSchema>
|
||||
96
server/routers/partners/sas/otp/verify/verifyOtp.ts
Normal file
96
server/routers/partners/sas/otp/verify/verifyOtp.ts
Normal file
@@ -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<typeof inputSchema>) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
57
server/routers/partners/sas/otp/verify/verifyOtpError.ts
Normal file
57
server/routers/partners/sas/otp/verify/verifyOtpError.ts
Normal file
@@ -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<typeof VerifyOtpGeneralError>
|
||||
|
||||
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<VerifyOtpResponseError, "UNKNOWN">]: 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<typeof sasOtpVerifyErrorSchema>
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Hotel } from "@/types/hotel"
|
||||
import type { Hotel } from "@/types/hotel"
|
||||
|
||||
export type HotelLogoProps = {
|
||||
hotelId: Hotel["operaId"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type
|
||||
HotelCardListingTypeEnum, type HotelData} from "./hotelCardListingProps"
|
||||
|
||||
type HotelCardListingTypeEnum,
|
||||
type HotelData,
|
||||
} from "./hotelCardListingProps"
|
||||
|
||||
export type HotelCardProps = {
|
||||
hotel: HotelData
|
||||
|
||||
Reference in New Issue
Block a user