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

@@ -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%;
}