Merge branch 'develop' into feat/SW-185-implement-footer-navigation

This commit is contained in:
Pontus Dreij
2024-08-22 16:42:09 +02:00
139 changed files with 2834 additions and 927 deletions

View File

@@ -12,6 +12,7 @@ CURITY_CLIENT_SECRET_SERVICE="test"
CURITY_CLIENT_ID_USER="test"
CURITY_CLIENT_SECRET_USER="test"
CURITY_ISSUER_USER="test"
CURITY_ISSUER_SERVICE="test"
CYPRESS_API_BASEURL="test"
CYPRESS_CURITY_USERNAME="test"
CYPRESS_CURITY_PASSWORD="test"
@@ -35,3 +36,4 @@ SEAMLESS_LOGOUT_FI="test"
SEAMLESS_LOGOUT_NO="test"
SEAMLESS_LOGOUT_SV="test"
WEBVIEW_ENCRYPTION_KEY="test"
BOOKING_ENCRYPTION_KEY="test"

View File

@@ -20,13 +20,17 @@ export default async function ProtectedLayout({
h.get("x-url") ?? h.get("x-pathname") ?? overview[getLang()]
)
const redirectURL = `/${getLang()}/login?redirectTo=${redirectTo}`
if (!session) {
redirect(`/${getLang()}/login?redirectTo=${redirectTo}`)
console.log(`[layout:protected] no session, redirecting to: ${redirectURL}`)
redirect(redirectURL)
}
const user = await serverClient().user.get()
if (!user || "error" in user) {
redirect(`/${getLang()}/login?redirectTo=${redirectTo}`)
console.log(`[layout:protected] no user, redirecting to: ${redirectURL}`)
redirect(redirectURL)
}
return children

View File

@@ -1,5 +1,3 @@
import { createActionURL } from "@auth/core"
import { headers as nextHeaders } from "next/headers"
import { NextRequest, NextResponse } from "next/server"
import { AuthError } from "next-auth"
@@ -16,11 +14,35 @@ export async function GET(
let redirectTo: string = ""
const returnUrl = request.headers.get("x-returnurl")
const isSeamless = request.headers.get("x-logout-source") === "seamless"
if (returnUrl) {
// Seamless logout request from Current web
redirectTo = returnUrl
console.log(
`[logout] source: ${request.headers.get("x-logout-source") || "normal"}`
)
const redirectToSearchParamValue =
request.nextUrl.searchParams.get("redirectTo")
const redirectToFallback = "/"
if (isSeamless) {
if (returnUrl) {
redirectTo = returnUrl
} else {
console.log(
`[login] missing returnUrl, using fallback: ${redirectToFallback}`
)
redirectTo = redirectToFallback
}
} else {
redirectTo = redirectToSearchParamValue || redirectToFallback
// Make relative URL to absolute URL
if (redirectTo.startsWith("/")) {
console.log(`[logout] make redirectTo absolute, from ${redirectTo}`)
redirectTo = new URL(redirectTo, env.PUBLIC_URL).href
console.log(`[logout] make redirectTo absolute, to ${redirectTo}`)
}
try {
// Initiate the seamless logout flow
let redirectUrlValue
@@ -45,6 +67,9 @@ export async function GET(
break
}
const redirectUrl = new URL(redirectUrlValue)
console.log(
`[logout] creating redirect to seamless logout: ${redirectUrl}`
)
redirectTo = redirectUrl.toString()
} catch (e) {
console.error(
@@ -55,37 +80,25 @@ export async function GET(
}
try {
redirectTo = `${env.CURITY_ISSUER_USER}/authn/authenticate/logout?redirect_uri=${encodeURIComponent(redirectTo)}`
console.log(`[logout] final redirectUrl: ${redirectTo}`)
console.log({ logout_env: process.env })
/**
* Passing `redirect: false` to `signOut` will return a result object
* instead of automatically redirecting inside of `signOut`.
* https://github.com/nextauthjs/next-auth/blob/3c035ec/packages/next-auth/src/lib/actions.ts#L104
*/
console.log({ logout_NEXTAUTH_URL: process.env.NEXTAUTH_URL })
console.log({ logout_env: process.env })
const headers = new Headers(nextHeaders())
const signOutURL = createActionURL(
"signout",
// @ts-expect-error `x-forwarded-proto` is not nullable, next.js sets it by default
headers.get("x-forwarded-proto"),
headers,
process.env
)
console.log({ logout_signOutURL: signOutURL })
// Redirect to Curity logout
const curityLogoutUrl = `${env.CURITY_ISSUER_USER}/authn/authenticate/logout?redirect_uri=${encodeURIComponent(redirectTo)}`
console.log({ logout_redirectTo: curityLogoutUrl })
const redirectUrlObj = await signOut({
redirectTo: curityLogoutUrl,
redirectTo,
redirect: false,
})
if (redirectUrlObj) {
console.log(`[logout] redirecting to: ${redirectUrlObj.redirect}`)
return NextResponse.redirect(redirectUrlObj.redirect)
} else {
console.error(`[logout] missing redirectUrlObj reponse from signOut()`)
}
} catch (error) {
if (error instanceof AuthError) {

View File

@@ -34,7 +34,6 @@ export default async function MyPages({
<p>{formatMessage({ id: "No content published" })}</p>
)}
</main>
<TrackingSDK pageData={tracking} />
</>
)

View File

@@ -14,17 +14,6 @@
gap: var(--Spacing-x1);
}
.card {
display: grid;
align-items: center;
column-gap: var(--Spacing-x1);
grid-template-columns: auto auto auto 1fr;
justify-items: flex-end;
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half,);
border-radius: var(--Corner-radius-Small);
background-color: var(--Base-Background-Primary-Normal);
}
@media screen and (min-width: 768px) {
.container {
gap: var(--Spacing-x3);

View File

@@ -1,11 +1,9 @@
import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server"
import { CreditCard, Delete } from "@/components/Icons"
import AddCreditCardButton from "@/components/Profile/AddCreditCardButton"
import Button from "@/components/TempDesignSystem/Button"
import CreditCardList from "@/components/Profile/CreditCardList"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { setLang } from "@/i18n/serverContext"
@@ -33,40 +31,8 @@ export default async function CreditCardSlot({ params }: PageArgs<LangParams>) {
})}
</Body>
</article>
{creditCards?.length ? (
<div className={styles.cardContainer}>
{creditCards.map((card, idx) => (
<CreditCardRow
key={idx}
cardType={card.attribute.cardType}
truncatedNumber={card.attribute.truncatedNumber}
/>
))}
</div>
) : null}
<AddCreditCardButton
redirectUrl={`${env.PUBLIC_URL}/api/web/add-card-callback/${lang}`}
/>
<CreditCardList initialData={creditCards} />
<AddCreditCardButton />
</section>
)
}
function CreditCardRow({
truncatedNumber,
cardType,
}: {
truncatedNumber: string
cardType: string
}) {
const maskedCardNumber = `**** ${truncatedNumber.slice(12, 16)}`
return (
<div className={styles.card}>
<CreditCard color="black" />
<Body textTransform="bold">{cardType}</Body>
<Caption color="textMediumContrast">{maskedCardNumber}</Caption>
<Button variant="icon" theme="base" intent="text">
<Delete color="burgundy" />
</Button>
</div>
)
}

View File

@@ -15,7 +15,8 @@ export default function ProfileLayout({
{profile}
<Divider color="burgundy" opacity={8} />
{creditCards}
{communication}
{/* TODO: Implement communication preferences flow. Hidden until decided on where to send user. */}
{/* {communication} */}
</section>
</main>
)

View File

@@ -1,8 +1,8 @@
import { notFound } from "next/navigation"
import ContentPage from "@/components/ContentType/ContentPage"
import HotelPage from "@/components/ContentType/HotelPage/HotelPage"
import LoyaltyPage from "@/components/ContentType/LoyaltyPage/LoyaltyPage"
import HotelPage from "@/components/ContentType/HotelPage"
import LoyaltyPage from "@/components/ContentType/LoyaltyPage"
import { setLang } from "@/i18n/serverContext"
import {

View File

@@ -0,0 +1,20 @@
.main {
display: flex;
justify-content: center;
padding: var(--Spacing-x4);
background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh;
}
.section {
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
width: 100%;
max-width: 365px;
}
@media screen and (min-width: 1367px) {
.section {
max-width: 525px;
}
}

View File

@@ -0,0 +1,20 @@
import IntroSection from "@/components/HotelReservation/BookingConfirmation/IntroSection"
import StaySection from "@/components/HotelReservation/BookingConfirmation/StaySection"
import SummarySection from "@/components/HotelReservation/BookingConfirmation/SummarySection"
import { tempConfirmationData } from "@/components/HotelReservation/BookingConfirmation/tempConfirmationData"
import styles from "./page.module.css"
export default function BookingConfirmationPage() {
const { email, hotel, stay, summary } = tempConfirmationData
return (
<main className={styles.main}>
<section className={styles.section}>
<IntroSection email={email} />
<StaySection hotel={hotel} stay={stay} />
<SummarySection summary={summary} />
</section>
</main>
)
}

View File

@@ -11,32 +11,51 @@ export async function GET(
request: NextRequest,
context: { params: { lang: Lang } }
) {
let redirectHeaders: Headers | undefined = undefined
let redirectTo: string
const returnUrl = request.headers.get("x-returnurl")
const isMFA = request.headers.get("x-mfa-login")
// This is to support seamless login when using magic link login
const isMagicLinkUpdateLogin = !!request.headers.get("x-magic-link")
if (!env.PUBLIC_URL) {
throw internalServerError("No value for env.PUBLIC_URL")
}
if (returnUrl) {
// Seamless login request from Current web
redirectTo = returnUrl
let redirectHeaders: Headers | undefined = undefined
let redirectTo: string
const returnUrl = request.headers.get("x-returnurl")
const isSeamless = request.headers.get("x-login-source") === "seamless"
const isMFA = request.headers.get("x-login-source") === "mfa"
const isSeamlessMagicLink =
request.headers.get("x-login-source") === "seamless-magiclink"
console.log(
`[login] source: ${request.headers.get("x-login-source") || "normal"}`
)
const redirectToCookieValue = request.cookies.get("redirectTo")?.value // Cookie gets set by authRequired middleware
const redirectToSearchParamValue =
request.nextUrl.searchParams.get("redirectTo")
const redirectToFallback = "/"
console.log(`[login] redirectTo cookie value: ${redirectToCookieValue}`)
console.log(
`[login] redirectTo search param value: ${redirectToSearchParamValue}`
)
if (isSeamless || isSeamlessMagicLink) {
if (returnUrl) {
redirectTo = returnUrl
} else {
console.log(
`[login] missing returnUrl, using fallback: ${redirectToFallback}`
)
redirectTo = redirectToFallback
}
} else {
// Normal login request from New web
redirectTo =
request.cookies.get("redirectTo")?.value || // Cookie gets set by authRequired middleware
request.nextUrl.searchParams.get("redirectTo") ||
"/"
redirectToCookieValue || redirectToSearchParamValue || redirectToFallback
// Make relative URL to absolute URL
if (redirectTo.startsWith("/")) {
console.log(`[login] make redirectTo absolute, from ${redirectTo}`)
redirectTo = new URL(redirectTo, env.PUBLIC_URL).href
console.log(`[login] make redirectTo absolute, to ${redirectTo}`)
}
// Clean up cookie from authRequired middleware
@@ -70,7 +89,11 @@ export async function GET(
break
}
const redirectUrl = new URL(redirectUrlValue)
console.log(`[login] creating redirect to seamless login: ${redirectUrl}`)
redirectUrl.searchParams.set("returnurl", redirectTo)
console.log(
`[login] returnurl for seamless login: ${redirectUrl.searchParams.get("returnurl")}`
)
redirectTo = redirectUrl.toString()
/** Set cookie with redirect Url to appropriately redirect user when using magic link login */
@@ -82,25 +105,20 @@ export async function GET(
)
} catch (e) {
console.error(
"Unable to create URL for seamless login, proceeding without it."
"[login] unable to create URL for seamless login, proceeding without it.",
e
)
console.error(e)
}
}
try {
/**
* Passing `redirect: false` to `signIn` will return the URL instead of
* automatically redirecting to it inside of `signIn`.
* https://github.com/nextauthjs/next-auth/blob/3c035ec/packages/next-auth/src/lib/actions.ts#L76
*/
console.log({ login_NEXTAUTH_URL: process.env.NEXTAUTH_URL })
console.log(`[login] final redirectUrl: ${redirectTo}`)
console.log({ login_env: process.env })
console.log({ login_redirectTo: redirectTo })
const params = {
/** Record<string, any> is next-auth typings */
const params: Record<string, any> = {
ui_locales: context.params.lang,
scope: ["openid", "profile"].join(" "),
scope: ["openid", "profile"],
/**
* The `acr_values` param is used to make Curity display the proper login
* page for Scandic. Without the parameter Curity presents some choices
@@ -117,18 +135,25 @@ export async function GET(
// This is new param set for differentiate between the Magic link login of New web and current web
version: "2",
}
if (isMFA) {
// Append profile_update scope for MFA
params.scope = params.scope + " profile_udpate"
params.scope.push("profile_update")
/**
* The below acr value is required as for New Web same Curity Client is used for MFA
* while in current web it is being setup using different Curity Client
*/
params.acr_values =
"urn:se:curity:authentication:otp-authenticator:OTP-Authenticator_web"
} else if (isMagicLinkUpdateLogin) {
} else if (isSeamlessMagicLink) {
params.acr_values = "abc"
}
params.scope = params.scope.join(" ")
/**
* Passing `redirect: false` to `signIn` will return the URL instead of
* automatically redirecting to it inside of `signIn`.
* https://github.com/nextauthjs/next-auth/blob/3c035ec/packages/next-auth/src/lib/actions.ts#L76
*/
const redirectUrl = await signIn(
"curity",
{
@@ -139,9 +164,13 @@ export async function GET(
)
if (redirectUrl) {
return NextResponse.redirect(redirectUrl, {
const redirectOpts = {
headers: redirectHeaders,
})
}
console.log(`[login] redirecting to: ${redirectUrl}`, redirectOpts)
return NextResponse.redirect(redirectUrl, redirectOpts)
} else {
console.error(`[login] missing redirectUrl reponse from signIn()`)
}
} catch (error) {
if (error instanceof AuthError) {

View File

@@ -12,39 +12,54 @@ export async function GET(
request: NextRequest,
context: { params: { lang: Lang } }
) {
let redirectTo: string
// Set redirect url from the magicLinkRedirect Cookie which is set when intiating login
redirectTo =
request.cookies.get("magicLinkRedirectTo")?.value ||
"/" + context.params.lang
if (!env.PUBLIC_URL) {
throw internalServerError("No value for env.PUBLIC_URL")
}
const loginKey = request.nextUrl.searchParams.get("loginKey")
if (!loginKey) {
console.log(
`[verifymagiclink] missing required loginKey, aborting bad request`
)
return badRequest()
}
let redirectTo: string
console.log(`[verifymagiclink] verifying callback`)
const redirectToCookieValue = request.cookies.get(
"magicLinkRedirectTo"
)?.value // Set redirect url from the magicLinkRedirect Cookie which is set when intiating login
const redirectToFallback = "/"
console.log(
`[verifymagiclink] magicLinkRedirectTo cookie value: ${redirectToCookieValue}`
)
redirectTo = redirectToCookieValue || redirectToFallback
// Make relative URL to absolute URL
if (redirectTo.startsWith("/")) {
console.log(
`[verifymagiclink] make redirectTo absolute, from ${redirectTo}`
)
redirectTo = new URL(redirectTo, env.PUBLIC_URL).href
console.log(`[verifymagiclink] make redirectTo absolute, to ${redirectTo}`)
}
// Update Seamless login url as Magic link login has a different authenticator in Curity
redirectTo = redirectTo.replace("updatelogin", "updateloginemail")
const loginKey = request.nextUrl.searchParams.get("loginKey")
if (!loginKey) {
return badRequest()
}
try {
console.log(`[verifymagiclink] final redirectUrl: ${redirectTo}`)
/**
* Passing `redirect: false` to `signIn` will return the URL instead of
* automatically redirecting to it inside of `signIn`.
* https://github.com/nextauthjs/next-auth/blob/3c035ec/packages/next-auth/src/lib/actions.ts#L76
*/
console.log({ login_redirectTo: redirectTo })
let redirectUrl = await signIn(
const redirectUrl = await signIn(
"curity",
{
redirectTo,
@@ -61,7 +76,12 @@ export async function GET(
)
if (redirectUrl) {
console.log(`[verifymagiclink] redirecting to: ${redirectUrl}`)
return NextResponse.redirect(redirectUrl)
} else {
console.error(
`[verifymagiclink] missing redirectUrl reponse from signIn()`
)
}
} catch (error) {
if (error instanceof AuthError) {

View File

@@ -15,14 +15,9 @@ import { getIntl } from "@/i18n"
import ServerIntlProvider from "@/i18n/Provider"
import { getLang, setLang } from "@/i18n/serverContext"
import type { Metadata } from "next"
import type { LangParams, LayoutArgs } from "@/types/params"
export const metadata: Metadata = {
description: "New web",
title: "Scandic Hotels",
}
export { generateMetadata } from "@/utils/generateMetadata"
export default async function RootLayout({
children,
@@ -35,8 +30,8 @@ export default async function RootLayout({
>) {
setLang(params.lang)
preloadUserTracking()
const { defaultLocale, locale, messages } = await getIntl()
return (
<html lang={getLang()}>
<head>

View File

@@ -1,10 +1,6 @@
import { getIntl } from "@/i18n"
import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params"
export default async function NotFound({ params }: PageArgs<LangParams>) {
setLang(params.lang)
export default async function NotFound() {
const { formatMessage } = await getIntl()
return (
<main>

View File

@@ -21,6 +21,7 @@ export default async function ContentTypePage({
const user = await serverClient().user.get()
if (!user) {
console.log(`[webview:page] unable to load user`)
return <p>Error: No user could be loaded</p>
}
@@ -31,9 +32,16 @@ export default async function ContentTypePage({
case "token_expired":
const h = headers()
const returnURL = `/${getLang()}/webview${h.get("x-pathname")!}`
redirect(
`/${getLang()}/webview/refresh?returnUrl=${encodeURIComponent(returnURL)}`
)
const redirectURL = `/${getLang()}/webview/refresh?returnUrl=${encodeURIComponent(returnURL)}`
console.log(`[webview:page] user error, redirecting to: ${redirectURL}`)
redirect(redirectURL)
case "notfound":
return <p>Error: user not found</p>
case "unknown":
return <p>Unknown error occurred loading user</p>
default:
const u: never = user
console.log(`[webview:page] unhandled user loading error`)
}
}

View File

@@ -2,46 +2,53 @@ import { NextRequest } from "next/server"
import { env } from "process"
import { Lang } from "@/constants/languages"
import { profile } from "@/constants/routes/myPages"
import { serverClient } from "@/lib/trpc/server"
import { badRequest, internalServerError } from "@/server/errors/next"
export async function GET(
request: NextRequest,
{ params }: { params: { lang: string } }
) {
try {
const lang = params.lang as Lang
console.log(`[add-card] callback started`)
const lang = params.lang as Lang
const returnUrl = new URL(`${env.PUBLIC_URL}/${profile[lang ?? Lang.en]}`)
try {
const searchParams = request.nextUrl.searchParams
const success = searchParams.get("success")
const failure = searchParams.get("failure")
const cancel = searchParams.get("cancel")
const trxId = searchParams.get("datatransTrxId")
const returnUrl = new URL(
`${env.PUBLIC_URL}/${lang ?? Lang.en}/scandic-friends/my-pages/profile`
)
if (success) {
if (!trxId) {
return badRequest("Missing datatransTrxId param")
}
if (trxId) {
const saveCardSuccess = await serverClient().user.creditCard.save({
transactionId: trxId,
})
const saveCardSuccess = await serverClient().user.saveCard({
transactionId: trxId,
})
if (saveCardSuccess) {
returnUrl.searchParams.set("success", "true")
if (saveCardSuccess) {
console.log(`[add-card] planet success: card saved success`)
returnUrl.searchParams.set("success", "true")
} else {
console.log(`[add-card] planet success: card saved fail`)
returnUrl.searchParams.set("failure", "true")
}
} else {
returnUrl.searchParams.set("failure", "true")
console.log(`[add-card] planet success: missing datatransTrxId`)
returnUrl.searchParams.set("error", "true")
}
} else if (failure) {
console.log(`[add-card] planet fail`)
returnUrl.searchParams.set("failure", "true")
} else if (cancel) {
console.log(`[add-card] planet cancel`)
returnUrl.searchParams.set("cancel", "true")
}
return Response.redirect(returnUrl, 307)
} catch (error) {
console.error(error)
return internalServerError()
} catch (e) {
console.error(`[add-card] error saving credit card`, e)
returnUrl.searchParams.set("error", "true")
}
console.log(`[add-card] redirecting to: ${returnUrl}`)
return Response.redirect(returnUrl)
}

View File

@@ -99,6 +99,7 @@
:root {
--max-width: 113.5rem;
--max-width-content: 74.75rem;
--max-width-text-block: 49.5rem;
}
* {

View File

@@ -136,7 +136,9 @@ export const config = {
return session
},
async redirect({ baseUrl, url }) {
console.log(`[auth] deciding redirect URL`, { baseUrl, url })
if (url.startsWith("/")) {
console.log(`[auth] relative URL accepted, returning: ${baseUrl}${url}`)
// Allows relative callback URLs
return `${baseUrl}${url}`
} else {
@@ -146,17 +148,19 @@ export const config = {
if (
/\.scandichotels\.(dk|de|com|fi|no|se)$/.test(parsedUrl.hostname)
) {
console.log(`[auth] subdomain URL accepted, returning: ${url}`)
// Allows any subdomains on all top level domains above
return url
} else if (parsedUrl.origin === baseUrl) {
// Allows callback URLs on the same origin
console.log(`[auth] origin URL accepted, returning: ${url}`)
return url
}
} catch (e) {
console.error("Error in auth redirect callback")
console.error(e)
console.error(`[auth] error parsing incoming URL for redirection`, e)
}
}
console.log(`[auth] URL denied, returning base URL: ${baseUrl}`)
return baseUrl
},
async authorized({ auth, request }) {

View File

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

View File

@@ -0,0 +1,19 @@
.contentPage {
padding-bottom: var(--Spacing-x9);
}
.header {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x4) var(--Spacing-x2);
}
.content {
padding: var(--Spacing-x4) var(--Spacing-x2);
display: grid;
justify-items: center;
}
.innerContent {
width: 100%;
max-width: var(--max-width-content);
}

View File

@@ -0,0 +1,46 @@
import { serverClient } from "@/lib/trpc/server"
import Hero from "@/components/Hero"
import Intro from "@/components/Intro"
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
import Title from "@/components/TempDesignSystem/Text/Title"
import TrackingSDK from "@/components/TrackingSDK"
import styles from "./contentPage.module.css"
export default async function ContentPage() {
const contentPageRes = await serverClient().contentstack.contentPage.get()
if (!contentPageRes) {
return null
}
const { tracking, contentPage } = contentPageRes
const heroImage = contentPage.heroImage
return (
<>
<section className={styles.contentPage}>
<header className={styles.header}>
<Intro>
<Title as="h2">{contentPage.header.heading}</Title>
<Preamble>{contentPage.header.preamble}</Preamble>
</Intro>
</header>
<main className={styles.content}>
<div className={styles.innerContent}>
{heroImage ? (
<Hero
alt={heroImage.meta.alt || heroImage.meta.caption || ""}
src={heroImage.url}
/>
) : null}
</div>
</main>
</section>
<TrackingSDK pageData={tracking} />
</>
)
}

View File

@@ -1,8 +1,11 @@
import { serverClient } from "@/lib/trpc/server"
import Hero from "@/components/Hero"
import Intro from "@/components/Intro"
import { Blocks } from "@/components/Loyalty/Blocks"
import Sidebar from "@/components/Loyalty/Sidebar"
import MaxWidth from "@/components/MaxWidth"
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
import Title from "@/components/TempDesignSystem/Text/Title"
import TrackingSDK from "@/components/TrackingSDK"
@@ -16,7 +19,7 @@ export default async function LoyaltyPage() {
}
const { tracking, loyaltyPage } = loyaltyPageRes
const heroImage = loyaltyPage.heroImage
return (
<>
<section className={styles.content}>
@@ -24,8 +27,22 @@ export default async function LoyaltyPage() {
<Sidebar blocks={loyaltyPage.sidebar} />
) : null}
<MaxWidth className={styles.blocks} tag="main">
<Title>{loyaltyPage.heading}</Title>
<MaxWidth className={styles.blocks}>
<header className={styles.header}>
<Intro>
<Title as="h2">{loyaltyPage.heading}</Title>
{loyaltyPage.preamble ? (
<Preamble>{loyaltyPage.preamble}</Preamble>
) : null}
</Intro>
{heroImage ? (
<Hero
alt={heroImage.meta.alt || heroImage.meta.caption || ""}
src={heroImage.url}
/>
) : null}
</header>
{loyaltyPage.blocks ? <Blocks blocks={loyaltyPage.blocks} /> : null}
</MaxWidth>
</section>

View File

@@ -15,6 +15,11 @@
padding-right: var(--Spacing-x2);
}
.header {
display: grid;
gap: var(--Spacing-x4);
}
@media screen and (min-width: 1367px) {
.content {
gap: var(--Spacing-x5);

View File

@@ -44,6 +44,7 @@ export default function LoginButton({
id={trackingId}
color={color}
href={`${login[lang]}?redirectTo=${encodeURIComponent(pathName)}`}
prefetch={false}
>
{children}
</Link>

View File

@@ -28,7 +28,7 @@ export default async function Header({
/**
* ToDo: Create logic to get this info from ContentStack based on page
* */
const hideBookingWidget = false
const hideBookingWidget = true
if (!data) {
return null

View File

@@ -0,0 +1,14 @@
.hero {
height: 400px;
margin-bottom: var(--Spacing-x2);
width: 100%;
object-fit: cover;
border-radius: var(--Corner-radius-xLarge);
margin: 0;
}
@media (min-width: 768px) {
.hero {
height: 480px;
}
}

4
components/Hero/hero.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface HeroProps {
alt: string
src: string
}

17
components/Hero/index.tsx Normal file
View File

@@ -0,0 +1,17 @@
import Image from "@/components/Image"
import { HeroProps } from "./hero"
import styles from "./hero.module.css"
export default async function Hero({ alt, src }: HeroProps) {
return (
<Image
className={styles.hero}
alt={alt}
height={480}
width={1196}
src={src}
/>
)
}

View File

@@ -0,0 +1,57 @@
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import styles from "./introSection.module.css"
import { IntroSectionProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default async function IntroSection({ email }: IntroSectionProps) {
const intl = await getIntl()
return (
<section className={styles.section}>
<div>
<Title textAlign="center" as="h2">
{intl.formatMessage({ id: "Thank you" })}
</Title>
<Subtitle textAlign="center" textTransform="uppercase">
{intl.formatMessage({ id: "We look forward to your visit!" })}
</Subtitle>
</div>
<Body color="burgundy" textAlign="center">
{intl.formatMessage({
id: "We have sent a detailed confirmation of your booking to your email: ",
})}
{email}
</Body>
<div className={styles.buttons}>
<Button
asChild
size="small"
theme="base"
intent="secondary"
className={styles.button}
>
<Link href="#" color="none">
{intl.formatMessage({ id: "Download the Scandic app" })}
</Link>
</Button>
<Button
asChild
size="small"
theme="base"
intent="secondary"
className={styles.button}
>
<Link href="#" color="none">
{intl.formatMessage({ id: "View your booking" })}
</Link>
</Button>
</div>
</section>
)
}

View File

@@ -0,0 +1,26 @@
.section {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
width: 100%;
}
.buttons {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x2);
}
.button {
width: 100%;
max-width: 240px;
justify-content: center;
}
@media screen and (min-width: 1367px) {
.buttons {
flex-direction: row;
justify-content: space-around;
}
}

View File

@@ -0,0 +1,80 @@
import { ArrowRightIcon, ScandicLogoIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import styles from "./staySection.module.css"
import { StaySectionProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default async function StaySection({ hotel, stay }: StaySectionProps) {
const intl = await getIntl()
const nightsText =
stay.nights > 1
? intl.formatMessage({ id: "nights" })
: intl.formatMessage({ id: "night" })
return (
<>
<section className={styles.card}>
<Image
src={hotel.image}
alt=""
height={400}
width={200}
className={styles.image}
/>
<div className={styles.info}>
<div className={styles.hotel}>
<ScandicLogoIcon color="red" />
<Title as="h5" textTransform="capitalize">
{hotel.name}
</Title>
<Caption color="burgundy" className={styles.caption}>
<span>{hotel.address}</span>
<span>{hotel.phone}</span>
</Caption>
</div>
<Body className={styles.stay}>
<span>{`${stay.nights} ${nightsText}`}</span>
<span className={styles.dates}>
<span>{stay.start}</span>
<ArrowRightIcon height={15} width={15} />
<span>{stay.end}</span>
</span>
</Body>
</div>
</section>
<section className={styles.table}>
<div className={styles.breakfast}>
<Body color="burgundy">
{intl.formatMessage({ id: "Breakfast" })}
</Body>
<Caption className={styles.caption}>
<span>{`${intl.formatMessage({ id: "Weekdays" })} ${hotel.breakfast.start}-${hotel.breakfast.end}`}</span>
<span>{`${intl.formatMessage({ id: "Weekends" })} ${hotel.breakfast.start}-${hotel.breakfast.end}`}</span>
</Caption>
</div>
<div className={styles.checkIn}>
<Body color="burgundy">{intl.formatMessage({ id: "Check in" })}</Body>
<Caption className={styles.caption}>
<span>{intl.formatMessage({ id: "From" })}</span>
<span>{hotel.checkIn}</span>
</Caption>
</div>
<div className={styles.checkOut}>
<Body color="burgundy">
{intl.formatMessage({ id: "Check out" })}
</Body>
<Caption className={styles.caption}>
<span>{intl.formatMessage({ id: "At latest" })}</span>
<span>{hotel.checkOut}</span>
</Caption>
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,78 @@
.card {
display: flex;
width: 100%;
background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Small);
overflow: hidden;
}
.image {
height: 100%;
width: 105px;
object-fit: cover;
}
.info {
display: flex;
flex-direction: column;
width: 100%;
gap: var(--Spacing-x1);
padding: var(--Spacing-x2);
}
.hotel,
.stay {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
}
.caption {
display: flex;
flex-direction: column;
}
.dates {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
}
.table {
display: flex;
justify-content: space-between;
padding: var(--Spacing-x2);
border-radius: var(--Corner-radius-Small);
background-color: var(--Base-Surface-Primary-dark-Normal);
width: 100%;
}
.breakfast,
.checkIn,
.checkOut {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
}
@media screen and (min-width: 1367px) {
.card {
flex-direction: column;
}
.image {
width: 100%;
max-height: 195px;
}
.info {
flex-direction: row;
justify-content: space-between;
}
.hotel,
.stay {
width: 100%;
max-width: 230px;
}
}

View File

@@ -0,0 +1,39 @@
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import styles from "./summarySection.module.css"
import { SummarySectionProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default async function SummarySection({ summary }: SummarySectionProps) {
const intl = await getIntl()
const roomType = `${intl.formatMessage({ id: "Type of room" })}: ${summary.roomType}`
const bedType = `${intl.formatMessage({ id: "Type of bed" })}: ${summary.bedType}`
const breakfast = `${intl.formatMessage({ id: "Breakfast" })}: ${summary.breakfast}`
const flexibility = `${intl.formatMessage({ id: "Flexibility" })}: ${summary.flexibility}`
return (
<section className={styles.section}>
<Title as="h4" textAlign="center">
{intl.formatMessage({ id: "Summary" })}
</Title>
<Caption className={styles.summary}>
<span>{roomType}</span>
<span>1648 SEK</span>
</Caption>
<Caption className={styles.summary}>
<span>{bedType}</span>
<span>0 SEK</span>
</Caption>
<Caption className={styles.summary}>
<span>{breakfast}</span>
<span>198 SEK</span>
</Caption>
<Caption className={styles.summary}>
<span>{flexibility}</span>
<span>200 SEK</span>
</Caption>
</section>
)
}

View File

@@ -0,0 +1,13 @@
.section {
width: 100%;
}
.summary {
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.summary span {
padding: var(--Spacing-x2) var(--Spacing-x0);
}

View File

@@ -0,0 +1,27 @@
import { BookingConfirmation } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export const tempConfirmationData: BookingConfirmation = {
email: "lisa.andersson@outlook.com",
hotel: {
name: "Helsinki Hub",
address: "Kaisaniemenkatu 7, Helsinki",
location: "Helsinki",
phone: "+358 300 870680",
image:
"https://test3.scandichotels.com/imagevault/publishedmedia/i11isd60bh119s9486b7/downtown-camper-by-scandic-lobby-reception-desk-ch.jpg?w=640",
checkIn: "15.00",
checkOut: "12.00",
breakfast: { start: "06:30", end: "10:00" },
},
stay: {
nights: 1,
start: "2024.03.09",
end: "2024.03.10",
},
summary: {
roomType: "Standard Room",
bedType: "King size",
breakfast: "Yes",
flexibility: "Yes",
},
}

View File

@@ -0,0 +1,11 @@
import { PropsWithChildren } from "react"
import styles from "./intro.module.css"
export default async function Intro({ children }: PropsWithChildren) {
return (
<div className={styles.intro}>
<div className={styles.content}>{children}</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
.intro {
max-width: var(--max-width-content);
margin: 0 auto;
}
.content {
display: grid;
max-width: var(--max-width-text-block);
gap: var(--Spacing-x2);
}
@media (min-width: 768px) {
.content {
gap: var(--Spacing-x3);
}
}

View File

@@ -347,7 +347,9 @@ export const renderOptions: RenderOptions = {
const image = insertResponseToImageVaultAsset(attrs)
const alt = image.meta.alt ?? image.title
const width = parseInt(attrs.width.replaceAll("px", ""))
const width = attrs.width
? parseInt(attrs.width.replaceAll("px", ""))
: image.dimensions.width
const props = extractPossibleAttributes(attrs)
return (
<section key={node.uid}>

View File

@@ -3,7 +3,7 @@
{
"level": 1,
"name": "New Friend",
"requirement": "0p",
"requirement": "0 Punkte",
"description": "Dies ist der Beginn von etwas Wunderbarem: Als New Friend können Sie sich auf eine Reise voller herrlicher Scandic-Entdeckungen freuen.",
"icon": "/_static/icons/loyaltylevels/new-friend.svg",
"benefits": [
@@ -78,7 +78,7 @@
{
"level": 2,
"name": "Good Friend",
"requirement": "5 000p",
"requirement": "5 000 Punkte",
"description": "Sie waren in letzter Zeit viel bei uns! Und ehrlich gesagt haben wir das Gefühl, dass wir auf einer Wellenlänge sind die vielen angenehmen Aufenthalte und lustigen Überraschungen sprechen für sich.",
"icon": "/_static/icons/loyaltylevels/good-friend.svg",
"benefits": [
@@ -153,7 +153,7 @@
{
"level": 3,
"name": "Close Friend",
"requirement": "10 000p",
"requirement": "10 000 Punkte",
"description": "Jetzt wird es ernst: Wir lernen uns wirklich besser kennen, was bedeutet, dass Ihre Zeit mit Scandic noch viel persönlicher wird.",
"icon": "/_static/icons/loyaltylevels/close-friend.svg",
"benefits": [
@@ -229,7 +229,7 @@
{
"level": 4,
"name": "Dear Friend",
"requirement": "25 000p",
"requirement": "25 000 Punkte",
"description": "Ein Hoch auf uns! Unser Verhältnis scheint sich in Richtung Freunde fürs Leben zu entwickeln was auch bedeutet, dass Sie Zugang zu einer ganzen Menge mehr Scandic bekommen.",
"icon": "/_static/icons/loyaltylevels/dear-friend.svg",
"benefits": [
@@ -306,7 +306,7 @@
{
"level": 5,
"name": "Loyal Friend",
"requirement": "100 000p",
"requirement": "100 000 Punkte",
"description": "Sie haben uns während zahlreicher Aufenthalte, Happy Hours und Workouts im Fitnessstudio die Treue gehalten deshalb wollen wir uns mit einigen unserer großartigsten Belohnungen bei Ihnen revanchieren.",
"icon": "/_static/icons/loyaltylevels/loyal-friend.svg",
"benefits": [
@@ -383,7 +383,7 @@
{
"level": 6,
"name": "True Friend",
"requirement": "250 000p",
"requirement": "250 000 Punkte",
"description": "Es spielt keine Rolle, ob Haupt- oder Nebensaison: Sie sind immer für uns da. Genießen Sie noch mehr individuelle Vorteile genau nach Ihrem Geschmack.",
"icon": "/_static/icons/loyaltylevels/true-friend.svg",
"benefits": [
@@ -460,7 +460,7 @@
{
"level": 7,
"name": "Best Friend",
"requirement": "400 000p oder 100 nächte",
"requirement": "400 000 Punkte oder 100 Nächte",
"description": "Für eine Freundschaft wie diese gibt es im Grunde keine passenden Worte, aber wir versuchen es trotzdem: Denn es könnte gar nichts Besseres geben, wenn es um sehr, sehr exklusive Erlebnisse geht!",
"icon": "/_static/icons/loyaltylevels/best-friend.svg",
"benefits": [

View File

@@ -3,29 +3,29 @@
{
"level": 1,
"name": "New Friend",
"requirement": "0p",
"description": "Olemme uuden ja upean kynnyksellä: New Friend-ystävänä pääset nauttimaan kaikesta ihanasta, mitä Scandic tarjoaa.",
"requirement": "0 p",
"description": "Ystävänämme pääset nauttimaan kaikesta ihanasta, mitä Scandic tarjoaa.",
"icon": "/_static/icons/loyaltylevels/new-friend.svg",
"benefits": [
{
"name": "Ystävähinnat",
"description": "Ystävänämme saat aina parhaan hinnan. Ei salaisia koodeja tai kädenpuristuksia sen kuin vain hyppäät varaamaan huoletta.",
"description": "Ystävänämme saat aina parhaan hinnan.",
"unlocked": true
},
{
"name": "Alennus ruoasta",
"description": "Mikä herkullinen etu! Hyödynnä 10%:n alennus hotelliemme ravintoloissa ja shopissa viikonloppuisin. Tarjous on voimassa niin majoittujille kuin hotellitunnelmaa hetkeksi etsiville. Hemmottele siis itseäsi ja löydä tie lähimpään Scandiciin.",
"unlocked": true,
"value": "10%"
"value": "10 %"
},
{
"name": "Mocktail lapsille yöpymisen yhteydessä maksutta",
"description": "Lapset mukana matkassa? Meillä lapset ovat superstaroja ja siksi he saavat raikkaan mocktailin majoittumisen yhteydessä meidän piikkiin.",
"name": "Mocktail lapsille maksutta",
"description": "Meillä lapset saavat raikkaan mocktailin majoittumisen yhteydessä.",
"unlocked": true
},
{
"name": "Myöhäinen uloskirjautuminen varaustilanteen mukaan",
"description": "Joskus myöhäinen uloskirjautuminen pelastaa päivän. Nyt ystävyteemme on jo sillä tasolla, että sinulla ei ole kiire hypätä sängystä aamuvarhaisella. Kirjaudu ulos tuntia myöhemmin ilman lisämaksua ja ota kaikki irti extra-ajasta.",
"name": "Myöhäinen uloskirjautuminen",
"description": "Kirjaudu ulos tuntia myöhemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": false
},
{
@@ -39,38 +39,38 @@
"unlocked": false
},
{
"name": "Aikainen sisäänkirjautuminen varaustilanteen mukaan",
"description": "Ota varaslähtö yöpymiseen! Kirjaudu sisään tuntia aiemmin ilman lisämaksua ja ota oikotie rentoutumiseen.",
"name": "Aikainen sisäänkirjautuminen",
"description": "Kirjaudu sisään tuntia aiemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": false
},
{
"name": "Maksuton huoneluokan korotus varaustilanteen mukaan",
"description": "Ystävyytemme on seuraavalla vaihteella ja nyt saat huoneluokan korotuksen ilman lisämaksua aina kun siihen on mahdollisuus. Luvassa siis entistä mukavampi majoitus!",
"name": "Maksuton huoneluokan korotus",
"description": "Saat huoneluokan korotuksen ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": false
},
{
"name": "Aamiainen kaksi yhden hinnalla",
"description": "Herkkuja kaksin kerroin! Nyt aamiaiselle kannattaa saapua kaverin kanssa, sillä saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et. Valmistaudu aamutreffeihin huolella tutustumalla lisätietoihin.",
"description": "Saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et.",
"unlocked": false
},
{
"name": "48tunnin huonetakuu",
"description": "Uniikki etu harvoille ja valituille eli sinulle! Ystävänäsi lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi. Aika taikuruutta, vai mitä?",
"description": "Lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi.",
"unlocked": false
},
{
"name": "Aamiainen aina maksutta",
"description": "Haluaisitko aloittaa aamusi herkullisesti? Tarjoamme sinulle valmiin hotelliaamiaisen maksutta vaikka joka aamu. Etu on käytettävissäsi riippumatta siitä, majoitutko meillä vai et.",
"description": "Tarjoamme sinulle hotelliaamiaisen maksutta, majoitut meillä tai et.",
"unlocked": false
},
{
"name": "Upea vuotuinen lahja",
"description": "Best Friend -ystävänämme ansaitset kuninkaallista kohtelua. Siksi palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella lahjalla. Mikä se on? No se on tietysti yllätys.",
"description": "Palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella yllätyslahjalla.",
"unlocked": false
},
{
"name": "Kids boost",
"description": "Ystävyytemme ulottuu myös mukana matkaaviin lapsiin. Muistamme pikkuväkeä pienellä lahjalla joka kerta kun majoitutte meillä. Pienet VIP-vieraat ovat sen arvoisia, vai mitä!",
"description": "Muistamme lapsia pienellä lahjalla majoituksen yhteydessä.",
"unlocked": false
}
]
@@ -78,29 +78,29 @@
{
"level": 2,
"name": "Good Friend",
"requirement": "5 000p",
"description": "Kiva, että olet vieraillut meillä, ja tuntuu, että ystävyytemme on hyvässä nosteessa. Tästä on hyvä jatkaa, yksi yöpyminen ja iloinen yllätys kerrallaan!",
"requirement": "5 000 p",
"description": "Tästä on hyvä jatkaa, yksi yöpyminen ja iloinen yllätys kerrallaan!",
"icon": "/_static/icons/loyaltylevels/good-friend.svg",
"benefits": [
{
"name": "Ystävähinnat",
"description": "Ystävänämme saat aina parhaan hinnan. Ei salaisia koodeja tai kädenpuristuksia sen kuin vain hyppäät varaamaan huoletta.",
"description": "Ystävänämme saat aina parhaan hinnan.",
"unlocked": true
},
{
"name": "Alennus ruoasta",
"description": "Maukas etu sinulle! Ystävänämme saat 10-15% alennusta ruoasta hotelliemme ravintoloissa ja shopissa. Tarjous on voimassa viikonloppuisin ja valittuina loma-aikoina, majoitut meillä tai et. Hemmottele siis itseäsi olet sen ansainnut!",
"description": "Ystävänämme saat 10-15 % alennusta ruoasta hotelliemme ravintoloissa ja shopissa viikonloppuisin ja valittuina loma-aikoina.",
"unlocked": true,
"value": "15%"
"value": "15 %"
},
{
"name": "Mocktail lapsille yöpymisen yhteydessä maksutta",
"description": "Lapset mukana matkassa? Meillä lapset ovat superstaroja ja siksi he saavat raikkaan mocktailin majoittumisen yhteydessä meidän piikkiin.",
"name": "Mocktail lapsille maksutta",
"description": "Meillä lapset saavat raikkaan mocktailin majoittumisen yhteydessä.",
"unlocked": true
},
{
"name": "Myöhäinen uloskirjautuminen varaustilanteen mukaan",
"description": "Joskus myöhäinen uloskirjautuminen pelastaa päivän. Nyt ystävyteemme on jo sillä tasolla, että sinulla ei ole kiire hypätä sängystä aamuvarhaisella. Kirjaudu ulos tuntia myöhemmin ilman lisämaksua ja ota kaikki irti extra-ajasta.",
"name": "Myöhäinen uloskirjautuminen",
"description": "Kirjaudu ulos tuntia myöhemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": false
},
{
@@ -114,38 +114,38 @@
"unlocked": false
},
{
"name": "Aikainen sisäänkirjautuminen varaustilanteen mukaan",
"description": "Ota varaslähtö yöpymiseen! Kirjaudu sisään tuntia aiemmin ilman lisämaksua ja ota oikotie rentoutumiseen.",
"name": "Aikainen sisäänkirjautuminen",
"description": "Kirjaudu sisään tuntia aiemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": false
},
{
"name": "Maksuton huoneluokan korotus varaustilanteen mukaan",
"description": "Ystävyytemme on seuraavalla vaihteella ja nyt saat huoneluokan korotuksen ilman lisämaksua aina kun siihen on mahdollisuus. Luvassa siis entistä mukavampi majoitus!",
"name": "Maksuton huoneluokan korotus",
"description": "Saat huoneluokan korotuksen ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": false
},
{
"name": "Aamiainen kaksi yhden hinnalla",
"description": "Herkkuja kaksin kerroin! Nyt aamiaiselle kannattaa saapua kaverin kanssa, sillä saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et. Valmistaudu aamutreffeihin huolella tutustumalla lisätietoihin.",
"description": "Saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et.",
"unlocked": false
},
{
"name": "48tunnin huonetakuu",
"description": "Uniikki etu harvoille ja valituille eli sinulle! Ystävänäsi lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi. Aika taikuruutta, vai mitä?",
"description": "Lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi.",
"unlocked": false
},
{
"name": "Aamiainen aina maksutta",
"description": "Haluaisitko aloittaa aamusi herkullisesti? Tarjoamme sinulle valmiin hotelliaamiaisen maksutta vaikka joka aamu. Etu on käytettävissäsi riippumatta siitä, majoitutko meillä vai et.",
"description": "Tarjoamme sinulle hotelliaamiaisen maksutta, majoitut meillä tai et.",
"unlocked": false
},
{
"name": "Upea vuotuinen lahja",
"description": "Best Friend -ystävänämme ansaitset kuninkaallista kohtelua. Siksi palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella lahjalla. Mikä se on? No se on tietysti yllätys.",
"description": "Palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella yllätyslahjalla.",
"unlocked": false
},
{
"name": "Kids boost",
"description": "Ystävyytemme ulottuu myös mukana matkaaviin lapsiin. Muistamme pikkuväkeä pienellä lahjalla joka kerta kun majoitutte meillä. Pienet VIP-vieraat ovat sen arvoisia, vai mitä!",
"description": "Muistamme lapsia pienellä lahjalla majoituksen yhteydessä.",
"unlocked": false
}
]
@@ -153,29 +153,29 @@
{
"level": 3,
"name": "Close Friend",
"requirement": "10 000p",
"description": "Onpa kiva, että olet vieraillut meillä näin usein! Nyt etusi vain paranevat, sillä olemmehan jo enemmän kuin hyvän päivän tuttuja.",
"requirement": "10 000 p",
"description": "Nyt etusi vain paranevat, sillä olemmehan jo enemmän kuin hyvän päivän tuttuja.",
"icon": "/_static/icons/loyaltylevels/close-friend.svg",
"benefits": [
{
"name": "Ystävähinnat",
"description": "Ystävänämme saat aina parhaan hinnan. Ei salaisia koodeja tai kädenpuristuksia sen kuin vain hyppäät varaamaan huoletta.",
"description": "Ystävänämme saat aina parhaan hinnan.",
"unlocked": true
},
{
"name": "Alennus ruoasta",
"description": "Maukas etu sinulle! Ystävänämme saat 10-15% alennusta ruoasta hotelliemme ravintoloissa ja shopissa. Tarjous on voimassa viikonloppuisin ja valittuina loma-aikoina, majoitut meillä tai et. Hemmottele siis itseäsi olet sen ansainnut!",
"description": "Ystävänämme saat 10-15 % alennusta ruoasta hotelliemme ravintoloissa ja shopissa viikonloppuisin ja valittuina loma-aikoina.",
"unlocked": true,
"value": "15%"
"value": "15 %"
},
{
"name": "Mocktail lapsille yöpymisen yhteydessä maksutta",
"description": "Lapset mukana matkassa? Meillä lapset ovat superstaroja ja siksi he saavat raikkaan mocktailin majoittumisen yhteydessä meidän piikkiin.",
"name": "Mocktail lapsille maksutta",
"description": "Meillä lapset saavat raikkaan mocktailin majoittumisen yhteydessä.",
"unlocked": true
},
{
"name": "Myöhäinen uloskirjautuminen varaustilanteen mukaan",
"description": "Joskus myöhäinen uloskirjautuminen pelastaa päivän. Nyt ystävyteemme on jo sillä tasolla, että sinulla ei ole kiire hypätä sängystä aamuvarhaisella. Kirjaudu ulos tuntia myöhemmin ilman lisämaksua ja ota kaikki irti extra-ajasta.",
"name": "Myöhäinen uloskirjautuminen",
"description": "Kirjaudu ulos tuntia myöhemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": true
},
{
@@ -190,38 +190,38 @@
"unlocked": false
},
{
"name": "Aikainen sisäänkirjautuminen varaustilanteen mukaan",
"description": "Ota varaslähtö yöpymiseen! Kirjaudu sisään tuntia aiemmin ilman lisämaksua ja ota oikotie rentoutumiseen.",
"name": "Aikainen sisäänkirjautuminen",
"description": "Kirjaudu sisään tuntia aiemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": false
},
{
"name": "Maksuton huoneluokan korotus varaustilanteen mukaan",
"description": "Ystävyytemme on seuraavalla vaihteella ja nyt saat huoneluokan korotuksen ilman lisämaksua aina kun siihen on mahdollisuus. Luvassa siis entistä mukavampi majoitus!",
"name": "Maksuton huoneluokan korotus",
"description": "Saat huoneluokan korotuksen ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": false
},
{
"name": "Aamiainen kaksi yhden hinnalla",
"description": "Herkkuja kaksin kerroin! Nyt aamiaiselle kannattaa saapua kaverin kanssa, sillä saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et. Valmistaudu aamutreffeihin huolella tutustumalla lisätietoihin.",
"description": "Saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et.",
"unlocked": false
},
{
"name": "48tunnin huonetakuu",
"description": "Uniikki etu harvoille ja valituille eli sinulle! Ystävänäsi lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi. Aika taikuruutta, vai mitä?",
"description": "Lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi.",
"unlocked": false
},
{
"name": "Aamiainen aina maksutta",
"description": "Haluaisitko aloittaa aamusi herkullisesti? Tarjoamme sinulle valmiin hotelliaamiaisen maksutta vaikka joka aamu. Etu on käytettävissäsi riippumatta siitä, majoitutko meillä vai et.",
"description": "Tarjoamme sinulle hotelliaamiaisen maksutta, majoitut meillä tai et.",
"unlocked": false
},
{
"name": "Upea vuotuinen lahja",
"description": "Best Friend -ystävänämme ansaitset kuninkaallista kohtelua. Siksi palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella lahjalla. Mikä se on? No se on tietysti yllätys.",
"description": "Palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella yllätyslahjalla.",
"unlocked": false
},
{
"name": "Kids boost",
"description": "Ystävyytemme ulottuu myös mukana matkaaviin lapsiin. Muistamme pikkuväkeä pienellä lahjalla joka kerta kun majoitutte meillä. Pienet VIP-vieraat ovat sen arvoisia, vai mitä!",
"description": "Muistamme lapsia pienellä lahjalla majoituksen yhteydessä.",
"unlocked": false
}
]
@@ -229,29 +229,29 @@
{
"level": 4,
"name": "Dear Friend",
"requirement": "25 000p",
"requirement": "25 000 p",
"description": "Kippis syventyvälle ystävyydellemme. Nyt pääset nauttimaan liudasta uusia etuja.",
"icon": "/_static/icons/loyaltylevels/dear-friend.svg",
"benefits": [
{
"name": "Ystävähinnat",
"description": "Ystävänämme saat aina parhaan hinnan. Ei salaisia koodeja tai kädenpuristuksia sen kuin vain hyppäät varaamaan huoletta.",
"description": "Ystävänämme saat aina parhaan hinnan.",
"unlocked": true
},
{
"name": "Alennus ruoasta",
"description": "Maukas etu sinulle! Ystävänämme saat 10-15% alennusta ruoasta hotelliemme ravintoloissa ja shopissa. Tarjous on voimassa viikonloppuisin ja valittuina loma-aikoina, majoitut meillä tai et. Hemmottele siis itseäsi olet sen ansainnut!",
"description": "Ystävänämme saat 10-15 % alennusta ruoasta hotelliemme ravintoloissa ja shopissa viikonloppuisin ja valittuina loma-aikoina.",
"unlocked": true,
"value": "15%"
"value": "15 %"
},
{
"name": "Mocktail lapsille yöpymisen yhteydessä maksutta",
"description": "Lapset mukana matkassa? Meillä lapset ovat superstaroja ja siksi he saavat raikkaan mocktailin majoittumisen yhteydessä meidän piikkiin.",
"name": "Mocktail lapsille maksutta",
"description": "Meillä lapset saavat raikkaan mocktailin majoittumisen yhteydessä.",
"unlocked": true
},
{
"name": "Myöhäinen uloskirjautuminen varaustilanteen mukaan",
"description": "Joskus myöhäinen uloskirjautuminen pelastaa päivän. Nyt ystävyteemme on jo sillä tasolla, että sinulla ei ole kiire hypätä sängystä aamuvarhaisella. Kirjaudu ulos tuntia myöhemmin ilman lisämaksua ja ota kaikki irti extra-ajasta.",
"name": "Myöhäinen uloskirjautuminen",
"description": "Kirjaudu ulos tuntia myöhemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": true
},
{
@@ -264,41 +264,41 @@
"name": "Enemmän pisteitä",
"description": "Tässä lisäboostia sinulle: saat 25 % enemmän pisteitä joka kerta kun ansaitset pisteitä! Pistä tuulemaan ja haali pisteet yöpymisistä, aterioista ja muusta, niin pääset nauttimaan palkintoyöstä tuossa tuokiossa.",
"unlocked": true,
"value": "25%"
"value": "25 %"
},
{
"name": "Aikainen sisäänkirjautuminen varaustilanteen mukaan",
"description": "Ota varaslähtö yöpymiseen! Kirjaudu sisään tuntia aiemmin ilman lisämaksua ja ota oikotie rentoutumiseen.",
"name": "Aikainen sisäänkirjautuminen",
"description": "Kirjaudu sisään tuntia aiemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": true
},
{
"name": "Maksuton huoneluokan korotus varaustilanteen mukaan",
"description": "Ystävyytemme on seuraavalla vaihteella ja nyt saat huoneluokan korotuksen ilman lisämaksua aina kun siihen on mahdollisuus. Luvassa siis entistä mukavampi majoitus!",
"name": "Maksuton huoneluokan korotus",
"description": "Saat huoneluokan korotuksen ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": false
},
{
"name": "Aamiainen kaksi yhden hinnalla",
"description": "Herkkuja kaksin kerroin! Nyt aamiaiselle kannattaa saapua kaverin kanssa, sillä saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et. Valmistaudu aamutreffeihin huolella tutustumalla lisätietoihin.",
"description": "Saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et.",
"unlocked": false
},
{
"name": "48tunnin huonetakuu",
"description": "Uniikki etu harvoille ja valituille eli sinulle! Ystävänäsi lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi. Aika taikuruutta, vai mitä?",
"description": "Lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi.",
"unlocked": false
},
{
"name": "Aamiainen aina maksutta",
"description": "Haluaisitko aloittaa aamusi herkullisesti? Tarjoamme sinulle valmiin hotelliaamiaisen maksutta vaikka joka aamu. Etu on käytettävissäsi riippumatta siitä, majoitutko meillä vai et.",
"description": "Tarjoamme sinulle hotelliaamiaisen maksutta, majoitut meillä tai et.",
"unlocked": false
},
{
"name": "Upea vuotuinen lahja",
"description": "Best Friend -ystävänämme ansaitset kuninkaallista kohtelua. Siksi palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella lahjalla. Mikä se on? No se on tietysti yllätys.",
"description": "Palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella yllätyslahjalla.",
"unlocked": false
},
{
"name": "Kids boost",
"description": "Ystävyytemme ulottuu myös mukana matkaaviin lapsiin. Muistamme pikkuväkeä pienellä lahjalla joka kerta kun majoitutte meillä. Pienet VIP-vieraat ovat sen arvoisia, vai mitä!",
"description": "Muistamme lapsia pienellä lahjalla majoituksen yhteydessä.",
"unlocked": false
}
]
@@ -306,29 +306,29 @@
{
"level": 5,
"name": "Loyal Friend",
"requirement": "100 000p",
"description": "Kiva, että olemme saaneet jakaa paljon yhteisiä hetkiä. Olet tosiaan nimesi arvoinen Loyal Friend! Haluamme panostaa ystävyyteemme myös jatkossa ja annammekin sinulle kasan uusia, ihania etuja.",
"requirement": "100 000 p",
"description": "Haluamme panostaa ystävyyteemme myös jatkossa ja annammekin sinulle kasan uusia, ihania etuja.",
"icon": "/_static/icons/loyaltylevels/loyal-friend.svg",
"benefits": [
{
"name": "Ystävähinnat",
"description": "Ystävänämme saat aina parhaan hinnan. Ei salaisia koodeja tai kädenpuristuksia sen kuin vain hyppäät varaamaan huoletta.",
"description": "Ystävänämme saat aina parhaan hinnan.",
"unlocked": true
},
{
"name": "Alennus ruoasta",
"description": "Maukas etu sinulle! Ystävänämme saat 10-15% alennusta ruoasta hotelliemme ravintoloissa ja shopissa. Tarjous on voimassa viikonloppuisin ja valittuina loma-aikoina, majoitut meillä tai et. Hemmottele siis itseäsi olet sen ansainnut!",
"description": "Ystävänämme saat 10-15 % alennusta ruoasta hotelliemme ravintoloissa ja shopissa viikonloppuisin ja valittuina loma-aikoina.",
"unlocked": true,
"value": "15%"
"value": "15 %"
},
{
"name": "Mocktail lapsille yöpymisen yhteydessä maksutta",
"description": "Lapset mukana matkassa? Meillä lapset ovat superstaroja ja siksi he saavat raikkaan mocktailin majoittumisen yhteydessä meidän piikkiin.",
"name": "Mocktail lapsille maksutta",
"description": "Meillä lapset saavat raikkaan mocktailin majoittumisen yhteydessä.",
"unlocked": true
},
{
"name": "Myöhäinen uloskirjautuminen varaustilanteen mukaan",
"description": "Joskus myöhäinen uloskirjautuminen pelastaa päivän. Nyt ystävyteemme on jo sillä tasolla, että sinulla ei ole kiire hypätä sängystä aamuvarhaisella. Kirjaudu ulos tuntia myöhemmin ilman lisämaksua ja ota kaikki irti extra-ajasta.",
"name": "Myöhäinen uloskirjautuminen",
"description": "Kirjaudu ulos tuntia myöhemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": true
},
{
@@ -341,41 +341,41 @@
"name": "Enemmän pisteitä",
"description": "Tässä lisäboostia sinulle: saat 25 % enemmän pisteitä joka kerta kun ansaitset pisteitä! Pistä tuulemaan ja haali pisteet yöpymisistä, aterioista ja muusta, niin pääset nauttimaan palkintoyöstä tuossa tuokiossa.",
"unlocked": true,
"value": "25%"
"value": "25 %"
},
{
"name": "Aikainen sisäänkirjautuminen varaustilanteen mukaan",
"description": "Ota varaslähtö yöpymiseen! Kirjaudu sisään tuntia aiemmin ilman lisämaksua ja ota oikotie rentoutumiseen.",
"name": "Aikainen sisäänkirjautuminen",
"description": "Kirjaudu sisään tuntia aiemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": true
},
{
"name": "Maksuton huoneluokan korotus varaustilanteen mukaan",
"description": "Ystävyytemme on seuraavalla vaihteella ja nyt saat huoneluokan korotuksen ilman lisämaksua aina kun siihen on mahdollisuus. Luvassa siis entistä mukavampi majoitus!",
"name": "Maksuton huoneluokan korotus",
"description": "Saat huoneluokan korotuksen ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": true
},
{
"name": "Aamiainen kaksi yhden hinnalla",
"description": "Herkkuja kaksin kerroin! Nyt aamiaiselle kannattaa saapua kaverin kanssa, sillä saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et. Valmistaudu aamutreffeihin huolella tutustumalla lisätietoihin.",
"description": "Saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et.",
"unlocked": true
},
{
"name": "48tunnin huonetakuu",
"description": "Uniikki etu harvoille ja valituille eli sinulle! Ystävänäsi lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi. Aika taikuruutta, vai mitä?",
"description": "Lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi.",
"unlocked": false
},
{
"name": "Aamiainen aina maksutta",
"description": "Haluaisitko aloittaa aamusi herkullisesti? Tarjoamme sinulle valmiin hotelliaamiaisen maksutta vaikka joka aamu. Etu on käytettävissäsi riippumatta siitä, majoitutko meillä vai et.",
"description": "Tarjoamme sinulle hotelliaamiaisen maksutta, majoitut meillä tai et.",
"unlocked": false
},
{
"name": "Upea vuotuinen lahja",
"description": "Best Friend -ystävänämme ansaitset kuninkaallista kohtelua. Siksi palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella lahjalla. Mikä se on? No se on tietysti yllätys.",
"description": "Palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella yllätyslahjalla.",
"unlocked": false
},
{
"name": "Kids boost",
"description": "Ystävyytemme ulottuu myös mukana matkaaviin lapsiin. Muistamme pikkuväkeä pienellä lahjalla joka kerta kun majoitutte meillä. Pienet VIP-vieraat ovat sen arvoisia, vai mitä!",
"description": "Muistamme lapsia pienellä lahjalla majoituksen yhteydessä.",
"unlocked": false
}
]
@@ -383,29 +383,29 @@
{
"level": 6,
"name": "True Friend",
"requirement": "250 000p",
"description": "Onpa ollut ihana nähdä sinua näin paljon viime aikoina. Tosiystävän tapaan haluamme palkita sinua entistä yksilöllisemmillä eduilla.",
"requirement": "250 000 p",
"description": "Tosiystävän tapaan haluamme palkita sinua entistä yksilöllisemmillä eduilla.",
"icon": "/_static/icons/loyaltylevels/true-friend.svg",
"benefits": [
{
"name": "Ystävähinnat",
"description": "Ystävänämme saat aina parhaan hinnan. Ei salaisia koodeja tai kädenpuristuksia sen kuin vain hyppäät varaamaan huoletta.",
"description": "Ystävänämme saat aina parhaan hinnan.",
"unlocked": true
},
{
"name": "Alennus ruoasta",
"description": "Maukas etu sinulle! Ystävänämme saat 10-15% alennusta ruoasta hotelliemme ravintoloissa ja shopissa. Tarjous on voimassa viikonloppuisin ja valittuina loma-aikoina, majoitut meillä tai et. Hemmottele siis itseäsi olet sen ansainnut!",
"description": "Ystävänämme saat 10-15 % alennusta ruoasta hotelliemme ravintoloissa ja shopissa viikonloppuisin ja valittuina loma-aikoina.",
"unlocked": true,
"value": "15%"
"value": "15 %"
},
{
"name": "Mocktail lapsille yöpymisen yhteydessä maksutta",
"description": "Lapset mukana matkassa? Meillä lapset ovat superstaroja ja siksi he saavat raikkaan mocktailin majoittumisen yhteydessä meidän piikkiin.",
"name": "Mocktail lapsille maksutta",
"description": "Meillä lapset saavat raikkaan mocktailin majoittumisen yhteydessä.",
"unlocked": true
},
{
"name": "Myöhäinen uloskirjautuminen varaustilanteen mukaan",
"description": "Joskus myöhäinen uloskirjautuminen pelastaa päivän. Nyt ystävyteemme on jo sillä tasolla, että sinulla ei ole kiire hypätä sängystä aamuvarhaisella. Kirjaudu ulos tuntia myöhemmin ilman lisämaksua ja ota kaikki irti extra-ajasta.",
"name": "Myöhäinen uloskirjautuminen",
"description": "Kirjaudu ulos tuntia myöhemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": true
},
{
@@ -416,43 +416,43 @@
},
{
"name": "Enemmän pisteitä",
"description": "Tässä extra-boostia sinulle: saat 50 % enemmän pisteitä joka kerta kun ansaitset pisteitä! Pistä tuulemaan ja haali pisteet yöpymisistä, aterioista ja muusta, niin pääset nauttimaan palkintoyöstä tuossa tuokiossa. ",
"description": "Saat 25 % tai 50 % enemmän pisteitä joka kerta kun ansaitset pisteitä!.",
"unlocked": true,
"value": "50%"
"value": "50 %"
},
{
"name": "Aikainen sisäänkirjautuminen varaustilanteen mukaan",
"description": "Ota varaslähtö yöpymiseen! Kirjaudu sisään tuntia aiemmin ilman lisämaksua ja ota oikotie rentoutumiseen.",
"name": "Aikainen sisäänkirjautuminen",
"description": "Kirjaudu sisään tuntia aiemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": true
},
{
"name": "Maksuton huoneluokan korotus varaustilanteen mukaan",
"description": "Ystävyytemme on seuraavalla vaihteella ja nyt saat huoneluokan korotuksen ilman lisämaksua aina kun siihen on mahdollisuus. Luvassa siis entistä mukavampi majoitus!",
"name": "Maksuton huoneluokan korotus",
"description": "Saat huoneluokan korotuksen ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": true
},
{
"name": "Aamiainen kaksi yhden hinnalla",
"description": "Herkkuja kaksin kerroin! Nyt aamiaiselle kannattaa saapua kaverin kanssa, sillä saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et. Valmistaudu aamutreffeihin huolella tutustumalla lisätietoihin.",
"description": "Saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et.",
"unlocked": true
},
{
"name": "48tunnin huonetakuu",
"description": "Uniikki etu harvoille ja valituille eli sinulle! Ystävänäsi lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi. Aika taikuruutta, vai mitä?",
"description": "Lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi.",
"unlocked": true
},
{
"name": "Aamiainen aina maksutta",
"description": "Haluaisitko aloittaa aamusi herkullisesti? Tarjoamme sinulle valmiin hotelliaamiaisen maksutta vaikka joka aamu. Etu on käytettävissäsi riippumatta siitä, majoitutko meillä vai et.",
"description": "Tarjoamme sinulle hotelliaamiaisen maksutta, majoitut meillä tai et.",
"unlocked": true
},
{
"name": "Upea vuotuinen lahja",
"description": "Best Friend -ystävänämme ansaitset kuninkaallista kohtelua. Siksi palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella lahjalla. Mikä se on? No se on tietysti yllätys.",
"description": "Palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella yllätyslahjalla.",
"unlocked": false
},
{
"name": "Kids boost",
"description": "Ystävyytemme ulottuu myös mukana matkaaviin lapsiin. Muistamme pikkuväkeä pienellä lahjalla joka kerta kun majoitutte meillä. Pienet VIP-vieraat ovat sen arvoisia, vai mitä!",
"description": "Muistamme lapsia pienellä lahjalla majoituksen yhteydessä.",
"unlocked": false
}
]
@@ -460,76 +460,76 @@
{
"level": 7,
"name": "Best Friend",
"requirement": "400 000p tai 100 yötä",
"description": "Ystävyytemme on vailla vertaa. Koska sanat eivät riitä kiittämään ystävyydestämme, pääset nyt käsiksi kaikkein eksklusiivisimpiin elämyksiin.",
"requirement": "400 000 p tai 100 yötä",
"description": "Koska sanat eivät riitä kiittämään ystävyydestämme, pääset nyt käsiksi kaikkein eksklusiivisimpiin elämyksiin.",
"icon": "/_static/icons/loyaltylevels/best-friend.svg",
"benefits": [
{
"name": "Ystävähinnat",
"description": "Ystävänämme saat aina parhaan hinnan. Ei salaisia koodeja tai kädenpuristuksia sen kuin vain hyppäät varaamaan huoletta.",
"description": "Ystävänämme saat aina parhaan hinnan.",
"unlocked": true
},
{
"name": "Alennus ruoasta",
"description": "Maukas etu sinulle! Ystävänämme saat 10-15% alennusta ruoasta hotelliemme ravintoloissa ja shopissa. Tarjous on voimassa viikonloppuisin ja valittuina loma-aikoina, majoitut meillä tai et. Hemmottele siis itseäsi olet sen ansainnut!",
"description": "Ystävänämme saat 10-15 % alennusta ruoasta hotelliemme ravintoloissa ja shopissa viikonloppuisin ja valittuina loma-aikoina.",
"unlocked": true,
"value": "15%"
"value": "15 %"
},
{
"name": "Mocktail lapsille yöpymisen yhteydessä maksutta",
"description": "Lapset mukana matkassa? Meillä lapset ovat superstaroja ja siksi he saavat raikkaan mocktailin majoittumisen yhteydessä meidän piikkiin.",
"name": "Mocktail lapsille maksutta",
"description": "Meillä lapset saavat raikkaan mocktailin majoittumisen yhteydessä.",
"unlocked": true
},
{
"name": "Myöhäinen uloskirjautuminen varaustilanteen mukaan",
"description": "Joskus myöhäinen uloskirjautuminen pelastaa päivän. Nyt ystävyteemme on jo sillä tasolla, että sinulla ei ole kiire hypätä sängystä aamuvarhaisella. Kirjaudu ulos tuntia myöhemmin ilman lisämaksua ja ota kaikki irti extra-ajasta.",
"name": "Myöhäinen uloskirjautuminen",
"description": "Kirjaudu ulos tuntia myöhemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": true
},
{
"name": "Ravintolakuponki",
"description": "Parhaana ystävänämme saat 20 € ravintolakupongin jokaisesta pisteisiin oikeuttavasta yöstä. Illallinen hotellin ravintolassa tai kasa herkkuja huoneeseen mihin sinä sen käyttäisit?",
"description": "Ystävänämme saat ravintolakupongin jokaisesta pisteisiin oikeuttavasta yöstä.",
"unlocked": true,
"value": "20 €"
},
{
"name": "Enemmän pisteitä",
"description": "Tässä extra-boostia sinulle: saat 50 % enemmän pisteitä joka kerta kun ansaitset pisteitä! Pistä tuulemaan ja haali pisteet yöpymisistä, aterioista ja muusta, niin pääset nauttimaan palkintoyöstä tuossa tuokiossa. ",
"description": "Saat 25 % tai 50 % enemmän pisteitä joka kerta kun ansaitset pisteitä!.",
"unlocked": true,
"value": "50%"
"value": "50 %"
},
{
"name": "Aikainen sisäänkirjautuminen varaustilanteen mukaan",
"description": "Ota varaslähtö yöpymiseen! Kirjaudu sisään tuntia aiemmin ilman lisämaksua ja ota oikotie rentoutumiseen.",
"name": "Aikainen sisäänkirjautuminen",
"description": "Kirjaudu sisään tuntia aiemmin ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": true
},
{
"name": "Maksuton huoneluokan korotus varaustilanteen mukaan",
"description": "Ystävyytemme on seuraavalla vaihteella ja nyt saat huoneluokan korotuksen ilman lisämaksua aina kun siihen on mahdollisuus. Luvassa siis entistä mukavampi majoitus!",
"name": "Maksuton huoneluokan korotus",
"description": "Saat huoneluokan korotuksen ilman lisämaksua. Saatavilla varaustilanteen mukaan.",
"unlocked": true
},
{
"name": "Aamiainen kaksi yhden hinnalla",
"description": "Herkkuja kaksin kerroin! Nyt aamiaiselle kannattaa saapua kaverin kanssa, sillä saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et. Valmistaudu aamutreffeihin huolella tutustumalla lisätietoihin.",
"description": "Saat kaksi aamiaista yhden hinnalla, majoitut meillä tai et.",
"unlocked": true
},
{
"name": "48tunnin huonetakuu",
"description": "Uniikki etu harvoille ja valituille eli sinulle! Ystävänäsi lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi. Aika taikuruutta, vai mitä?",
"description": "Lupaamme sinulle huoneen, vaikka hotelli olisi täyteen buukattu, kunhan varaat sen vähintään 48 tuntia ennen saapumistasi.",
"unlocked": true
},
{
"name": "Aamiainen aina maksutta",
"description": "Haluaisitko aloittaa aamusi herkullisesti? Tarjoamme sinulle valmiin hotelliaamiaisen maksutta vaikka joka aamu. Etu on käytettävissäsi riippumatta siitä, majoitutko meillä vai et.",
"description": "Tarjoamme sinulle hotelliaamiaisen maksutta, majoitut meillä tai et.",
"unlocked": true
},
{
"name": "Upea vuotuinen lahja",
"description": "Best Friend -ystävänämme ansaitset kuninkaallista kohtelua. Siksi palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella lahjalla. Mikä se on? No se on tietysti yllätys.",
"description": "Palkitsemme sinut vuosittain mahtavalla ja eksklusiivisella yllätyslahjalla.",
"unlocked": true
},
{
"name": "Kids boost",
"description": "Ystävyytemme ulottuu myös mukana matkaaviin lapsiin. Muistamme pikkuväkeä pienellä lahjalla joka kerta kun majoitutte meillä. Pienet VIP-vieraat ovat sen arvoisia, vai mitä!",
"description": "Muistamme lapsia pienellä lahjalla majoituksen yhteydessä.",
"unlocked": true
}
]

View File

@@ -16,7 +16,7 @@
"name": "Rabatt på mat",
"description": "Nam! Nyt en smakfull 10 % rabatt i restauranten og shoppen vår i helgene. Dette tilbudet gjelder enten du er gjesten vår over natten eller bare kommer innom for en matbit. Så, sett i gang, unn deg selv noe godt.",
"unlocked": true,
"value": "10%"
"value": "10 %"
},
{
"name": "Gratis barne-mocktail under oppholdet",
@@ -34,7 +34,7 @@
"unlocked": false
},
{
"name": "Ekstra vennskap",
"name": "Friendsboost",
"description": "",
"unlocked": false
},
@@ -89,9 +89,9 @@
},
{
"name": "Rabatt på mat",
"description": "Hva er bedre enn en rabatt? Som vår venn får du 10-15% rabatt på mat i restauranten og shoppen vår i helger og utvalgte helligdager og det gjelder både når du har opphold hos oss og når du ikke har det. Så kom igjen, skjem deg selv bort.",
"description": "Hva er bedre enn rabatt? Som vår Friend får du 10-15 % rabatt på mat i vår restaurant og shop i helger og utvalgte ferier og helligdager. Det gjelder uansett om du bor hos oss eller ikke. Så kom igjen, skjem deg bort.",
"unlocked": true,
"value": "15%"
"value": "15 %"
},
{
"name": "Gratis barne-mocktail under oppholdet",
@@ -109,7 +109,7 @@
"unlocked": false
},
{
"name": "Ekstra vennskap",
"name": "Friendsboost",
"description": "",
"unlocked": false
},
@@ -164,9 +164,9 @@
},
{
"name": "Rabatt på mat",
"description": "Hva er bedre enn en rabatt? Som vår venn får du 10-15% rabatt på mat i restauranten og shoppen vår i helger og på utvalgte helligdager og det gjelder både når du bor hos oss og når du ikke har det. Så kom igjen, skjem deg selv bort.",
"description": "Hva er bedre enn en rabatt? Som vår venn får du 10-15 % rabatt på mat i restauranten og shoppen vår i helger og på utvalgte helligdager og det gjelder både når du bor hos oss og når du ikke har det. Så kom igjen, skjem deg selv bort.",
"unlocked": true,
"value": "15%"
"value": "15 %"
},
{
"name": "Gratis barne-mocktail under oppholdet",
@@ -185,7 +185,7 @@
"value": "50 NOK"
},
{
"name": "Ekstra vennskap",
"name": "Friendsboost",
"description": "",
"unlocked": false
},
@@ -240,9 +240,9 @@
},
{
"name": "Rabatt på mat",
"description": "Hva er bedre enn en rabatt? Som vår venn får du 10-15% rabatt på mat i restauranten og shoppen vår i helger og utvalgte helligdager og det gjelder både når du har opphold hos oss og når du ikke har det. Så kom igjen, skjem deg selv bort.",
"description": "Hva er bedre enn rabatt? Som vår Friend får du 10-15 % rabatt på mat i vår restaurant og shop i helger og utvalgte ferier og helligdager. Det gjelder uansett om du bor hos oss eller ikke. Så kom igjen, skjem deg bort.",
"unlocked": true,
"value": "15%"
"value": "15 %"
},
{
"name": "Gratis barne-mocktail under oppholdet",
@@ -261,10 +261,10 @@
"value": "75 NOK"
},
{
"name": "Ekstra vennskap",
"name": "Friendsboost",
"description": "Her har du noe veldig bra: Hver gang du øker antall vennskapspoeng, får du 25 % ekstra ekstra på det ekstra! Så, begynn å samle poeng på opphold, måltider og mer, og du vil veldig snart få et gratis opphold.",
"unlocked": true,
"value": "25%"
"value": "25 %"
},
{
"name": "Tidlig innsjekk når tilgjengelig",
@@ -317,9 +317,9 @@
},
{
"name": "Rabatt på mat",
"description": "Hva er bedre enn en rabatt? Som vår venn får du 10-15% rabatt på mat i restauranten og shoppen vår i helger og utvalgte helligdager og det gjelder både når du har opphold hos oss og når du ikke har det. Så kom igjen, skjem deg selv bort.",
"description": "Hva er bedre enn rabatt? Som vår Friend får du 10-15 % rabatt på mat i vår restaurant og shop i helger og utvalgte ferier og helligdager. Det gjelder uansett om du bor hos oss eller ikke. Så kom igjen, skjem deg bort.",
"unlocked": true,
"value": "15%"
"value": "15 %"
},
{
"name": "Gratis barne-mocktail under oppholdet",
@@ -338,10 +338,10 @@
"value": "100 NOK"
},
{
"name": "Ekstra vennskap",
"name": "Friendsboost",
"description": "Her har du noe veldig bra: Hver gang du øker antall vennskapspoeng, får du 25 % ekstra ekstra på det ekstra! Så, begynn å samle poeng på opphold, måltider og mer, og du vil veldig snart få et gratis opphold.",
"unlocked": true,
"value": "25%"
"value": "25 %"
},
{
"name": "Tidlig innsjekk når tilgjengelig",
@@ -394,9 +394,9 @@
},
{
"name": "Rabatt på mat",
"description": "Hva er bedre enn en rabatt? Som vår venn får du 10-15% rabatt på mat i restauranten og shoppen vår i helger og utvalgte helligdager og det gjelder både når du har opphold hos oss og når du ikke har det. Så kom igjen, skjem deg selv bort.",
"description": "Hva er bedre enn rabatt? Som vår Friend får du 10-15 % rabatt på mat i vår restaurant og shop i helger og utvalgte ferier og helligdager. Det gjelder uansett om du bor hos oss eller ikke. Så kom igjen, skjem deg bort.",
"unlocked": true,
"value": "15%"
"value": "15 %"
},
{
"name": "Gratis barne-mocktail under oppholdet",
@@ -415,10 +415,10 @@
"value": "150 NOK"
},
{
"name": "Ekstra vennskap",
"description": "Du kan virkelig glede deg. Hver gang du øker antall vennskapspoeng, får du 50 % ekstra ekstra på det ekstra! Så, få flere poeng på opphold, måltider og mer, og du vil få et gratis opphold lynraskt",
"name": "Friendsboost",
"description": "Gled deg! Hver gang du tjener nye Friends-poeng får du 25 % eller 50 % ekstra poeng som en superboost! Begynn å tjene poeng ved å bo og spise hos oss, og du vil få en bonusnatt før du aner det.",
"unlocked": true,
"value": "50%"
"value": "50 %"
},
{
"name": "Tidlig innsjekk når tilgjengelig",
@@ -471,9 +471,9 @@
},
{
"name": "Rabatt på mat",
"description": "Hva er bedre enn en rabatt? Som vår venn får du 10-15% rabatt på mat i restauranten og shoppen vår i helger og utvalgte helligdager og det gjelder både når du har opphold hos oss og når du ikke har det. Så kom igjen, skjem deg selv bort.",
"description": "Hva er bedre enn rabatt? Som vår Friend får du 10-15 % rabatt på mat i vår restaurant og shop i helger og utvalgte ferier og helligdager. Det gjelder uansett om du bor hos oss eller ikke. Så kom igjen, skjem deg bort.",
"unlocked": true,
"value": "15%"
"value": "15 %"
},
{
"name": "Gratis barne-mocktail under oppholdet",
@@ -492,10 +492,10 @@
"value": "200 NOK"
},
{
"name": "Ekstra vennskap",
"description": "Du kan virkelig glede deg. Hver gang du øker antall vennskapspoeng, får du 50 % ekstra ekstra på det ekstra! Så, få flere poeng på opphold, måltider og mer, og du vil få et gratis opphold lynraskt",
"name": "Friendsboost",
"description": "Gled deg! Hver gang du tjener nye Friends-poeng får du 25 % eller 50 % ekstra poeng som en superboost! Begynn å tjene poeng ved å bo og spise hos oss, og du vil få en bonusnatt før du aner det.",
"unlocked": true,
"value": "50%"
"value": "50 %"
},
{
"name": "Tidlig innsjekk når tilgjengelig",

View File

@@ -0,0 +1,13 @@
import { serverClient } from "@/lib/trpc/server"
import MyPagesSidebar from "@/components/MyPages/Sidebar"
export async function MyPagesNavigation() {
const user = await serverClient().user.name()
// Check if we have user, that means we are logged in andt the My Pages menu can show.
if (!user) {
return null
}
return <MyPagesSidebar />
}

View File

@@ -1,7 +1,7 @@
import JsonToHtml from "@/components/JsonToHtml"
import SidebarMyPages from "@/components/MyPages/Sidebar"
import JoinLoyaltyContact from "./JoinLoyalty"
import { MyPagesNavigation } from "./MyPagesNavigation"
import styles from "./sidebar.module.css"
@@ -38,7 +38,7 @@ export default function SidebarLoyalty({ blocks }: SidebarProps) {
case SidebarTypenameEnum.LoyaltyPageSidebarDynamicContent:
switch (block.dynamic_content.component) {
case LoyaltySidebarDynamicComponentEnum.my_pages_navigation:
return <SidebarMyPages key={`${block.__typename}-${idx}`} />
return <MyPagesNavigation key={`${block.__typename}-${idx}`} />
default:
return null
}

View File

@@ -31,7 +31,7 @@ export default async function Friend({
{formatMessage(
isHighestLevel
? { id: "Highest level" }
: { id: "Your current level" }
: { id: `Level ${membershipLevels[membership.membershipLevel]}` }
)}
</Body>
{membership ? (

View File

@@ -7,9 +7,8 @@ import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import DesktopTable from "./Desktop"
import MobileTable from "./Mobile"
import Pagination from "./Pagination"
import Table from "./Table"
import { Transactions } from "@/types/components/myPages/myPage/earnAndBurn"
@@ -40,8 +39,7 @@ export default function TransactionTable({
<LoadingSpinner />
) : (
<>
<MobileTable transactions={data?.data.transactions || []} />
<DesktopTable transactions={data?.data.transactions || []} />
<Table transactions={data?.data.transactions || []} />
{data && data.meta.totalPages > 1 ? (
<Pagination
handlePageChange={setPage}

View File

@@ -1,26 +0,0 @@
import { Lang } from "@/constants/languages"
import { awardPointsVariants } from "./awardPointsVariants"
import type {
AwardPointsProps,
AwardPointsVariantProps,
} from "@/types/components/myPages/myPage/earnAndBurn"
export default function AwardPoints({ awardPoints }: AwardPointsProps) {
let variant: AwardPointsVariantProps["variant"] = undefined
if (awardPoints > 0) {
variant = "addition"
} else if (awardPoints < 0) {
variant = "negation"
awardPoints = Math.abs(awardPoints)
}
const classNames = awardPointsVariants({
variant,
})
// sv hardcoded to force space on thousands
const formatter = new Intl.NumberFormat(Lang.sv)
return <td className={classNames}>{formatter.format(awardPoints)} pts</td>
}

View File

@@ -1,35 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import useLang from "@/hooks/useLang"
import AwardPoints from "./AwardPoints"
import styles from "./row.module.css"
import type { RowProps } from "@/types/components/myPages/myPage/earnAndBurn"
export default function Row({ transaction }: RowProps) {
const intl = useIntl()
const lang = useLang()
const description =
transaction.hotelName && transaction.city
? `${transaction.hotelName}, ${transaction.city} ${transaction.nights} ${intl.formatMessage({ id: "nights" })}`
: `${transaction.nights} ${intl.formatMessage({ id: "nights" })}`
const arrival = dt(transaction.checkinDate).locale(lang).format("DD MMM YYYY")
const departure = dt(transaction.checkoutDate)
.locale(lang)
.format("DD MMM YYYY")
return (
<tr className={styles.tr}>
<td className={styles.td}>{arrival}</td>
<td className={styles.td}>{description}</td>
<td className={styles.td}>{transaction.confirmationNumber}</td>
<td className={styles.td}>{departure}</td>
<AwardPoints awardPoints={transaction.awardPoints} />
</tr>
)
}

View File

@@ -1,69 +0,0 @@
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import AwardPoints from "@/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Desktop/Row/AwardPoints"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
import styles from "./mobile.module.css"
import type { TableProps } from "@/types/components/myPages/myPage/earnAndBurn"
export default function MobileTable({ transactions }: TableProps) {
const intl = useIntl()
const lang = useLang()
return (
<div className={styles.container}>
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
<Body asChild>
<th className={styles.th}>
{intl.formatMessage({ id: "Transactions" })}
</th>
</Body>
<Body asChild>
<th className={styles.th}>
{intl.formatMessage({ id: "Points" })}
</th>
</Body>
</tr>
</thead>
<tbody>
{transactions.length ? (
transactions.map((transaction, idx) => (
<tr
className={styles.tr}
key={`${transaction.confirmationNumber}-${idx}`}
>
<td className={`${styles.td} ${styles.transactionDetails}`}>
<span className={styles.transactionDate}>
{dt(transaction.checkinDate)
.locale(lang)
.format("DD MMM YYYY")}
</span>
{transaction.hotelName && transaction.city ? (
<span>{`${transaction.hotelName}, ${transaction.city}`}</span>
) : null}
<span>
{`${transaction.nights} ${intl.formatMessage({ id: transaction.nights === 1 ? "night" : "nights" })}`}
</span>
</td>
<AwardPoints awardPoints={transaction.awardPoints} />
</tr>
))
) : (
<tr>
<td className={styles.placeholder} colSpan={2}>
{intl.formatMessage({
id: "There are no transactions to display",
})}
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}

View File

@@ -1,52 +0,0 @@
.table {
border-collapse: collapse;
border-spacing: 0;
width: 100%;
}
.thead {
background-color: var(--Main-Grey-10);
}
.th {
padding: var(--Spacing-x2);
}
.tr {
border-top: 1px solid var(--Main-Grey-10);
}
.td {
padding: var(--Spacing-x2);
}
.transactionDetails {
display: grid;
font-size: var(--typography-Footnote-Regular-fontSize);
}
.transactionDate {
font-weight: 700;
}
.placeholder {
text-align: center;
padding: var(--Spacing-x4);
border: 1px solid var(--Main-Grey-10);
}
.loadMoreButton {
background-color: var(--Main-Grey-10);
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: var(--Spacing-x-half);
padding: var(--Spacing-x2);
width: 100%;
}
@media screen and (min-width: 768px) {
.container {
display: none;
}
}

View File

@@ -0,0 +1,40 @@
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { awardPointsVariants } from "./awardPointsVariants"
import type { AwardPointsVariantProps } from "@/types/components/myPages/myPage/earnAndBurn"
export default function AwardPoints({
awardPoints,
isCalculated,
}: {
awardPoints: number
isCalculated: boolean
}) {
let variant: AwardPointsVariantProps["variant"] = undefined
const intl = useIntl()
if (isCalculated) {
if (awardPoints > 0) {
variant = "addition"
} else if (awardPoints < 0) {
variant = "negation"
awardPoints = Math.abs(awardPoints)
}
}
const classNames = awardPointsVariants({
variant,
})
// sv hardcoded to force space on thousands
const formatter = new Intl.NumberFormat(Lang.sv)
return (
<td className={classNames}>
{isCalculated
? formatter.format(awardPoints)
: intl.formatMessage({ id: "Points being calculated" })}
</td>
)
}

View File

@@ -0,0 +1,82 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import AwardPoints from "./AwardPoints"
import styles from "./row.module.css"
import type { RowProps } from "@/types/components/myPages/myPage/earnAndBurn"
import { RewardTransactionTypes } from "@/types/components/myPages/myPage/enums"
export default function Row({ transaction }: RowProps) {
const intl = useIntl()
const lang = useLang()
const nightString = `${transaction.nights} ${transaction.nights === 1 ? intl.formatMessage({ id: "night" }) : intl.formatMessage({ id: "nights" })}`
let description =
transaction.hotelName && transaction.city
? `${transaction.hotelName}, ${transaction.city} ${nightString}`
: `${nightString}`
switch (transaction.type) {
case RewardTransactionTypes.stay:
if (transaction.hotelId === "ORS")
description = intl.formatMessage({ id: "Former Scandic Hotel" })
break
case RewardTransactionTypes.ancillary:
description = intl.formatMessage({ id: "Extras to your booking" })
break
case RewardTransactionTypes.enrollment:
description = intl.formatMessage({ id: "Sign up bonus" })
break
case RewardTransactionTypes.mastercard_points:
description = intl.formatMessage({ id: "Scandic Friends Mastercard" })
break
case RewardTransactionTypes.tui_points:
description = intl.formatMessage({ id: "TUI Points" })
case RewardTransactionTypes.stayAdj:
if (transaction.confirmationNumber === "BALFWD")
description = intl.formatMessage({
id: "Points earned prior to May 1, 2021",
})
break
case RewardTransactionTypes.pointShop:
description = intl.formatMessage({ id: "Scandic Friends Point Shop" })
break
}
const arrival = dt(transaction.checkinDate).locale(lang).format("DD MMM YYYY")
const transactionDate = dt(transaction.transactionDate)
.locale(lang)
.format("DD MMM YYYY")
return (
<tr className={styles.tr}>
<AwardPoints
awardPoints={transaction.awardPoints}
isCalculated={transaction.pointsCalculated}
/>
<td className={`${styles.td} ${styles.description}`}>{description}</td>
<td className={styles.td}>
{transaction.type === RewardTransactionTypes.stay &&
transaction.bookingUrl ? (
<Link variant="underscored" href={transaction.bookingUrl}>
{transaction.confirmationNumber}
</Link>
) : (
transaction.confirmationNumber
)}
</td>
<td className={styles.td}>
{transaction.checkinDate ? arrival : transactionDate}
</td>
</tr>
)
}

View File

@@ -1,13 +1,21 @@
.tr {
border: 1px solid #e6e9ec;
border-bottom: 1px solid var(--Scandic-Brand-Pale-Peach);
&:last-child {
border-bottom: none;
}
}
.td {
background-color: #fff;
color: var(--UI-Text-High-contrast);
padding: var(--Spacing-x2) var(--Spacing-x4);
padding: var(--Spacing-x2);
position: relative;
text-align: left;
text-wrap: nowrap;
}
.description {
font-weight: var(--typography-Body-Bold-fontWeight);
}
.addition {
@@ -17,8 +25,7 @@
.addition::before {
color: var(--Secondary-Light-On-Surface-Accent);
content: "+";
left: var(--Spacing-x2);
position: absolute;
margin-right: var(--Spacing-x-half);
}
.negation {
@@ -28,6 +35,11 @@
.negation::before {
color: var(--Base-Text-Accent);
content: "-";
left: var(--Spacing-x2);
position: absolute;
margin-right: var(--Spacing-x-half);
}
@media screen and (min-width: 768px) {
.td {
padding: var(--Spacing-x3);
}
}

View File

@@ -1,50 +1,48 @@
"use client"
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Row from "./Row"
import styles from "./desktop.module.css"
import styles from "./table.module.css"
import type { TableProps } from "@/types/components/myPages/myPage/earnAndBurn"
const tableHeadings = [
"Arrival date",
"Points",
"Description",
"Booking number",
"Transaction date",
"Points",
"Arrival date",
]
export default function DesktopTable({ transactions }: TableProps) {
export default function Table({ transactions }: TableProps) {
const intl = useIntl()
return (
<div className={styles.container}>
{transactions.length ? (
<div>
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
{tableHeadings.map((heading) => (
<th key={heading} className={styles.th}>
<Body textTransform="bold">
{intl.formatMessage({ id: heading })}
</Body>
</th>
))}
</tr>
</thead>
<tbody>
{transactions.map((transaction, idx) => (
<Row
key={`${transaction.confirmationNumber}-${idx}`}
transaction={transaction}
/>
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
{tableHeadings.map((heading) => (
<th key={heading} className={styles.th}>
<Body textTransform="bold">
{intl.formatMessage({ id: heading })}
</Body>
</th>
))}
</tbody>
</table>
</div>
</tr>
</thead>
<tbody>
{transactions.map((transaction, index) => (
<Row
key={`${transaction.confirmationNumber}-${index}`}
transaction={transaction}
/>
))}
</tbody>
</table>
) : (
<table className={styles.table}>
<thead className={styles.thead}>

View File

@@ -1,5 +1,8 @@
.container {
display: none;
display: flex;
flex-direction: column;
overflow-x: auto;
border-radius: var(--Corner-radius-Small);
}
.table {
@@ -17,7 +20,8 @@
.th {
text-align: left;
padding: 20px 32px;
text-wrap: nowrap;
padding: var(--Spacing-x2);
}
.placeholder {
@@ -49,9 +53,10 @@
}
@media screen and (min-width: 768px) {
.container {
display: flex;
flex-direction: column;
gap: 16px;
overflow-x: auto;
border-radius: var(--Corner-radius-Large);
}
.th {
padding: var(--Spacing-x2) var(--Spacing-x3);
}
}

View File

@@ -11,6 +11,7 @@ export default async function Breadcrumbs() {
if (!breadcrumbs?.length) {
return null
}
const homeBreadcrumb = breadcrumbs.shift()
return (
<nav className={styles.breadcrumbs}>

View File

@@ -1,64 +1,81 @@
"use client"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { useEffect, useRef } from "react"
import { useIntl } from "react-intl"
import { toast } from "sonner"
import { trpc } from "@/lib/trpc/client"
import { PlusCircleIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import styles from "./addCreditCardButton.module.css"
import { type AddCreditCardButtonProps } from "@/types/components/myPages/myProfile/addCreditCardButton"
let hasRunOnce = false
function useAddCardResultToast() {
const hasRunOnce = useRef(false)
const intl = useIntl()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (hasRunOnce) return
if (hasRunOnce.current) return
const success = searchParams.get("success")
const failure = searchParams.get("failure")
const cancel = searchParams.get("cancel")
const error = searchParams.get("error")
if (success) {
// setTimeout is used to make sure DOM is loaded before triggering toast. See documentation for more info: https://sonner.emilkowal.ski/toast#render-toast-on-page-load
setTimeout(() => {
toast.success(
intl.formatMessage({ id: "Your card was successfully saved!" })
)
})
} else if (failure) {
setTimeout(() => {
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
})
toast.success(
intl.formatMessage({ id: "Your card was successfully saved!" })
)
} else if (cancel) {
toast.warning(
intl.formatMessage({
id: "You canceled adding a new credit card.",
})
)
} else if (failure || error) {
toast.error(
intl.formatMessage({
id: "Something went wrong and we couldn't add your card. Please try again later.",
})
)
}
router.replace(pathname)
hasRunOnce = true
hasRunOnce.current = true
}, [intl, pathname, router, searchParams])
}
export default function AddCreditCardButton({
redirectUrl,
}: AddCreditCardButtonProps) {
export default function AddCreditCardButton() {
const intl = useIntl()
const router = useRouter()
const lang = useLang()
useAddCardResultToast()
const initiateAddCard = trpc.user.initiateSaveCard.useMutation({
onSuccess: (result) => (result ? router.push(result.attribute.link) : null),
onError: () =>
toast.error(intl.formatMessage({ id: "Something went wrong!" })),
const initiateAddCard = trpc.user.creditCard.add.useMutation({
onSuccess: (result) => {
if (result?.attribute.link) {
router.push(result.attribute.link)
} else {
toast.error(
intl.formatMessage({
id: "We could not add a card right now, please try again later.",
})
)
}
},
onError: () => {
toast.error(
intl.formatMessage({
id: "An error occurred when adding a credit card, please try again later.",
})
)
},
})
return (
@@ -70,8 +87,6 @@ export default function AddCreditCardButton({
onClick={() =>
initiateAddCard.mutate({
language: lang,
mobileToken: false,
redirectUrl,
})
}
wrapping

View File

@@ -0,0 +1,4 @@
.cardContainer {
display: grid;
gap: var(--Spacing-x1);
}

View File

@@ -0,0 +1,31 @@
"use client"
import React from "react"
import { trpc } from "@/lib/trpc/client"
import CreditCardRow from "../CreditCardRow"
import styles from "./CreditCardList.module.css"
import type { CreditCard } from "@/types/user"
export default function CreditCardList({
initialData,
}: {
initialData?: CreditCard[] | null
}) {
const creditCards = trpc.user.creditCards.useQuery(undefined, { initialData })
if (!creditCards.data || !creditCards.data.length) {
return null
}
return (
<div className={styles.cardContainer}>
{creditCards.data.map((card) => (
<CreditCardRow key={card.id} card={card} />
))}
</div>
)
}

View File

@@ -0,0 +1,10 @@
.card {
display: grid;
align-items: center;
column-gap: var(--Spacing-x1);
grid-template-columns: auto auto auto 1fr;
justify-items: flex-end;
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half,);
border-radius: var(--Corner-radius-Small);
background-color: var(--Base-Background-Primary-Normal);
}

View File

@@ -0,0 +1,22 @@
import { CreditCard } from "@/components/Icons"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import DeleteCreditCardConfirmation from "../DeleteCreditCardConfirmation"
import styles from "./creditCardRow.module.css"
import type { CreditCardRowProps } from "@/types/components/myPages/myProfile/creditCards"
export default function CreditCardRow({ card }: CreditCardRowProps) {
const maskedCardNumber = `**** ${card.truncatedNumber.slice(-4)}`
return (
<div className={styles.card}>
<CreditCard color="black" />
<Body textTransform="bold">{card.type}</Body>
<Caption color="textMediumContrast">{maskedCardNumber}</Caption>
<DeleteCreditCardConfirmation card={card} />
</div>
)
}

View File

@@ -0,0 +1,40 @@
"use client"
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { Delete } from "@/components/Icons"
import LoadingSpinner from "@/components/LoadingSpinner"
import Button from "@/components/TempDesignSystem/Button"
import { toast } from "@/components/TempDesignSystem/Toasts"
export default function DeleteCreditCardButton({
creditCardId,
}: {
creditCardId: string
}) {
const { formatMessage } = useIntl()
const trpcUtils = trpc.useUtils()
const deleteCreditCardMutation = trpc.user.creditCard.delete.useMutation({
onSuccess() {
trpcUtils.user.creditCards.invalidate()
toast.success(formatMessage({ id: "Credit card deleted successfully" }))
},
onError() {
toast.error(
formatMessage({
id: "Failed to delete credit card, please try again later.",
})
)
},
})
async function handleDelete() {
deleteCreditCardMutation.mutate({ creditCardId })
}
return (
<Button variant="icon" theme="base" intent="text" onClick={handleDelete}>
<Delete color="burgundy" />
</Button>
)
}

View File

@@ -0,0 +1,70 @@
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: var(--visual-viewport-height);
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
&[data-entering] {
animation: modal-fade 200ms;
}
&[data-exiting] {
animation: modal-fade 150ms reverse ease-in;
}
}
.modal section {
background: var(--Main-Grey-White);
border-radius: var(--Corner-radius-Medium);
padding: var(--Spacing-x4);
padding-bottom: var(--Spacing-x6);
}
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
font-family: var(--typography-Body-Regular-fontFamily);
}
.title {
font-family: var(--typography-Subtitle-1-fontFamily);
text-align: center;
margin: 0;
padding-bottom: var(--Spacing-x1);
}
.bodyText {
text-align: center;
max-width: 425px;
margin: 0;
padding: 0;
}
.buttonContainer {
display: flex;
justify-content: space-between;
gap: var(--Spacing-x2);
flex-wrap: wrap;
}
.buttonContainer button {
flex-grow: 1;
justify-content: center;
}
@keyframes modal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@@ -0,0 +1,99 @@
"use client"
import {
Dialog,
DialogTrigger,
Heading,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { Delete } from "@/components/Icons"
import LoadingSpinner from "@/components/LoadingSpinner"
import Button from "@/components/TempDesignSystem/Button"
import { toast } from "@/components/TempDesignSystem/Toasts"
import styles from "./deleteCreditCardConfirmation.module.css"
import type { DeleteCreditCardConfirmationProps } from "@/types/components/myPages/myProfile/creditCards"
export default function DeleteCreditCardConfirmation({
card,
}: DeleteCreditCardConfirmationProps) {
const intl = useIntl()
const trpcUtils = trpc.useUtils()
const deleteCard = trpc.user.creditCard.delete.useMutation({
onSuccess() {
trpcUtils.user.creditCards.invalidate()
toast.success(
intl.formatMessage({ id: "Your card was successfully removed!" })
)
},
onError() {
toast.error(
intl.formatMessage({
id: "Something went wrong and we couldn't remove your card. Please try again later.",
})
)
},
})
const lastFourDigits = card.truncatedNumber.slice(-4)
return (
<div>
<DialogTrigger>
<Button variant="icon" theme="base" intent="text">
<Delete color="burgundy" />
</Button>
<ModalOverlay className={styles.overlay} isDismissable>
<Modal className={styles.modal}>
<Dialog role="alertdialog">
{({ close }) => (
<div className={styles.container}>
<Heading slot="title" className={styles.title}>
{intl.formatMessage({
id: "Remove card from member profile",
})}
</Heading>
<p className={styles.bodyText}>
{`${intl.formatMessage({
id: "Are you sure you want to remove the card ending with",
})} ${lastFourDigits} ${intl.formatMessage({ id: "from your member profile?" })}`}
</p>
{deleteCard.isPending ? (
<LoadingSpinner />
) : (
<div className={styles.buttonContainer}>
<Button intent="secondary" theme="base" onClick={close}>
{intl.formatMessage({ id: "No, keep card" })}
</Button>
<Button
intent="primary"
theme="base"
onClick={() => {
deleteCard.mutate(
{ creditCardId: card.id },
{ onSettled: close }
)
}}
>
{intl.formatMessage({ id: "Yes, remove my card" })}
</Button>
</div>
)}
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
</div>
)
}

View File

@@ -1,9 +1,20 @@
import { buttonVariants } from "./variants"
import type { VariantProps } from "class-variance-authority"
import type { ButtonProps as ReactAriaButtonProps } from "react-aria-components"
export interface ButtonProps
export interface ButtonPropsRAC
extends Omit<ReactAriaButtonProps, "isDisabled">,
VariantProps<typeof buttonVariants> {
asChild?: false | undefined | never
disabled?: ReactAriaButtonProps["isDisabled"]
onClick?: ReactAriaButtonProps["onPress"]
}
export interface ButtonPropsSlot
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
asChild: true
}
export type ButtonProps = ButtonPropsSlot | ButtonPropsRAC

View File

@@ -1,23 +1,16 @@
"use client"
import { Slot } from "@radix-ui/react-slot"
import { Button as ButtonRAC } from "react-aria-components"
import { buttonVariants } from "./variants"
import type { ButtonProps } from "./button"
export default function Button({
asChild = false,
theme,
className,
disabled,
intent,
size,
variant,
wrapping,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button"
export default function Button(props: ButtonProps) {
const { className, intent, size, theme, wrapping, variant, ...restProps } =
props
const classNames = buttonVariants({
className,
intent,
@@ -26,5 +19,19 @@ export default function Button({
wrapping,
variant,
})
return <Comp className={classNames} disabled={disabled} {...props} />
if (restProps.asChild) {
const { asChild, ...slotProps } = restProps
return <Slot className={classNames} {...slotProps} />
}
const { asChild, onClick, disabled, ...racProps } = restProps
return (
<ButtonRAC
className={classNames}
isDisabled={disabled}
onPress={onClick}
{...racProps}
/>
)
}

View File

@@ -2,7 +2,7 @@ import { loyaltyCardVariants } from "./variants"
import type { VariantProps } from "class-variance-authority"
import { ImageVaultAsset } from "@/types/components/imageVaultImage"
import { ImageVaultAsset } from "@/types/components/imageVault"
export interface LoyaltyCardProps
extends React.HTMLAttributes<HTMLDivElement>,

View File

@@ -16,7 +16,7 @@ import { toastVariants } from "./variants"
import styles from "./toasts.module.css"
export function ToastHandler() {
return <Toaster />
return <Toaster position="bottom-right" />
}
function getIcon(variant: ToastsProps["variant"]) {

View File

@@ -31,8 +31,9 @@
.iconContainer {
display: flex;
background-color: var(--icon-background-color);
padding: var(--Spacing-x2);
align-items: center;
justify-content: center;
background-color: var(--icon-background-color);
padding: var(--Spacing-x2);
height: 100%;
}

View File

@@ -12,6 +12,16 @@ export const login = {
sv: "/sv/logga-in",
}
/** @type {import('@/types/routes').LangRoute} */
export const loginUnLocalized = {
da: "/da/login",
de: "/de/login",
en: "/en/login",
fi: "/fi/login",
no: "/no/login",
sv: "/sv/login",
}
/** @type {import('@/types/routes').LangRoute} */
export const logout = {
da: "/da/log-ud",
@@ -22,6 +32,16 @@ export const logout = {
sv: "/sv/logga-ut",
}
/** @type {import('@/types/routes').LangRoute} */
export const logoutUnLocalized = {
da: "/da/logout",
de: "/de/logout",
en: "/en/logout",
fi: "/fi/logout",
no: "/no/logout",
sv: "/sv/logout",
}
/** @type {import('@/types/routes').LangRoute} */
export const verifymagiclink = {
da: "/da/verifymagiclink",
@@ -36,4 +56,6 @@ export const handleAuth = [
...Object.values(login),
...Object.values(logout),
...Object.values(verifymagiclink),
...Object.values(loginUnLocalized),
...Object.values(logoutUnLocalized),
]

View File

@@ -7,6 +7,8 @@
"All rooms comes with standard amenities": "Alle værelser er udstyret med standardfaciliteter",
"Already a friend?": "Allerede en ven?",
"Amenities": "Faciliteter",
"An error occurred when adding a credit card, please try again later.": "Der opstod en fejl under tilføjelse af et kreditkort. Prøv venligst igen senere.",
"Are you sure you want to remove the card ending with": "Er du sikker på, at du vil fjerne kortet, der slutter med",
"Arrival date": "Ankomstdato",
"as of today": "fra idag",
"As our": "Som vores",
@@ -31,6 +33,7 @@
"Could not find requested resource": "Kunne ikke finde den anmodede ressource",
"Country": "Land",
"Country code": "Landekode",
"Credit card deleted successfully": "Kreditkort blev slettet",
"Your current level": "Dit nuværende niveau",
"Current password": "Nuværende kodeord",
"characters": "tegn",
@@ -42,13 +45,24 @@
"Edit": "Redigere",
"Edit profile": "Rediger profil",
"Email": "E-mail",
"Extras to your booking": "Ekstra til din booking",
"There are no transactions to display": "Der er ingen transaktioner at vise",
"Explore all levels and benefits": "Udforsk alle niveauer og fordele",
"Failed to delete credit card, please try again later.": "Kunne ikke slette kreditkort. Prøv venligst igen senere.",
"Find booking": "Find booking",
"Flexibility": "Fleksibilitet",
"Former Scandic Hotel": "Tidligere Scandic Hotel",
"From": "Fra",
"from your member profile?": "fra din medlemsprofil?",
"Get inspired": "Bliv inspireret",
"Go back to overview": "Gå tilbage til oversigten",
"Level 1": "Niveau 1",
"Level 2": "Niveau 2",
"Level 3": "Niveau 3",
"Level 4": "Niveau 4",
"Level 5": "Niveau 5",
"Level 6": "Niveau 6",
"Level 7": "Niveau 7",
"Highest level": "Højeste niveau",
"How do you want to sleep?": "Hvordan vil du sove?",
"How it works": "Hvordan det virker",
@@ -73,6 +87,7 @@
"Next": "Næste",
"next level:": "Næste niveau:",
"No content published": "Intet indhold offentliggjort",
"No, keep card": "Nej, behold kortet",
"No transactions available": "Ingen tilgængelige transaktioner",
"Not found": "Ikke fundet",
"night": "nat",
@@ -88,7 +103,9 @@
"Phone is required": "Telefonnummer er påkrævet",
"Phone number": "Telefonnummer",
"Please enter a valid phone number": "Indtast venligst et gyldigt telefonnummer",
"Points": "Point",
"Points": "Points",
"Points being calculated": "Point udregnes",
"Points earned prior to May 1, 2021": "Point optjent før 1. maj 2021",
"Points may take up to 10 days to be displayed.": "Det kan tage op til 10 dage at få vist point.",
"Points needed to level up": "Point nødvendige for at komme i niveau",
"Points needed to stay on level": "Point nødvendige for at holde sig på niveau",
@@ -96,10 +113,13 @@
"Previous victories": "Tidligere sejre",
"Read more": "Læs mere",
"Read more about the hotel": "Læs mere om hotellet",
"Remove card from member profile": "Fjern kortet fra medlemsprofilen",
"Restaurant & Bar": "Restaurant & Bar",
"Retype new password": "Gentag den nye adgangskode",
"Rooms": "Værelser",
"Save": "Gemme",
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Select a country": "Vælg et land",
"Select country of residence": "Vælg bopælsland",
"Select date of birth": "Vælg fødselsdato",
@@ -108,7 +128,10 @@
"Show more": "Vis mere",
"Show all amenities": "Vis alle faciliteter",
"Skip to main content": "Spring over og gå til hovedindhold",
"Sign up bonus": "Tilmeldingsbonus",
"Something went wrong!": "Noget gik galt!",
"Something went wrong and we couldn't add your card. Please try again later.": "Noget gik galt, og vi kunne ikke tilføje dit kort. Prøv venligst igen senere.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Noget gik galt, og vi kunne ikke fjerne dit kort. Prøv venligst igen senere.",
"Street": "Gade",
"special character": "speciel karakter",
"Total Points": "Samlet antal point",
@@ -117,17 +140,22 @@
"Transactions": "Transaktioner",
"Tripadvisor reviews": "{rating} ({count} anmeldelser på Tripadvisor)",
"to": "til",
"TUI Points": "TUI-point",
"User information": "Brugeroplysninger",
"uppercase letter": "stort bogstav",
"Visiting address": "Besøgsadresse",
"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.",
"Welcome": "Velkommen",
"Welcome to": "Velkommen til",
"Wellness & Exercise": "Velvære & Motion",
"Where should you go next?": "Find inspiration til dit næste ophold",
"Which room class suits you the best?": "Hvilken rumklasse passer bedst til dig",
"Year": "År",
"You canceled adding a new credit card.": "Du har annulleret tilføjelsen af et nyt kreditkort.",
"Yes, remove my card": "Ja, fjern mit kort",
"You have no previous stays.": "Du har ingen tidligere ophold.",
"You have no upcoming stays.": "Du har ingen kommende ophold.",
"Your card was successfully removed!": "Dit kort blev fjernet!",
"Your card was successfully saved!": "Dit kort blev gemt!",
"Your Challenges Conquer & Earn!": "Dine udfordringer Overvind og tjen!",
"Your level": "Dit niveau",
@@ -136,6 +164,19 @@
"Hotel facilities": "Hotel faciliteter",
"Hotel surroundings": "Hotel omgivelser",
"Show map": "Vis kort",
"Check in": "Check ind",
"Check out": "Check ud",
"Summary": "Opsummering",
"Thank you": "Tak",
"We look forward to your visit!": "Vi ser frem til dit besøg!",
"We have sent a detailed confirmation of your booking to your email:": "Vi har sendt en detaljeret bekræftelse af din booking til din email:",
"Download the Scandic app": "Download Scandic-appen",
"View your booking": "Se din booking",
"At latest": "Senest",
"Type of room": "Værelsestype",
"Type of bed": "Sengtype",
"Weekdays": "Hverdage",
"Weekends": "Weekender",
"Where to": "Hvorhen",
"When": "Hvornår",
"Rooms & Guests": "Værelser & gæster",

View File

@@ -6,8 +6,10 @@
"All rooms comes with standard amenities": "Alle Zimmer sind mit den üblichen Annehmlichkeiten ausgestattet",
"Already a friend?": "Sind wir schon Freunde?",
"Amenities": "Annehmlichkeiten",
"An error occurred when adding a credit card, please try again later.": "Beim Hinzufügen einer Kreditkarte ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.",
"Are you sure you want to remove the card ending with": "Möchten Sie die Karte mit der Endung",
"Arrival date": "Ankunftsdatum",
"as of today": "Ab heute",
"as of today": "Stand heute",
"As our": "Als unser",
"As our Close Friend": "Als unser enger Freund",
"At the hotel": "Im Hotel",
@@ -30,6 +32,7 @@
"Could not find requested resource": "Die angeforderte Ressource konnte nicht gefunden werden.",
"Country": "Land",
"Country code": "Landesvorwahl",
"Credit card deleted successfully": "Kreditkarte erfolgreich gelöscht",
"Your current level": "Ihr aktuelles Level",
"Current password": "Aktuelles Passwort",
"characters": "figuren",
@@ -41,13 +44,24 @@
"Edit": "Bearbeiten",
"Edit profile": "Profil bearbeiten",
"Email": "Email",
"Extras to your booking": "Extras zu Ihrer Buchung",
"There are no transactions to display": "Es sind keine Transaktionen zum Anzeigen vorhanden",
"Explore all levels and benefits": "Entdecken Sie alle Levels und Vorteile",
"Failed to delete credit card, please try again later.": "Kreditkarte konnte nicht gelöscht werden. Bitte versuchen Sie es später noch einmal.",
"Find booking": "Buchung finden",
"Flexibility": "Flexibilität",
"Former Scandic Hotel": "Ehemaliges Scandic Hotel",
"From": "Fromm",
"from your member profile?": "wirklich aus Ihrem Mitgliedsprofil entfernen?",
"Get inspired": "Lassen Sie sich inspieren",
"Go back to overview": "Zurück zur Übersicht",
"Level 1": "Level 1",
"Level 2": "Level 2",
"Level 3": "Level 3",
"Level 4": "Level 4",
"Level 5": "Level 5",
"Level 6": "Level 6",
"Level 7": "Level 7",
"Highest level": "Höchstes Level",
"How do you want to sleep?": "Wie möchtest du schlafen?",
"How it works": "Wie es funktioniert",
@@ -71,6 +85,7 @@
"Next": "Nächste",
"next level:": "Nächstes Level:",
"No content published": "Kein Inhalt veröffentlicht",
"No, keep card": "Nein, Karte behalten",
"No transactions available": "Keine Transaktionen verfügbar",
"Not found": "Nicht gefunden",
"night": "nacht",
@@ -86,15 +101,20 @@
"Phone number": "Telefonnummer",
"Please enter a valid phone number": "Bitte geben Sie eine gültige Telefonnummer ein",
"Points": "Punkte",
"Points being calculated": "Punkte werden berechnet",
"Points earned prior to May 1, 2021": "Vor dem 1. Mai 2021 gesammelte Punkte",
"Points may take up to 10 days to be displayed.": "Es kann bis zu 10 Tage dauern, bis Punkte angezeigt werden.",
"Points needed to level up": "Punkte, die zum Levelaufstieg benötigt werden",
"Points needed to stay on level": "Erforderliche Punkte, um auf diesem Niveau zu bleiben",
"Points needed to stay on level": "Erforderliche Punkte, um auf diesem Level zu bleiben",
"spendable points expiring by": "Einlösbare punkte verfallen bis zum",
"Previous victories": "Bisherige Siege",
"Read more": "Mehr lesen",
"Read more about the hotel": "Lesen Sie mehr über das Hotel",
"Remove card from member profile": "Karte aus dem Mitgliedsprofil entfernen",
"Retype new password": "Neues Passwort erneut eingeben",
"Save": "Speichern",
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Select a country": "Wähle ein Land",
"Select country of residence": "Wählen Sie das Land Ihres Wohnsitzes aus",
"Select date of birth": "Geburtsdatum auswählen",
@@ -103,25 +123,33 @@
"Show more": "Mehr anzeigen",
"Show all amenities": "Alle Annehmlichkeiten anzeigen",
"Skip to main content": "Direkt zum Inhalt",
"Sign up bonus": "Anmeldebonus",
"Something went wrong!": "Etwas ist schief gelaufen!",
"Something went wrong and we couldn't add your card. Please try again later.": "Ein Fehler ist aufgetreten und wir konnten Ihre Karte nicht hinzufügen. Bitte versuchen Sie es später erneut.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Ein Fehler ist aufgetreten und wir konnten Ihre Karte nicht entfernen. Bitte versuchen Sie es später noch einmal.",
"Street": "Straße",
"special character": "sonderzeichen",
"Total Points": "Gesamtpunktzahl",
"Your points to spend": "Deine Punkte",
"Your points to spend": "Meine Punkte",
"Transaction date": "Transaktionsdatum",
"Transactions": "Transaktionen",
"Tripadvisor reviews": "{rating} ({count} Bewertungen auf Tripadvisor)",
"to": "zu",
"TUI Points": "TUI Punkte",
"User information": "Nutzerinformation",
"uppercase letter": "großbuchstabe",
"Visiting address": "Besuchsadresse",
"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.",
"Welcome to": "Willkommen zu",
"Welcome": "Willkommen",
"Where should you go next?": "Wo geht es als Nächstes hin?",
"Which room class suits you the best?": "Welche Zimmerklasse passt am besten zu Ihnen?",
"Year": "Jahr",
"You canceled adding a new credit card.": "Sie haben das Hinzufügen einer neuen Kreditkarte abgebrochen.",
"Yes, remove my card": "Ja, meine Karte entfernen",
"You have no previous stays.": "Sie haben keine vorherigen Aufenthalte.",
"You have no upcoming stays.": "Sie haben keine bevorstehenden Aufenthalte.",
"Your card was successfully removed!": "Ihre Karte wurde erfolgreich entfernt!",
"Your card was successfully saved!": "Ihre Karte wurde erfolgreich gespeichert!",
"Your Challenges Conquer & Earn!": "Meistern Sie Ihre Herausforderungen und verdienen Sie Geld!",
"Your level": "Dein level",
@@ -130,6 +158,19 @@
"Hotel facilities": "Hotel-Infos",
"Hotel surroundings": "Umgebung des Hotels",
"Show map": "Karte anzeigen",
"Check in": "Einchecken",
"Check out": "Auschecken",
"Summary": "Zusammenfassung",
"Thank you": "Danke",
"We look forward to your visit!": "Wir freuen uns auf Ihren Besuch!",
"We have sent a detailed confirmation of your booking to your email:": "Wir haben eine detaillierte Bestätigung Ihrer Buchung an Ihre E-Mail gesendet:",
"Download the Scandic app": "Laden Sie die Scandic-App herunter",
"View your booking": "Ihre Buchung ansehen",
"At latest": "Spätestens",
"Type of room": "Zimmerart",
"Type of bed": "Bettentyp",
"Weekdays": "Wochentage",
"Weekends": "Wochenenden",
"Where to": "Wohin",
"When": "Wann",
"Rooms & Guests": "Zimmer & Gäste",

View File

@@ -7,6 +7,8 @@
"All rooms comes with standard amenities": "All rooms comes with standard amenities",
"Already a friend?": "Already a friend?",
"Amenities": "Amenities",
"An error occurred when adding a credit card, please try again later.": "An error occurred when adding a credit card, please try again later.",
"Are you sure you want to remove the card ending with": "Are you sure you want to remove the card ending with",
"Arrival date": "Arrival date",
"as of today": "as of today",
"As our": "As our",
@@ -34,6 +36,7 @@
"Your current level": "Your current level",
"Current password": "Current password",
"characters": "characters",
"Credit card deleted successfully": "Credit card deleted successfully",
"Date of Birth": "Date of Birth",
"Day": "Day",
"Description": "Description",
@@ -45,15 +48,26 @@
"Email": "Email",
"There are no transactions to display": "There are no transactions to display",
"Explore all levels and benefits": "Explore all levels and benefits",
"Failed to delete credit card, please try again later.": "Failed to delete credit card, please try again later.",
"Extras to your booking": "Extras to your booking",
"FAQ": "FAQ",
"Find booking": "Find booking",
"Former Scandic Hotel": "Former Scandic Hotel",
"Flexibility": "Flexibility",
"From": "From",
"from your member profile?": "from your member profile?",
"Get inspired": "Get inspired",
"Go back to overview": "Go back to overview",
"hotelPages.rooms.roomCard.person": "person",
"hotelPages.rooms.roomCard.persons": "persons",
"hotelPages.rooms.roomCard.seeRoomDetails": "See room details",
"Level 1": "Level 1",
"Level 2": "Level 2",
"Level 3": "Level 3",
"Level 4": "Level 4",
"Level 5": "Level 5",
"Level 6": "Level 6",
"Level 7": "Level 7",
"Highest level": "Highest level",
"How do you want to sleep?": "How do you want to sleep?",
"How it works": "How it works",
@@ -78,6 +92,7 @@
"Next": "Next",
"next level:": "next level:",
"No content published": "No content published",
"No, keep card": "No, keep card",
"No transactions available": "No transactions available",
"Not found": "Not found",
"night": "night",
@@ -94,6 +109,8 @@
"Phone number": "Phone number",
"Please enter a valid phone number": "Please enter a valid phone number",
"Points": "Points",
"Points being calculated": "Points being calculated",
"Points earned prior to May 1, 2021": "Points earned prior to May 1, 2021",
"Points may take up to 10 days to be displayed.": "Points may take up to 10 days to be displayed.",
"Points needed to level up": "Points needed to level up",
"Points needed to stay on level": "Points needed to stay on level",
@@ -101,11 +118,14 @@
"Previous victories": "Previous victories",
"Read more": "Read more",
"Read more about the hotel": "Read more about the hotel",
"Remove card from member profile": "Remove card from member profile",
"Restaurant & Bar": "Restaurant & Bar",
"Retype new password": "Retype new password",
"Rooms": "Rooms",
"Save": "Save",
"See room details": "See room details",
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Select a country": "Select a country",
"Select country of residence": "Select country of residence",
"Select date of birth": "Select date of birth",
@@ -114,26 +134,34 @@
"Show more": "Show more",
"Show all amenities": "Show all amenities",
"Skip to main content": "Skip to main content",
"Sign up bonus": "Sign up bonus",
"Something went wrong!": "Something went wrong!",
"Something went wrong and we couldn't add your card. Please try again later.": "Something went wrong and we couldn't add your card. Please try again later.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Something went wrong and we couldn't remove your card. Please try again later.",
"Street": "Street",
"special character": "special character",
"Total Points": "Total Points",
"Your points to spend": "Your points to spend",
"You canceled adding a new credit card.": "You canceled adding a new credit card.",
"Transaction date": "Transaction date",
"Transactions": "Transactions",
"Tripadvisor reviews": "{rating} ({count} reviews on Tripadvisor)",
"to": "to",
"TUI Points": "TUI Points",
"User information": "User information",
"uppercase letter": "uppercase letter",
"Welcome": "Welcome",
"Visiting address": "Visiting address",
"We could not add a card right now, please try again later.": "We could not add a card right now, please try again later.",
"Welcome to": "Welcome to",
"Wellness & Exercise": "Wellness & Exercise",
"Where should you go next?": "Where should you go next?",
"Which room class suits you the best?": "Which room class suits you the best?",
"Year": "Year",
"Yes, remove my card": "Yes, remove my card",
"You have no previous stays.": "You have no previous stays.",
"You have no upcoming stays.": "You have no upcoming stays.",
"Your card was successfully removed!": "Your card was successfully removed!",
"Your card was successfully saved!": "Your card was successfully saved!",
"Your Challenges Conquer & Earn!": "Your Challenges Conquer & Earn!",
"Your level": "Your level",
@@ -142,6 +170,19 @@
"Hotel facilities": "Hotel facilities",
"Hotel surroundings": "Hotel surroundings",
"Show map": "Show map",
"Check in": "Check in",
"Check out": "Check out",
"Summary": "Summary",
"Thank you": "Thank you",
"We look forward to your visit!": "We look forward to your visit!",
"We have sent a detailed confirmation of your booking to your email: ": "We have sent a detailed confirmation of your booking to your email: ",
"Download the Scandic app": "Download the Scandic app",
"View your booking": "View your booking",
"At latest": "At latest",
"Type of room": "Type of room",
"Type of bed": "Type of bed",
"Weekdays": "Weekdays",
"Weekends": "Weekends",
"Where to": "Where to",
"When": "When",
"Rooms & Guests": "Rooms & Guests",

View File

@@ -7,6 +7,8 @@
"All rooms comes with standard amenities": "Kaikissa huoneissa on perusmukavuudet",
"Already a friend?": "Oletko jo ystävä?",
"Amenities": "Mukavuudet",
"An error occurred when adding a credit card, please try again later.": "Luottokorttia lisättäessä tapahtui virhe. Yritä myöhemmin uudelleen.",
"Are you sure you want to remove the card ending with": "Haluatko varmasti poistaa kortin, joka päättyy numeroon",
"Arrival date": "Saapumispäivä",
"as of today": "tästä päivästä lähtien",
"As our": "Kuin meidän",
@@ -31,6 +33,7 @@
"Could not find requested resource": "Pyydettyä resurssia ei löytynyt",
"Country": "Maa",
"Country code": "Maatunnus",
"Credit card deleted successfully": "Luottokortti poistettu onnistuneesti",
"Your current level": "Nykyinen tasosi",
"Current password": "Nykyinen salasana",
"characters": "hahmoja",
@@ -42,13 +45,24 @@
"Edit": "Muokata",
"Edit profile": "Muokkaa profiilia",
"Email": "Sähköposti",
"Extras to your booking": "Lisävarusteet varaukseesi",
"There are no transactions to display": "Näytettäviä tapahtumia ei ole",
"Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin",
"Failed to delete credit card, please try again later.": "Luottokortin poistaminen epäonnistui, yritä myöhemmin uudelleen.",
"Find booking": "Etsi varaus",
"Flexibility": "Joustavuus",
"Former Scandic Hotel": "Entinen Scandic Hotel",
"From": "From",
"from your member profile?": "jäsenprofiilistasi?",
"Get inspired": "Inspiroidu",
"Go back to overview": "Palaa yleiskatsaukseen",
"Level 1": "Taso 1",
"Level 2": "Taso 2",
"Level 3": "Taso 3",
"Level 4": "Taso 4",
"Level 5": "Taso 5",
"Level 6": "Taso 6",
"Level 7": "Taso 7",
"Highest level": "Korkein taso",
"How do you want to sleep?": "Kuinka haluat nukkua?",
"How it works": "Kuinka se toimii",
@@ -73,6 +87,7 @@
"Next": "Seuraava",
"next level:": "Seuraava taso:",
"No content published": "Ei julkaistua sisältöä",
"No, keep card": "Ei, pidä kortti",
"No transactions available": "Ei tapahtumia saatavilla",
"Not found": "Ei löydetty",
"night": "yö",
@@ -89,6 +104,8 @@
"Phone number": "Puhelinnumero",
"Please enter a valid phone number": "Ole hyvä ja näppäile voimassaoleva puhelinnumero",
"Points": "Pistettä",
"Points being calculated": "Pisteitä lasketaan",
"Points earned prior to May 1, 2021": "Ennen 1. toukokuuta 2021 ansaitut pisteet",
"Points may take up to 10 days to be displayed.": "Pisteiden näyttäminen voi kestää jopa 10 päivää.",
"Points needed to level up": "Pisteitä tarvitaan tasolle pääsemiseksi",
"Points needed to stay on level": "Tällä tasolla pysymiseen tarvittavat pisteet",
@@ -96,10 +113,13 @@
"Previous victories": "Edelliset voitot",
"Read more": "Lue lisää",
"Read more about the hotel": "Lue lisää hotellista",
"Remove card from member profile": "Poista kortti jäsenprofiilista",
"Restaurant & Bar": "Ravintola & Baari",
"Retype new password": "Kirjoita uusi salasana uudelleen",
"Rooms": "Huoneet",
"Save": "Tallentaa",
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Select a country": "Valitse maa",
"Select country of residence": "Valitse asuinmaa",
"Select date of birth": "Valitse syntymäaika",
@@ -108,7 +128,10 @@
"Show more": "Näytä lisää",
"Show all amenities": "Näytä kaikki mukavuudet",
"Skip to main content": "Siirry pääsisältöön",
"Sign up bonus": "Rekisteröidy bonus",
"Something went wrong!": "Jotain meni pieleen!",
"Something went wrong and we couldn't add your card. Please try again later.": "Jotain meni pieleen, emmekä voineet lisätä korttiasi. Yritä myöhemmin uudelleen.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Jotain meni pieleen, emmekä voineet poistaa korttiasi. Yritä myöhemmin uudelleen.",
"Street": "Katu",
"special character": "erikoishahmo",
"Total Points": "Kokonaispisteet",
@@ -117,18 +140,23 @@
"Transactions": "Tapahtumat",
"Tripadvisor reviews": "{rating} ({count} arvostelua TripAdvisorissa)",
"to": "to",
"TUI Points": "TUI-pisteet",
"User information": "Käyttäjän tiedot",
"uppercase letter": "iso kirjain",
"Visiting address": "Käyntiosoite",
"We could not add a card right now, please try again later.": "Emme voineet lisätä korttia juuri nyt. Yritä myöhemmin uudelleen.",
"Welcome": "Tervetuloa",
"Welcome to": "Tervetuloa",
"Wellness & Exercise": "Hyvinvointi & Liikunta",
"Where should you go next?": "Mihin menisit seuraavaksi?",
"Which room class suits you the best?": "Mikä huoneluokka sopii sinulle parhaiten?",
"Year": "Vuosi",
"Yes, remove my card": "Kyllä, poista korttini",
"You have no previous stays.": "Sinulla ei ole aiempaa oleskelua.",
"You have no upcoming stays.": "Sinulla ei ole tulevia oleskeluja.",
"Your card was successfully removed!": "Korttisi poistettiin onnistuneesti!",
"Your card was successfully saved!": "Korttisi tallennettu onnistuneesti!",
"You canceled adding a new credit card.": "Peruutit uuden luottokortin lisäämisen.",
"Your Challenges Conquer & Earn!": "Voita ja ansaitse haasteesi!",
"Your level": "Tasosi",
"Zip code": "Postinumero",
@@ -136,6 +164,19 @@
"Hotel facilities": "Hotellin palvelut",
"Hotel surroundings": "Hotellin ympäristö",
"Show map": "Näytä kartta",
"Check in": "Sisäänkirjautuminen",
"Check out": "Uloskirjautuminen",
"Summary": "Yhteenveto",
"Thank you": "Kiitos",
"We look forward to your visit!": "Odotamme innolla vierailuasi!",
"We have sent a detailed confirmation of your booking to your email:": "Olemme lähettäneet yksityiskohtaisen varausvahvistuksen sähköpostiisi:",
"Download the Scandic app": "Lataa Scandic-sovellus",
"View your booking": "Näytä varauksesi",
"At latest": "Viimeistään",
"Type of room": "Huonetyyppi",
"Type of bed": "Vuodetyyppi",
"Weekdays": "Arkisin",
"Weekends": "Viikonloppuisin",
"Where to": "Minne",
"When": "Kun",
"Rooms & Guestss": "Huoneet & Vieraat",

View File

@@ -7,12 +7,14 @@
"All rooms comes with standard amenities": "Alle rommene har standard fasiliteter",
"Already a friend?": "Allerede Friend?",
"Amenities": "Fasiliteter",
"An error occurred when adding a credit card, please try again later.": "Det oppstod en feil ved å legge til et kredittkort. Prøv igjen senere.",
"Are you sure you want to remove the card ending with": "Er du sikker på at du vil fjerne kortet som slutter på",
"Arrival date": "Ankomstdato",
"as of today": "per idag",
"As our": "Som vår",
"As our Close Friend": "Som vår nære venn",
"At the hotel": "På hotellet",
"Book": "Bok",
"Book": "Bestill",
"Booking number": "Bestillingsnummer",
"Breakfast": "Frokost",
"by": "innen",
@@ -34,6 +36,7 @@
"Your current level": "Ditt nåværende nivå",
"Current password": "Nåværende passord",
"characters": "tegn",
"Credit card deleted successfully": "Kredittkort slettet",
"Date of Birth": "Fødselsdato",
"Day": "Dag",
"Description": "Beskrivelse",
@@ -42,13 +45,24 @@
"Edit": "Redigere",
"Edit profile": "Rediger profil",
"Email": "E-post",
"Extras to your booking": "Ekstra til din bestilling",
"There are no transactions to display": "Det er ingen transaksjoner å vise",
"Explore all levels and benefits": "Utforsk alle nivåer og fordeler",
"Failed to delete credit card, please try again later.": "Kunne ikke slette kredittkortet, prøv igjen senere.",
"Find booking": "Finn booking",
"Flexibility": "Fleksibilitet",
"Former Scandic Hotel": "Tidligere Scandic Hotel",
"From": "Fra",
"from your member profile?": "fra medlemsprofilen din?",
"Get inspired": "Bli inspirert",
"Go back to overview": "Gå tilbake til oversikten",
"Level 1": "Nivå 1",
"Level 2": "Nivå 2",
"Level 3": "Nivå 3",
"Level 4": "Nivå 4",
"Level 5": "Nivå 5",
"Level 6": "Nivå 6",
"Level 7": "Nivå 7",
"Highest level": "Høyeste nivå",
"How do you want to sleep?": "Hvordan vil du sove?",
"How it works": "Hvordan det fungerer",
@@ -73,6 +87,7 @@
"Next": "Neste",
"next level:": "Neste nivå:",
"No content published": "Ingen innhold publisert",
"No, keep card": "Nei, behold kortet",
"No transactions available": "Ingen transaksjoner tilgjengelig",
"Not found": "Ikke funnet",
"night": "natt",
@@ -89,6 +104,8 @@
"Phone number": "Telefonnummer",
"Please enter a valid phone number": "Vennligst oppgi et gyldig telefonnummer",
"Points": "Poeng",
"Points being calculated": "Poeng beregnes",
"Points earned prior to May 1, 2021": "Poeng opptjent før 1. mai 2021",
"Points may take up to 10 days to be displayed.": "Det kan ta opptil 10 dager før poeng vises.",
"Points needed to level up": "Poeng som trengs for å komme opp i nivå",
"Points needed to stay on level": "Poeng som trengs for å holde seg på nivå",
@@ -96,10 +113,13 @@
"Previous victories": "Tidligere seire",
"Read more": "Les mer",
"Read more about the hotel": "Les mer om hotellet",
"Remove card from member profile": "Fjern kortet fra medlemsprofilen",
"Restaurant & Bar": "Restaurant & Bar",
"Retype new password": "Skriv inn nytt passord på nytt",
"Rooms": "Rom",
"Save": "Lagre",
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Select a country": "Velg et land",
"Select country of residence": "Velg bostedsland",
"Select date of birth": "Velg fødselsdato",
@@ -108,7 +128,10 @@
"Show more": "Vis mer",
"Show all amenities": "Vis alle fasiliteter",
"Skip to main content": "Gå videre til hovedsiden",
"Sign up bonus": "Registreringsbonus",
"Something went wrong!": "Noe gikk galt!",
"Something went wrong and we couldn't add your card. Please try again later.": "Noe gikk galt, og vi kunne ikke legge til kortet ditt. Prøv igjen senere.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Noe gikk galt, og vi kunne ikke fjerne kortet ditt. Vennligst prøv igjen senere.",
"Street": "Gate",
"special character": "spesiell karakter",
"Total Points": "Totale poeng",
@@ -117,17 +140,22 @@
"Transactions": "Transaksjoner",
"Tripadvisor reviews": "{rating} ({count} anmeldelser på Tripadvisor)",
"to": "til",
"TUI Points": "TUI-poeng",
"User information": "Brukerinformasjon",
"uppercase letter": "stor bokstav",
"Visiting address": "Besøksadresse",
"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.",
"Welcome": "Velkommen",
"Welcome to": "Velkommen til",
"Wellness & Exercise": "Velvære & Trening",
"Where should you go next?": "Hvor ønsker du å reise neste gang?",
"Which room class suits you the best?": "Hvilken romklasse passer deg best?",
"Year": "År",
"You canceled adding a new credit card.": "Du kansellerte å legge til et nytt kredittkort.",
"Yes, remove my card": "Ja, fjern kortet mitt",
"You have no previous stays.": "Du har ingen tidligere opphold.",
"You have no upcoming stays.": "Du har ingen kommende opphold.",
"Your card was successfully removed!": "Kortet ditt ble fjernet!",
"Your card was successfully saved!": "Kortet ditt ble lagret!",
"Your Challenges Conquer & Earn!": "Dine utfordringer Erobre og tjen!",
"Your level": "Ditt nivå",
@@ -136,6 +164,19 @@
"Hotel facilities": "Hotelfaciliteter",
"Hotel surroundings": "Hotellomgivelser",
"Show map": "Vis kart",
"Check in": "Sjekk inn",
"Check out": "Sjekk ut",
"Summary": "Sammendrag",
"Thank you": "Takk",
"We look forward to your visit!": "Vi ser frem til ditt besøk!",
"We have sent a detailed confirmation of your booking to your email:": "Vi har sendt en detaljert bekreftelse av din bestilling til din e-post:",
"Download the Scandic app": "Last ned Scandic-appen",
"View your booking": "Se din bestilling",
"At latest": "Senest",
"Type of room": "Romtype",
"Type of bed": "Sengtype",
"Weekdays": "Hverdager",
"Weekends": "Helger",
"Where to": "Hvor skal du",
"When": "Når",
"Rooms & Guests": "Rom og gjester",

View File

@@ -7,6 +7,8 @@
"All rooms comes with standard amenities": "Alla rum har standardbekvämligheter",
"Already a friend?": "Är du redan en vän?",
"Amenities": "Bekvämligheter",
"An error occurred when adding a credit card, please try again later.": "Ett fel uppstod när ett kreditkort lades till, försök igen senare.",
"Are you sure you want to remove the card ending with": "Är du säker på att du vill ta bort kortet som slutar med",
"Arrival date": "Ankomstdatum",
"as of today": "från och med idag",
"As our": "Som vår",
@@ -31,6 +33,7 @@
"Could not find requested resource": "Det gick inte att hitta den begärda resursen",
"Country": "Land",
"Country code": "Landskod",
"Credit card deleted successfully": "Kreditkort har tagits bort",
"Your current level": "Din nuvarande nivå",
"Current password": "Nuvarande lösenord",
"characters": "tecken",
@@ -42,13 +45,24 @@
"Edit": "Redigera",
"Edit profile": "Redigera profil",
"Email": "E-post",
"Extras to your booking": "Extra till din bokning",
"There are no transactions to display": "Det finns inga transaktioner att visa",
"Explore all levels and benefits": "Utforska alla nivåer och fördelar",
"Failed to delete credit card, please try again later.": "Det gick inte att ta bort kreditkortet, försök igen senare.",
"Find booking": "Hitta bokning",
"Flexibility": "Flexibilitet",
"Former Scandic Hotel": "Tidigare Scandic Hotel",
"From": "Från",
"from your member profile?": "från din medlemsprofil?",
"Get inspired": "Bli inspirerad",
"Go back to overview": "Gå tillbaka till översikten",
"Level 1": "Nivå 1",
"Level 2": "Nivå 2",
"Level 3": "Nivå 3",
"Level 4": "Nivå 4",
"Level 5": "Nivå 5",
"Level 6": "Nivå 6",
"Level 7": "Nivå 7",
"Highest level": "Högsta nivå",
"How do you want to sleep?": "Hur vill du sova?",
"How it works": "Hur det fungerar",
@@ -76,6 +90,7 @@
"Next": "Nästa",
"next level:": "Nästa nivå:",
"No content published": "Inget innehåll publicerat",
"No, keep card": "Nej, behåll kortet",
"No transactions available": "Inga transaktioner tillgängliga",
"Not found": "Hittades inte",
"night": "natt",
@@ -92,6 +107,8 @@
"Phone number": "Telefonnummer",
"Please enter a valid phone number": "Var vänlig och ange ett giltigt telefonnummer",
"Points": "Poäng",
"Points being calculated": "Poäng beräknas",
"Points earned prior to May 1, 2021": "Poäng intjänade före 1 maj 2021",
"Points may take up to 10 days to be displayed.": "Det kan ta upp till 10 dagar innan poäng visas.",
"Points needed to level up": "Poäng som behövs för att gå upp i nivå",
"Points needed to stay on level": "Poäng som behövs för att hålla sig på nivå",
@@ -99,10 +116,13 @@
"Previous victories": "Tidigare segrar",
"Read more": "Läs mer",
"Read more about the hotel": "Läs mer om hotellet",
"Remove card from member profile": "Ta bort kortet från medlemsprofilen",
"Restaurant & Bar": "Restaurang & Bar",
"Retype new password": "Upprepa nytt lösenord",
"Rooms": "Rum",
"Save": "Spara",
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Select a country": "Välj ett land",
"Select country of residence": "Välj bosättningsland",
"Select date of birth": "Välj födelsedatum",
@@ -111,7 +131,10 @@
"Show more": "Visa mer",
"Show all amenities": "Visa alla bekvämligheter",
"Skip to main content": "Fortsätt till huvudinnehåll",
"Sign up bonus": "Registreringsbonus",
"Something went wrong!": "Något gick fel!",
"Something went wrong and we couldn't add your card. Please try again later.": "Något gick fel och vi kunde inte lägga till ditt kort. Försök igen senare.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Något gick fel och vi kunde inte ta bort ditt kort. Försök igen senare.",
"Street": "Gata",
"special character": "speciell karaktär",
"Total Points": "Poäng totalt",
@@ -120,16 +143,21 @@
"Transactions": "Transaktioner",
"Tripadvisor reviews": "{rating} ({count} recensioner på Tripadvisor)",
"to": "till",
"TUI Points": "TUI-poäng",
"User information": "Användar information",
"uppercase letter": "stor bokstav",
"Visiting address": "Besöksadress",
"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.",
"Welcome": "Välkommen",
"Wellness & Exercise": "Hälsa & Träning",
"Where should you go next?": "Låter inte en spontanweekend härligt?",
"Which room class suits you the best?": "Vilken rumsklass passar dig bäst?",
"Year": "År",
"You canceled adding a new credit card.": "Du avbröt att lägga till ett nytt kreditkort.",
"Yes, remove my card": "Ja, ta bort mitt kort",
"You have no previous stays.": "Du har inga tidigare vistelser.",
"You have no upcoming stays.": "Du har inga planerade resor.",
"Your card was successfully removed!": "Ditt kort har tagits bort!",
"Your card was successfully saved!": "Ditt kort har sparats!",
"Your Challenges Conquer & Earn!": "Dina utmaningar Erövra och tjäna!",
"Your level": "Din nivå",
@@ -138,6 +166,19 @@
"Hotel facilities": "Hotellfaciliteter",
"Hotel surroundings": "Hotellomgivning",
"Show map": "Visa karta",
"Check in": "Checka in",
"Check out": "Checka ut",
"Summary": "Sammanfattning",
"Thank you": "Tack",
"We look forward to your visit!": "Vi ser fram emot ditt besök!",
"We have sent a detailed confirmation of your booking to your email:": "Vi har skickat en detaljerad bekräftelse av din bokning till din e-post:",
"Download the Scandic app": "Ladda ner Scandic-appen",
"View your booking": "Visa din bokning",
"At latest": "Senast",
"Type of room": "Rumstyp",
"Type of bed": "Sängtyp",
"Weekdays": "Vardagar",
"Weekends": "Helger",
"Where to": "Vart",
"When": "När",
"Rooms & Guests": "Rum och gäster",

View File

@@ -13,6 +13,8 @@ export namespace endpoints {
upcomingStays = "booking/v1/Stays/future",
previousStays = "booking/v1/Stays/past",
hotels = "hotel/v1/Hotels",
intiateSaveCard = `${creditCards}/initiateSaveCard`,
deleteCreditCard = `${profile}/creditCards`,
}
}

View File

@@ -27,7 +27,7 @@ const fetch = fetchRetry(global.fetch, {
})
export async function get(
endpoint: Endpoint | `${endpoints.v1.hotels}/${string}`,
endpoint: Endpoint | `${Endpoint}/${string}`,
options: RequestOptionsWithOutBody,
params?: URLSearchParams
) {
@@ -38,7 +38,7 @@ export async function get(
}
export async function patch(
endpoint: Endpoint,
endpoint: Endpoint | `${Endpoint}/${string}`,
options: RequestOptionsWithJSONBody
) {
const { body, ...requestOptions } = options
@@ -54,11 +54,12 @@ export async function patch(
export async function post(
endpoint: Endpoint | `${Endpoint}/${string}`,
options: RequestOptionsWithJSONBody
options: RequestOptionsWithJSONBody,
params?: URLSearchParams
) {
const { body, ...requestOptions } = options
return fetch(
`${env.API_BASEURL}/${endpoint}`,
`${env.API_BASEURL}/${endpoint}${params ? `?${params.toString()}` : ""}`,
merge.all([
defaultOptions,
{ body: JSON.stringify(body), method: "POST" },
@@ -68,11 +69,12 @@ export async function post(
}
export async function remove(
endpoint: Endpoint,
options: RequestOptionsWithOutBody
endpoint: Endpoint | `${Endpoint}/${string}`,
options: RequestOptionsWithOutBody,
params?: URLSearchParams
) {
return fetch(
`${env.API_BASEURL}/${endpoint}`,
`${env.API_BASEURL}/${endpoint}${params ? `?${params.toString()}` : ""}`,
merge.all([defaultOptions, { method: "DELETE" }, options])
)
}

View File

@@ -0,0 +1,37 @@
fragment ContentPageBreadcrumbs on ContentPage {
web {
breadcrumbs {
title
parentsConnection {
edges {
node {
... on ContentPage {
web {
breadcrumbs {
title
}
}
system {
locale
uid
}
url
}
... on LoyaltyPage {
web {
breadcrumbs {
title
}
}
system {
locale
uid
}
url
}
}
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
#import "../Image.graphql"
fragment LoyaltyPageMetaData on LoyaltyPage {
web {
seo_metadata {
title
description
imageConnection {
edges {
node {
...Image
}
}
}
}
breadcrumbs {
title
}
}
}

View File

@@ -0,0 +1,20 @@
#import "../Image.graphql"
fragment MyPagesMetaData on AccountPage {
web {
seo_metadata {
title
description
imageConnection {
edges {
node {
...Image
}
}
}
}
breadcrumbs {
title
}
}
}

View File

@@ -0,0 +1,38 @@
#import "../System.graphql"
fragment ContentPageBreadcrumbsRefs on ContentPage {
web {
breadcrumbs {
title
parentsConnection {
edges {
node {
... on ContentPage {
web {
breadcrumbs {
title
}
}
system {
...System
}
}
... on LoyaltyPage {
web {
breadcrumbs {
title
}
}
system {
...System
}
}
}
}
}
}
}
system {
...System
}
}

View File

@@ -1,4 +1,4 @@
#import "./System.graphql"
#import "../System.graphql"
fragment ContentPageRef on ContentPage {
system {

View File

@@ -3,7 +3,7 @@
#import "../Fragments/MyPages/AccountPage/AccountPageContentTextContent.graphql"
#import "../Fragments/Refs/MyPages/AccountPage.graphql"
#import "../Fragments/Refs/ContentPage.graphql"
#import "../Fragments/Refs/ContentPage/ContentPage.graphql"
#import "../Fragments/Refs/LoyaltyPage/LoyaltyPage.graphql"
#import "../Fragments/Refs/System.graphql"

View File

@@ -0,0 +1,21 @@
#import "../Fragments/ContentPage/Breadcrumbs.graphql"
#import "../Fragments/Refs/ContentPage/Breadcrumbs.graphql"
query GetContentPageBreadcrumbs($locale: String!, $url: String!) {
all_content_page(locale: $locale, where: { url: $url }) {
items {
...ContentPageBreadcrumbs
system {
uid
}
}
}
}
query GetContentPageBreadcrumbsRefs($locale: String!, $url: String!) {
all_content_page(locale: $locale, where: { url: $url }) {
items {
...ContentPageBreadcrumbsRefs
}
}
}

View File

@@ -0,0 +1,52 @@
query GetContentPage($locale: String!, $uid: String!) {
content_page(uid: $uid, locale: $locale) {
title
header {
heading
preamble
}
hero_image
system {
uid
created_at
updated_at
locale
}
}
}
query GetDaDeEnUrlsContentPage($uid: String!) {
de: all_content_page(where: { uid: $uid }, locale: "de") {
items {
url
}
}
en: all_content_page(where: { uid: $uid }, locale: "en") {
items {
url
}
}
da: all_content_page(where: { uid: $uid }, locale: "da") {
items {
url
}
}
}
query GetFiNoSvUrlsContentPage($uid: String!) {
fi: all_content_page(where: { uid: $uid }, locale: "fi") {
items {
url
}
}
no: all_content_page(where: { uid: $uid }, locale: "no") {
items {
url
}
}
sv: all_content_page(where: { uid: $uid }, locale: "sv") {
items {
url
}
}
}

View File

@@ -5,13 +5,12 @@
#import "../Fragments/Blocks/Refs/Card.graphql"
#import "../Fragments/Blocks/Refs/LoyaltyCard.graphql"
#import "../Fragments/LoyaltyPage/Breadcrumbs.graphql"
#import "../Fragments/PageLink/AccountPageLink.graphql"
#import "../Fragments/PageLink/ContentPageLink.graphql"
#import "../Fragments/PageLink/LoyaltyPageLink.graphql"
#import "../Fragments/Refs/MyPages/AccountPage.graphql"
#import "../Fragments/Refs/ContentPage.graphql"
#import "../Fragments/Refs/ContentPage/ContentPage.graphql"
#import "../Fragments/Refs/LoyaltyPage/LoyaltyPage.graphql"
#import "../Fragments/Refs/System.graphql"
@@ -107,6 +106,8 @@ query GetLoyaltyPage($locale: String!, $uid: String!) {
}
title
heading
preamble
hero_image
sidebar {
__typename
... on LoyaltyPageSidebarDynamicContent {
@@ -167,7 +168,6 @@ query GetLoyaltyPage($locale: String!, $uid: String!) {
}
}
}
...LoyaltyPageBreadcrumbs
system {
uid
created_at

View File

@@ -0,0 +1,12 @@
#import "../Fragments/LoyaltyPage/MetaData.graphql"
query GetLoyaltyPageMetaData($locale: String!, $url: String!) {
all_loyalty_page(locale: $locale, where: { url: $url }) {
items {
...LoyaltyPageMetaData
system {
uid
}
}
}
}

View File

@@ -0,0 +1,12 @@
#import "../Fragments/MyPages/MetaData.graphql"
query GetMyPagesMetaData($locale: String!, $url: String!) {
all_account_page(locale: $locale, where: { url: $url }) {
items {
...MyPagesMetaData
system {
uid
}
}
}
}

View File

@@ -2,7 +2,7 @@
#import "../Fragments/PageLink/ContentPageLink.graphql"
#import "../Fragments/PageLink/LoyaltyPageLink.graphql"
#import "../Fragments/Refs/MyPages/AccountPage.graphql"
#import "../Fragments/Refs/ContentPage.graphql"
#import "../Fragments/Refs/ContentPage/ContentPage.graphql"
#import "../Fragments/Refs/LoyaltyPage/LoyaltyPage.graphql"
#import "../Fragments/Refs/System.graphql"

View File

@@ -50,9 +50,9 @@ export async function request<T>(
const nr = Math.random()
console.log(`START REQUEST ${nr}`)
console.time(`OUTGOING REQUEST ${nr}`)
console.log(`Sending reqeust to ${env.CMS_URL}`)
console.log(`Query:`, print(query as DocumentNode))
console.log(`Variables:`, variables)
// console.log(`Sending reqeust to ${env.CMS_URL}`)
// console.log(`Query:`, print(query as DocumentNode))
// console.log(`Variables:`, variables)
const response = await client.request<T>({
document: query,
@@ -64,7 +64,7 @@ export async function request<T>(
})
console.timeEnd(`OUTGOING REQUEST ${nr}`)
console.log({ response })
// console.log({ response })
return { data: response }
} catch (error) {

View File

@@ -1,5 +1,9 @@
import { createTRPCReact } from "@trpc/react-query"
import { inferRouterInputs, inferRouterOutputs } from "@trpc/server"
import type { AppRouter } from "@/server"
export const trpc = createTRPCReact<AppRouter>()
export type RouterInput = inferRouterInputs<AppRouter>
export type RouterOutput = inferRouterOutputs<AppRouter>

View File

@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server"
import { redirect } from "next/navigation"
import { Lang } from "@/constants/languages"
import { login } from "@/constants/routes/handleAuth"
import { webviews } from "@/constants/routes/webviews"
import { appRouter } from "@/server"
import { createContext } from "@/server/context"
@@ -13,12 +14,11 @@ const createCaller = createCallerFactory(appRouter)
export function serverClient() {
return createCaller(createContext(), {
onError: ({ ctx, error, input, path, type }) => {
console.error(`Server Client error for ${type}: ${path}`)
console.error(`[serverClient] error for ${type}: ${path}`, error)
if (input) {
console.error(`Received input:`)
console.error(input)
console.error(`[serverClient] received input:`, input)
}
console.error(error)
if (error instanceof TRPCError) {
if (error.code === "UNAUTHORIZED") {
@@ -41,12 +41,13 @@ export function serverClient() {
redirectUrl
)
console.log(`[serverClient] onError redirecting to: ${redirectUrl}`)
redirect(redirectUrl)
}
redirect(
`/${lang}/login?redirectTo=${encodeURIComponent(`/${lang}/${pathname}`)}`
)
const redirectUrl = `${login[lang]}?redirectTo=${encodeURIComponent(`/${lang}/${pathname}`)}`
console.log(`[serverClient] onError redirecting to: ${redirectUrl}`)
redirect(redirectUrl)
}
}

View File

@@ -49,9 +49,9 @@ export const middleware = auth(async (request) => {
}
const publicUrl = new URL(env.PUBLIC_URL)
const nextUrlClone = nextUrl.clone()
nextUrlClone.host = publicUrl.host
nextUrlClone.hostname = publicUrl.hostname
const nextUrlPublic = nextUrl.clone()
nextUrlPublic.host = publicUrl.host
nextUrlPublic.hostname = publicUrl.hostname
/**
* Function to validate MFA from token data
@@ -67,8 +67,8 @@ export const middleware = auth(async (request) => {
if (isLoggedIn && isMFAPath && isMFAInvalid()) {
const headers = new Headers(request.headers)
headers.set("x-mfa-login", "true")
headers.set("x-returnurl", nextUrlClone.href)
headers.set("x-returnurl", nextUrlPublic.href)
headers.set("x-login-source", "mfa")
return NextResponse.rewrite(new URL(`/${lang}/login`, request.nextUrl), {
request: {
headers,
@@ -87,13 +87,16 @@ export const middleware = auth(async (request) => {
const headers = new Headers()
headers.append(
"set-cookie",
`redirectTo=${encodeURIComponent(nextUrlClone.href)}; Path=/; HttpOnly; SameSite=Lax`
`redirectTo=${encodeURIComponent(nextUrlPublic.href)}; Path=/; HttpOnly; SameSite=Lax`
)
const loginUrl = login[lang]
return NextResponse.redirect(new URL(loginUrl, nextUrlClone), {
const redirectUrl = new URL(loginUrl, nextUrlPublic)
const redirectOpts = {
headers,
})
}
console.log(`[authRequired] redirecting to: ${redirectUrl}`, redirectOpts)
return NextResponse.redirect(redirectUrl, redirectOpts)
}) as NextMiddleware // See comment above
export const matcher: MiddlewareMatcher = (request) => {

View File

@@ -19,6 +19,7 @@ export const middleware: NextMiddleware = (request) => {
const headers = new Headers(request.headers)
headers.set("x-returnurl", returnUrl)
headers.set("x-login-source", "seamless")
return NextResponse.rewrite(new URL(`/${lang}/login`, request.nextUrl), {
request: {

View File

@@ -19,7 +19,7 @@ export const middleware: NextMiddleware = (request) => {
const headers = new Headers(request.headers)
headers.set("x-returnurl", returnUrl)
headers.set("x-magic-link", "1")
headers.set("x-login-source", "seamless-magiclink")
return NextResponse.rewrite(new URL(`/${lang}/login`, request.nextUrl), {
request: {

View File

@@ -23,6 +23,7 @@ export const middleware: NextMiddleware = (request) => {
const headers = new Headers(request.headers)
headers.set("x-returnurl", redirectTo)
headers.set("x-logout-source", "seamless")
return NextResponse.rewrite(new URL(`/${lang}/logout`, request.nextUrl), {
request: {

View File

@@ -34,7 +34,9 @@ export const middleware: NextMiddleware = async (request) => {
nextUrlClone.hostname = publicUrl.hostname
const overviewUrl = overview[lang]
return NextResponse.redirect(new URL(overviewUrl, nextUrlClone))
const redirectUrl = new URL(overviewUrl, nextUrlClone)
console.log(`[myPages] redirecting to: ${redirectUrl}`)
return NextResponse.redirect(redirectUrl)
}
const pathNameWithoutLang = nextUrl.pathname.replace(`/${lang}`, "")

View File

@@ -1,3 +1,6 @@
import { env } from "@/env/server"
import { internalServerError } from "@/server/errors/next"
import { findLang } from "@/utils/languages"
import { removeTrailingSlash } from "@/utils/url"
@@ -6,6 +9,17 @@ import type { NextRequest } from "next/server"
export function getDefaultRequestHeaders(request: NextRequest) {
const lang = findLang(request.nextUrl.pathname)!
let nextUrl
if (env.PUBLIC_URL) {
const publicUrl = new URL(env.PUBLIC_URL)
const nextUrlPublic = request.nextUrl.clone()
nextUrlPublic.host = publicUrl.host
nextUrlPublic.hostname = publicUrl.hostname
nextUrl = nextUrlPublic
} else {
nextUrl = request.nextUrl
}
const headers = new Headers(request.headers)
headers.set("x-lang", lang)
headers.set(
@@ -14,7 +28,7 @@ export function getDefaultRequestHeaders(request: NextRequest) {
request.nextUrl.pathname.replace(`/${lang}`, "").replace(`/webview`, "")
)
)
headers.set("x-url", removeTrailingSlash(request.nextUrl.href))
headers.set("x-url", removeTrailingSlash(nextUrl.href))
return headers
}

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