29 Commits

Author SHA1 Message Date
Anton Gunnarsson 1dffeb6be7 Merged in fix/loy-603-fix-edit-profile-redirect (pull request #3543)
fix(LOY-603): Remove refresh preventing redirect

* Remove refresh preventing redirect


Approved-by: Matilda Landström
2026-02-05 07:45:19 +00:00
Anton Gunnarsson 1f1ed2e4f3 Merged in chore/update-sas-banner-image (pull request #3545)
fix(LOY-621): Replace scandic banner image and reduce size

* Replace scandic banner image and reduce size


Approved-by: Emma Zettervall
Approved-by: Matilda Landström
2026-02-05 07:44:54 +00:00
Linus Flood bc9eaf6706 Merged in fix/robots-txt (pull request #3547)
feat(robots): fixed robots.txt file

* feat(robots): fixed robots.txt file
2026-02-05 07:38:15 +00:00
Matilda Landström 549265cd34 Merged in feat/LOY-615-cleanup-env-prof-consent (pull request #3537)
feat(LOY-615): cleanup profiling consent env var

* feat(LOY-615): cleanup profiling consent env var


Approved-by: Anton Gunnarsson
2026-02-04 16:51:06 +00:00
Matilda Landström 989b18527e Merged in fix/STAY-138-center-text (pull request #3538)
fix(STAY-138): center text

* fix(STAY-138): center text


Approved-by: Emma Zettervall
2026-02-04 13:09:12 +00:00
Erik Tiekstra 0cda37808e Merged in fix/BOOK-755-alert-content (pull request #3523)
fix(BOOK-755, BOOK-787): Fixed issue for phone number and sidepeeks not showing

* fix(BOOK-755): Fixed issue for phone number and sidepeeks not showing

* fix(BOOK-755): fix issue phonenumber alert

* fix(BOOK-755): fix issue phonenumber


Approved-by: Matilda Landström
2026-02-03 15:28:23 +00:00
Erik Tiekstra b3c4761ae5 Merged in chore/BOOK-773-replace-old-typography-variables (pull request #3515)
Chore/BOOK-773 replace old typography variables

* chore(BOOK-773): Replaced body typography

* chore(BOOK-773): Replaced caption typography

* chore(BOOK-773): Replaced footnote typography

* chore(BOOK-773): Replaced subtitle typography


Approved-by: Bianca Widstam
2026-02-03 15:07:18 +00:00
Linus Flood dd65467573 Merged in fix/close-map-text (pull request #3536)
feat(map): fixed close map text alignment

* feat(map): fixed close map text alignment
2026-02-03 14:07:40 +00:00
Joakim Jäderberg eb45e6b294 Merged in fix/LOY-606 (pull request #3535)
fix(LOY-606): breakfast price now considers number of nights in MyStay/ChangeDates

* fix(LOY-606): breakfast price now considers number of nights in MyStay/ChangeDates


Approved-by: Linus Flood
2026-02-03 13:51:45 +00:00
Emma Zettervall 6553fcf685 Merged in fix/use-old-loacalize-keys (pull request #3534)
fix(LOY-391): changed back localize keys to the old ones to make less work for content

* fix(LOY-391): changed back localize keys to the old ones to make less work for content


Approved-by: Anton Gunnarsson
2026-02-03 13:42:37 +00:00
Anton Gunnarsson c2cf6b03a7 Merged in feat/loy-291-new-claim-points-flow (pull request #3508)
feat(LOY-291): New claim points flow for logged in users

* wip new flow

* More wip

* More wip

* Wip styling

* wip with a mutation

* Actually fetch booking data

* More styling wip

* Fix toast duration

* fix loading a11y maybe

* More stuff

* Add feature flag

* Add invalid state

* Clean up

* Add fields for missing user info

* Restructure files

* Add todos

* Disable warning

* Fix icon and border radius


Approved-by: Emma Zettervall
Approved-by: Matilda Landström
2026-02-03 13:27:24 +00:00
Linus Flood 310ad7bc7f Merged in fix/book-785-hotelfilters-cache (pull request #3533)
fix(BOOK-785): fix incorrect cache keys for contact config and hotel filters

* fix(BOOK-785): fix incorrect cache keys for contact config and hotel filters
2026-02-03 13:18:23 +00:00
Joakim Jäderberg fbdbd35813 Merged in fix/mainmenu-button-css-cascade (pull request #3532)
Fix/mainmenu button css cascade

* fix(MainMenuButton): add @layer to make css cascading work

* reset
2026-02-03 12:36:27 +00:00
Matilda Landström d6b94376b0 Merged in fix/STAY-23-room-price (pull request #3529)
fix(STAY-23): don't strikethrough price if only one room is cancelled (multiroom)

* fix(STAY-23): don't strikethrough price if only room is cancelled (multiroom)


Approved-by: Anton Gunnarsson
2026-02-03 07:58:52 +00:00
Linus Flood 7a604f1250 Merged in chore/nextjs-version (pull request #3530)
Chore/nextjs version

* chore(next/react): update to latest versions

* Correct swc version

* Readme

* No turbopack

* test

* test

* test
2026-02-03 07:52:04 +00:00
Joakim Jäderberg 13fd8f81c9 Merged in revert-nextjs-upgrade (pull request #3528)
revert nextjs upgrade

* revert nextjs upgrade

* Fix revert


Approved-by: Anton Gunnarsson
2026-02-02 15:15:42 +00:00
Joakim Jäderberg 16cc26632e Merged in chore/refactor-trpc-booking-routes (pull request #3510)
feat(BOOK-750): refactor booking endpoints

* WIP

* wip

* wip

* parse dates in UTC

* wip

* no more errors

* Merge branch 'master' of bitbucket.org:scandic-swap/web into chore/refactor-trpc-booking-routes

* .

* cleanup

* import named z from zod

* fix(BOOK-750): updateBooking api endpoint expects dateOnly, we passed ISO date


Approved-by: Anton Gunnarsson
2026-02-02 14:28:14 +00:00
Linus Flood 8ac2c4ba22 Merged in chore/nextjs-version (pull request #3527)
chore(next/react): update to latest versions

* chore(next/react): update to latest versions

* Correct swc version


Approved-by: Anton Gunnarsson
2026-02-02 14:21:39 +00:00
Emma Zettervall 65e5d90fee Merged in fix/point-transaction-link (pull request #3526)
Fix/point transaction link

* fix: only make stay transactions links

* fix: only make transaction of type stay and reward nights with a bookingUrl as links


Approved-by: Matilda Landström
2026-02-02 12:44:08 +00:00
Linus Flood 5f55687239 Merged in feat/lokalise-sync-020226 (pull request #3525)
Lokalise sync

* Lokalise sync
2026-02-02 10:35:04 +00:00
Matilda Haneling e30ce9ac30 Merged in fix/book-769-booking-widget-ui-bugs (pull request #3524)
Fix/book 769 booking widget ui bugs

* fixed text in serachList not wrapping properly

* fixed spacing on mobile searchList

* fixed close button icon color

* fix for issues with fixed vs sticky elements on scroll lock

Approved-by: Linus Flood
2026-02-02 10:26:48 +00:00
Matilda Landström 61c024dbda Merged in fix/correct-policies-link (pull request #3522)
Fix/correct policies link

* fix: add correct link

* fix: move route


Approved-by: Emma Zettervall
2026-02-02 09:22:08 +00:00
Emma Zettervall 2f73fce6f2 Merged in fix/LOY-391-fix-for-transaction-details-not-available (pull request #3513)
fix(LOY-391): uses transaction date if checkinDate does not exist

* fix(LOY-391): uses transaction date if checkinDate does not exist

* fix(LOY-391): changed date to datejs and fixed description for reward night and reward gift

* fix(LOY-391):use year transaction date

* fix(LOY-391): fixed date timezone and dt() instead of datejs()

* fix(LOY-391): small improv with year

* fix(LOY-391): utc fix

* fix(LOY-391): changed sorting order on transaction date

* fix(LOY-391): filter out transactions with 0 award points


Approved-by: Joakim Jäderberg
2026-01-30 14:59:14 +00:00
Anton Gunnarsson 76ee5e97bf Merged in chore/improve-my-stay-load-times (pull request #3514)
chore: Improve My Stay load times

* Restructure My Stay page to avoid data fetching waterfalls


Approved-by: Linus Flood
Approved-by: Matilda Landström
2026-01-30 14:29:49 +00:00
Anton Gunnarsson fd38542863 Merged in fix/sw-3703-fix-focus-in-lightbox-gallery (pull request #3521)
fix(SW-3703): Fix focus management in lightbox gallery

* Add keys to fix focus


Approved-by: Matilda Landström
2026-01-30 12:55:46 +00:00
Anton Gunnarsson cc60cf2903 Merged in chore/change-icons-to-rounded (pull request #3520)
chore: Change default icon style to rounded

* Change default icon style to rounded


Approved-by: Linus Flood
2026-01-30 10:13:03 +00:00
Erik Tiekstra 13b0d976ac fix(BOOK-391): Fixed typo in path to Futura PT font
* fix(BOOK-391): Now using correct Futura PT fonts

Approved-by: Linus Flood
2026-01-30 09:58:50 +00:00
Anton Gunnarsson 77eabac038 Merged in fix/loy-589-update-cancellation-labels (pull request #3495)
fix(LOY-589): Split cancellation label into two

* Split cancellation label into two

* Fix copy


Approved-by: Bianca Widstam
2026-01-30 08:29:35 +00:00
Matilda Landström 0919134f88 Merged in fix/LOY-597-upcoming-today (pull request #3517)
fix(LOY-597): Display "Today" for upcoming stay today

* fix(LOY-597): Display "Today" for upcoming stay today


Approved-by: Anton Gunnarsson
2026-01-30 08:03:28 +00:00
575 changed files with 5883 additions and 2951 deletions
@@ -1,3 +1,4 @@
.layout {
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
}
+5
View File
@@ -158,6 +158,11 @@ body.partner-sas {
white-space: nowrap;
border-width: 0;
}
/* When a select in the booking widget is open, react-aria sets overflow:hidden
which breaks sticky positioning. Override with clip which doesn't break sticky. */
body:has([data-booking-widget-open] [data-open]) {
overflow: clip !important;
}
@media screen and (min-width: 768px) {
:root {
+135 -1
View File
@@ -231,6 +231,12 @@
"value": "Særlige ønsker (valgfrit)"
}
],
"ancillaries.label.quantity": [
{
"type": 0,
"value": "Antal"
}
],
"ancillaries.unableToDisplayBreakfastPrices": [
{
"type": 0,
@@ -249,6 +255,18 @@
"value": "Jeg accepterer booking- og annulleringsbetingelserne"
}
],
"booking.alert.extraBeds.text": [
{
"type": 0,
"value": "Ved booking for mere end 2 gæster vil der blive opkrævet et ekstra gebyr pr. person. Se link for detaljer."
}
],
"booking.alert.extraguests": [
{
"type": 0,
"value": "Ekstra gæst(er)"
}
],
"booking.approx": [
{
"type": 0,
@@ -953,6 +971,16 @@
"value": "Tilføj kode"
}
],
"bookingWidget.bookingCode.readMore": [
{
"type": 0,
"value": "Læs mere om brug af "
},
{
"type": 1,
"value": "codeVoucher"
}
],
"bookingWidget.bookingCode.remember": [
{
"type": 0,
@@ -1061,6 +1089,16 @@
"value": "details"
}
],
"bookingWidget.reward.readMore": [
{
"type": 0,
"value": "Læs mere om booking med "
},
{
"type": 1,
"value": "reward"
}
],
"bookingWidget.reward.rewardNight": [
{
"type": 0,
@@ -2510,6 +2548,44 @@
"value": "Datoer"
}
],
"designSystem.stepper.ariaLabel.currentCount": [
{
"type": 0,
"value": "Nuværende "
},
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "count"
}
],
"designSystem.stepper.ariaLabel.decrease": [
{
"type": 0,
"value": "Reducer "
},
{
"type": 1,
"value": "label"
}
],
"designSystem.stepper.ariaLabel.increase": [
{
"type": 0,
"value": "Øg "
},
{
"type": 1,
"value": "label"
}
],
"destination.backToCities": [
{
"type": 0,
@@ -5783,7 +5859,7 @@
"myPages.myStay.ancillaries.reachedMaxItemsStepperMessage": [
{
"type": 0,
"value": "Maksimal antal nået for denne vare."
"value": "Maksimalt antal nået for denne vare."
}
],
"myPages.myStay.ancillaries.reachedMaxPointsMessage": [
@@ -7569,6 +7645,58 @@
"value": "Samlede ophold"
}
],
"price.numberOfEuroBonusPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "EB-point"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "EB-point"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfEuroBonusPoints"
}
],
"price.numberOfScandicPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "Point"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "Point"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfScandicPoints"
}
],
"price.numberOfVouchers": [
{
"offset": 0,
@@ -8629,6 +8757,12 @@
"value": "). Se tilgængelige priser nedenfor."
}
],
"selectRate.alert.reservationPolicies": [
{
"type": 0,
"value": "Se prisdetaljer"
}
],
"selectRate.availableRooms.all": [
{
"offset": 0,
+138
View File
@@ -231,6 +231,12 @@
"value": "Sonderwünsche (optional)"
}
],
"ancillaries.label.quantity": [
{
"type": 0,
"value": "Menge"
}
],
"ancillaries.unableToDisplayBreakfastPrices": [
{
"type": 0,
@@ -249,6 +255,18 @@
"value": "Ich akzeptiere die Buchungs- und Stornierungsbedingungen"
}
],
"booking.alert.extraBeds.text": [
{
"type": 0,
"value": "Bei Buchungen für mehr als 2 Gäste fällt eine zusätzliche Gebühr pro Person an. Siehe Link für Details."
}
],
"booking.alert.extraguests": [
{
"type": 0,
"value": "Zusätzliche Gäste"
}
],
"booking.approx": [
{
"type": 0,
@@ -949,6 +967,16 @@
"value": "Code hinzufügen"
}
],
"bookingWidget.bookingCode.readMore": [
{
"type": 0,
"value": "Mehr erfahren über die Verwendung von "
},
{
"type": 1,
"value": "codeVoucher"
}
],
"bookingWidget.bookingCode.remember": [
{
"type": 0,
@@ -1057,6 +1085,20 @@
"value": "details"
}
],
"bookingWidget.reward.readMore": [
{
"type": 0,
"value": "Mehr über Buchungen mit "
},
{
"type": 1,
"value": "reward"
},
{
"type": 0,
"value": "erfahren"
}
],
"bookingWidget.reward.rewardNight": [
{
"type": 0,
@@ -2522,6 +2564,44 @@
"value": "Daten"
}
],
"designSystem.stepper.ariaLabel.currentCount": [
{
"type": 0,
"value": "Aktuell "
},
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "count"
}
],
"designSystem.stepper.ariaLabel.decrease": [
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": "verringern"
}
],
"designSystem.stepper.ariaLabel.increase": [
{
"type": 0,
"value": "Erhöhen "
},
{
"type": 1,
"value": "label"
}
],
"destination.backToCities": [
{
"type": 0,
@@ -7570,6 +7650,58 @@
"value": "Aufenthalte insgesamt"
}
],
"price.numberOfEuroBonusPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "EB-Punkt"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "EB-Punkte"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfEuroBonusPoints"
}
],
"price.numberOfScandicPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "Punkt"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "Punkte"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfScandicPoints"
}
],
"price.numberOfVouchers": [
{
"offset": 0,
@@ -8634,6 +8766,12 @@
"value": "). Verfügbare Übernachtungspreise sind unten zu sehen."
}
],
"selectRate.alert.reservationPolicies": [
{
"type": 0,
"value": "Tarifdetails anzeigen"
}
],
"selectRate.availableRooms.all": [
{
"offset": 0,
+290
View File
@@ -231,6 +231,12 @@
"value": "Special requests (optional)"
}
],
"ancillaries.label.quantity": [
{
"type": 0,
"value": "Quantity"
}
],
"ancillaries.unableToDisplayBreakfastPrices": [
{
"type": 0,
@@ -249,6 +255,18 @@
"value": "I accept the booking and cancellation terms"
}
],
"booking.alert.extraBeds.text": [
{
"type": 0,
"value": "When booking for more than 2 guests, an additional fee will apply per person. See link for details."
}
],
"booking.alert.extraguests": [
{
"type": 0,
"value": "Extra guest(s)"
}
],
"booking.approx": [
{
"type": 0,
@@ -337,6 +355,12 @@
"value": "Change or cancel"
}
],
"booking.changeTitle": [
{
"type": 0,
"value": "Change"
}
],
"booking.codeVoucher": [
{
"type": 0,
@@ -953,6 +977,16 @@
"value": "Add code"
}
],
"bookingWidget.bookingCode.readMore": [
{
"type": 0,
"value": "Read more about using "
},
{
"type": 1,
"value": "codeVoucher"
}
],
"bookingWidget.bookingCode.remember": [
{
"type": 0,
@@ -1061,6 +1095,16 @@
"value": "details"
}
],
"bookingWidget.reward.readMore": [
{
"type": 0,
"value": "Read more about booking with "
},
{
"type": 1,
"value": "reward"
}
],
"bookingWidget.reward.rewardNight": [
{
"type": 0,
@@ -2198,6 +2242,16 @@
"value": " points"
}
],
"common.pointsInLine": [
{
"type": 1,
"value": "points"
},
{
"type": 0,
"value": " points"
}
],
"common.pointsToSpend": [
{
"type": 0,
@@ -2510,6 +2564,44 @@
"value": "Dates"
}
],
"designSystem.stepper.ariaLabel.currentCount": [
{
"type": 0,
"value": "Current "
},
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "count"
}
],
"designSystem.stepper.ariaLabel.decrease": [
{
"type": 0,
"value": "Decrease "
},
{
"type": 1,
"value": "label"
}
],
"designSystem.stepper.ariaLabel.increase": [
{
"type": 0,
"value": "Increase "
},
{
"type": 1,
"value": "label"
}
],
"destination.backToCities": [
{
"type": 0,
@@ -3060,6 +3152,12 @@
"value": "When you confirm the booking the room will be guaranteed for late arrival. If you fail to arrive without cancelling in advance or if you cancel after 18:00 local time, you will be charged for one reward night."
}
],
"enterDetails.confirmBooking.rewardNightGuaranteeInfo": [
{
"type": 0,
"value": "When you complete the booking the room will be guaranteed for late arrival. The hotel will hold your booking, even if you arrive after 18:00. In case of a no-show, you will be charged for one reward night."
}
],
"enterDetails.details.description": [
{
"type": 0,
@@ -3344,6 +3442,40 @@
"value": "Select payment method"
}
],
"enterDetails.paymentStep.flexBookingTermsAndConditions": [
{
"type": 0,
"value": "To complete your booking, please accept the general "
},
{
"children": [
{
"type": 0,
"value": "Booking & Cancellation Terms"
}
],
"type": 8,
"value": "termsAndConditionsLink"
},
{
"type": 0,
"value": ", and acknowledge that your data will be processed in accordance with Scandic's "
},
{
"children": [
{
"type": 0,
"value": "Privacy policy"
}
],
"type": 8,
"value": "privacyPolicyLink"
},
{
"type": 0,
"value": "."
}
],
"enterDetails.priceChangeDialog.acceptButton": [
{
"type": 0,
@@ -5669,6 +5801,24 @@
"value": "!"
}
],
"myPages.l6progress.modal.title": [
{
"type": 0,
"value": "Level upgrade and membership year"
}
],
"myPages.l6progress.modal.youCanAlsoReach": [
{
"type": 0,
"value": "You can also reach Best Friend, our highest membership level, by staying 100 nights with us within a membership year."
}
],
"myPages.l6progress.modal.yourLevelDuring": [
{
"type": 0,
"value": "Your level during the current and next period is based on the points you earn during this 12-month period."
}
],
"myPages.leftToLevelUp": [
{
"type": 0,
@@ -6057,6 +6207,88 @@
"value": "Your membership"
}
],
"myPoints.pointTransactions.extrasToBooking": [
{
"type": 0,
"value": "Extras to your booking"
}
],
"myPoints.pointTransactions.formerScandicHotel": [
{
"type": 0,
"value": "Former Scandic Hotel"
}
],
"myPoints.pointTransactions.noTransactions": [
{
"type": 0,
"value": "No transactions available"
}
],
"myPoints.pointTransactions.pointShop": [
{
"type": 0,
"value": "Scandic Friends Point Shop"
}
],
"myPoints.pointTransactions.pointsActivity": [
{
"type": 0,
"value": "Point activity"
}
],
"myPoints.pointTransactions.pointsEarnedPriorMay2021": [
{
"type": 0,
"value": "Points earned prior to May 1, 2021"
}
],
"myPoints.pointTransactions.redGift": [
{
"type": 0,
"value": "Reward Gift"
}
],
"myPoints.pointTransactions.rewardNight": [
{
"type": 0,
"value": "Reward Night"
}
],
"myPoints.pointTransactions.scandicFriendsMastercard": [
{
"type": 0,
"value": "Scandic Friends Mastercard"
}
],
"myPoints.pointTransactions.showMoreTransactions": [
{
"type": 0,
"value": "Show more transactions"
}
],
"myPoints.pointTransactions.signUpBonus": [
{
"type": 0,
"value": "Sign up bonus"
}
],
"myPoints.pointTransactions.stayAt": [
{
"type": 0,
"value": "Stay at "
},
{
"type": 1,
"value": "hotelName"
}
],
"myPoints.pointTransactions.tuiPoints": [
{
"type": 0,
"value": "TUI Points"
}
],
"myStay.accessDenied.bookingNotFound": [
{
"type": 0,
@@ -7546,6 +7778,58 @@
"value": "Total stays"
}
],
"price.numberOfEuroBonusPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "EB Point"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "EB Points"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfEuroBonusPoints"
}
],
"price.numberOfScandicPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "Point"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "Points"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfScandicPoints"
}
],
"price.numberOfVouchers": [
{
"offset": 0,
@@ -8610,6 +8894,12 @@
"value": "). See available rates below."
}
],
"selectRate.alert.reservationPolicies": [
{
"type": 0,
"value": "See rate details"
}
],
"selectRate.availableRooms.all": [
{
"offset": 0,
+142
View File
@@ -231,6 +231,12 @@
"value": "Erityistoiveet (valinnainen)"
}
],
"ancillaries.label.quantity": [
{
"type": 0,
"value": "Määrä"
}
],
"ancillaries.unableToDisplayBreakfastPrices": [
{
"type": 0,
@@ -249,6 +255,18 @@
"value": "Hyväksyn varaus- ja peruutusehdot"
}
],
"booking.alert.extraBeds.text": [
{
"type": 0,
"value": "Kun varaat yli 2 vieraalle, lisämaksu veloitetaan per henkilö. Katso lisätietoja linkistä."
}
],
"booking.alert.extraguests": [
{
"type": 0,
"value": "Ylimääräinen vieras/vieraat"
}
],
"booking.approx": [
{
"type": 0,
@@ -953,6 +971,20 @@
"value": "Lisää koodi"
}
],
"bookingWidget.bookingCode.readMore": [
{
"type": 0,
"value": "Lue lisää "
},
{
"type": 1,
"value": "codeVoucher"
},
{
"type": 0,
"value": ":n käytöstä"
}
],
"bookingWidget.bookingCode.remember": [
{
"type": 0,
@@ -1061,6 +1093,20 @@
"value": "details"
}
],
"bookingWidget.reward.readMore": [
{
"type": 0,
"value": "Lue lisää "
},
{
"type": 1,
"value": "reward"
},
{
"type": 0,
"value": " varaamisesta"
}
],
"bookingWidget.reward.rewardNight": [
{
"type": 0,
@@ -2514,6 +2560,44 @@
"value": "Päivämäärät"
}
],
"designSystem.stepper.ariaLabel.currentCount": [
{
"type": 0,
"value": "Nykyinen "
},
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "count"
}
],
"designSystem.stepper.ariaLabel.decrease": [
{
"type": 0,
"value": "Vähennä "
},
{
"type": 1,
"value": "label"
}
],
"designSystem.stepper.ariaLabel.increase": [
{
"type": 0,
"value": "Lisää "
},
{
"type": 1,
"value": "label"
}
],
"destination.backToCities": [
{
"type": 0,
@@ -7586,6 +7670,58 @@
"value": "Majoitukset yhteensä"
}
],
"price.numberOfEuroBonusPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "EB-piste"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "EB-pistettä"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfEuroBonusPoints"
}
],
"price.numberOfScandicPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "Piste"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "pistettä"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfScandicPoints"
}
],
"price.numberOfVouchers": [
{
"offset": 0,
@@ -8658,6 +8794,12 @@
"value": "). Katso saatavilla olevat hinnat alla."
}
],
"selectRate.alert.reservationPolicies": [
{
"type": 0,
"value": "Katso hinnan tiedot"
}
],
"selectRate.availableRooms.all": [
{
"offset": 0,
+134
View File
@@ -231,6 +231,12 @@
"value": "Spesielle ønsker (valgfritt)"
}
],
"ancillaries.label.quantity": [
{
"type": 0,
"value": "Antall"
}
],
"ancillaries.unableToDisplayBreakfastPrices": [
{
"type": 0,
@@ -249,6 +255,18 @@
"value": "Jeg godtar bestillings- og avbestillingsvilkårene"
}
],
"booking.alert.extraBeds.text": [
{
"type": 0,
"value": "Ved bestilling for mer enn 2 gjester vil det påløpe et tilleggsgebyr per person. Se lenke for detaljer."
}
],
"booking.alert.extraguests": [
{
"type": 0,
"value": "Ekstra gjest(er)"
}
],
"booking.approx": [
{
"type": 0,
@@ -953,6 +971,16 @@
"value": "Legg til kode"
}
],
"bookingWidget.bookingCode.readMore": [
{
"type": 0,
"value": "Les mer om bruk av "
},
{
"type": 1,
"value": "codeVoucher"
}
],
"bookingWidget.bookingCode.remember": [
{
"type": 0,
@@ -1061,6 +1089,16 @@
"value": "details"
}
],
"bookingWidget.reward.readMore": [
{
"type": 0,
"value": "Les mer om bestilling med "
},
{
"type": 1,
"value": "reward"
}
],
"bookingWidget.reward.rewardNight": [
{
"type": 0,
@@ -2510,6 +2548,44 @@
"value": "Datoer"
}
],
"designSystem.stepper.ariaLabel.currentCount": [
{
"type": 0,
"value": "Gjeldende "
},
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "count"
}
],
"designSystem.stepper.ariaLabel.decrease": [
{
"type": 0,
"value": "Reduser "
},
{
"type": 1,
"value": "label"
}
],
"designSystem.stepper.ariaLabel.increase": [
{
"type": 0,
"value": "Øk "
},
{
"type": 1,
"value": "label"
}
],
"destination.backToCities": [
{
"type": 0,
@@ -7582,6 +7658,58 @@
"value": "Totalt antall opphold"
}
],
"price.numberOfEuroBonusPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "EB-poeng"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "EB-poeng"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfEuroBonusPoints"
}
],
"price.numberOfScandicPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "Poeng"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "Poeng"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfScandicPoints"
}
],
"price.numberOfVouchers": [
{
"offset": 0,
@@ -8654,6 +8782,12 @@
"value": " ). Se tilgjengelige priser nedenfor."
}
],
"selectRate.alert.reservationPolicies": [
{
"type": 0,
"value": "Se prisdetaljer"
}
],
"selectRate.availableRooms.all": [
{
"offset": 0,
+135 -1
View File
@@ -231,6 +231,12 @@
"value": "Särskilda önskemål (valfritt)"
}
],
"ancillaries.label.quantity": [
{
"type": 0,
"value": "Antal"
}
],
"ancillaries.unableToDisplayBreakfastPrices": [
{
"type": 0,
@@ -249,6 +255,18 @@
"value": "Jag accepterar boknings- och avbokningsvillkoren"
}
],
"booking.alert.extraBeds.text": [
{
"type": 0,
"value": "När du bokar för fler än 2 gäster tillkommer en extra avgift per person. Se länk för detaljer."
}
],
"booking.alert.extraguests": [
{
"type": 0,
"value": "Extra gäst(er)"
}
],
"booking.approx": [
{
"type": 0,
@@ -953,6 +971,16 @@
"value": "Lägg till kod"
}
],
"bookingWidget.bookingCode.readMore": [
{
"type": 0,
"value": "Läs mer om att använda "
},
{
"type": 1,
"value": "codeVoucher"
}
],
"bookingWidget.bookingCode.remember": [
{
"type": 0,
@@ -1061,6 +1089,16 @@
"value": "details"
}
],
"bookingWidget.reward.readMore": [
{
"type": 0,
"value": "Läs mer om bokning med "
},
{
"type": 1,
"value": "reward"
}
],
"bookingWidget.reward.rewardNight": [
{
"type": 0,
@@ -2510,6 +2548,44 @@
"value": "Datum"
}
],
"designSystem.stepper.ariaLabel.currentCount": [
{
"type": 0,
"value": "Aktuell "
},
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "count"
}
],
"designSystem.stepper.ariaLabel.decrease": [
{
"type": 0,
"value": "Minska "
},
{
"type": 1,
"value": "label"
}
],
"designSystem.stepper.ariaLabel.increase": [
{
"type": 0,
"value": "Öka "
},
{
"type": 1,
"value": "label"
}
],
"destination.backToCities": [
{
"type": 0,
@@ -3845,7 +3921,7 @@
"findMyBooking.findYourStay": [
{
"type": 0,
"value": "Hitta ditt hotell"
"value": "Hitta din bokning"
}
],
"findMyBooking.manageBooking": [
@@ -7566,6 +7642,58 @@
"value": "Totalt antal vistelser"
}
],
"price.numberOfEuroBonusPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "EB-poäng"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "EB-poäng"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfEuroBonusPoints"
}
],
"price.numberOfScandicPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "Punkt"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "Poäng"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfScandicPoints"
}
],
"price.numberOfVouchers": [
{
"offset": 0,
@@ -8626,6 +8754,12 @@
"value": "). Se tillgängliga priser nedan."
}
],
"selectRate.alert.reservationPolicies": [
{
"type": 0,
"value": "Se bokningsvillkor"
}
],
"selectRate.availableRooms.all": [
{
"offset": 0,
+5 -5
View File
@@ -22,13 +22,13 @@
},
"dependencies": {
"@formatjs/intl": "^3.1.6",
"@netlify/plugin-nextjs": "^5.15.1",
"@netlify/plugin-nextjs": "^5.15.7",
"@scandic-hotels/booking-flow": "workspace:*",
"@scandic-hotels/design-system": "workspace:*",
"@scandic-hotels/tracking": "workspace:*",
"@scandic-hotels/trpc": "workspace:*",
"@sentry/nextjs": "^10.33.0",
"@swc/plugin-formatjs": "^3.2.2",
"@swc/plugin-formatjs": "^8.1.0",
"@t3-oss/env-nextjs": "^0.13.4",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
@@ -36,11 +36,11 @@
"@trpc/server": "^11.1.2",
"class-variance-authority": "^0.7.1",
"iron-session": "^8.0.4",
"next": "16.0.10",
"next": "16.1.6",
"next-auth": "5.0.0-beta.29",
"react": "19.2.1",
"react": "19.2.4",
"react-aria-components": "1.8.0",
"react-dom": "19.2.1",
"react-dom": "19.2.4",
"react-intl": "^7.1.11",
"server-only": "^0.0.1",
"usehooks-ts": "3.1.1",
+1
View File
@@ -52,3 +52,4 @@ DTMC_ENTRA_ID_CLIENT=""
DTMC_ENTRA_ID_ISSUER=""
DTMC_ENTRA_ID_SECRET=""
NEXT_PUBLIC_NEW_POINTCLAIMS="true"
+12
View File
@@ -16,6 +16,18 @@ yarn workspace @scandic-hotels/design-system build
yarn dev
```
To run only scandic web
```bash
yarn dev:web
```
To run only partner sas
```bash
yarn dev:sas
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### Caching
@@ -4,7 +4,8 @@
.layout {
display: grid;
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
grid-template-rows: auto 1fr;
min-height: 100dvh;
max-width: var(--max-width-page);
@@ -1,12 +1,7 @@
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import {
getEurobonusMembership,
scandicMembershipTypes,
} from "@scandic-hotels/trpc/routers/user/helpers"
import { getEurobonusMembership } from "@scandic-hotels/trpc/routers/user/helpers"
import { env } from "@/env/server"
import {
getBasicProfileSafely,
getProfileSafely,
getProfilingConsent,
} from "@/lib/trpc/memoizedRequests"
@@ -26,15 +21,7 @@ type MyPagesLayoutProps = React.PropsWithChildren<{
breadcrumbs: React.ReactNode
}>
export default async function MyPagesLayout(props: MyPagesLayoutProps) {
if (env.ENABLE_PROFILE_CONSENT) {
return <MyPagesLayoutWithConsent {...props} />
}
return <MyPagesLayoutBase {...props} />
}
async function MyPagesLayoutWithConsent({
export default async function MyPagesLayout({
breadcrumbs,
children,
}: MyPagesLayoutProps) {
@@ -84,25 +71,3 @@ async function MyPagesLayoutWithConsent({
</ProfilingConsentAlertProvider>
)
}
async function MyPagesLayoutBase({
breadcrumbs,
children,
}: MyPagesLayoutProps) {
const profile = await getBasicProfileSafely()
const eurobonusMembership = profile?.loyalty?.memberships?.find(
(m) => m.membershipType === scandicMembershipTypes.SAS_EB
)
return (
<div className={styles.container}>
<div className={styles.layout}>
{breadcrumbs}
<div className={styles.content}>{children}</div>
</div>
{eurobonusMembership && <SASLevelUpgradeCheck />}
<Surprises />
</div>
)
}
@@ -1,24 +1,13 @@
import { redirect } from "next/navigation"
import { profile } from "@scandic-hotels/common/constants/routes/myPages"
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import { env } from "@/env/server"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import { ProfilingConsent } from "@/components/Forms/ProfilingConsent"
import { getLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
export default async function ProfilingConsentSlot() {
const lang = await getLang()
if (!env.ENABLE_PROFILE_CONSENT) {
redirect(profile[lang])
}
const caller = await serverClient()
const accountPage = await caller.contentstack.accountPage.get()
const user = await getProfile()
@@ -1,6 +1,7 @@
.layout {
display: grid;
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
grid-template-rows: auto 1fr;
position: relative;
}
@@ -1,3 +1,4 @@
.layout {
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
}
@@ -5,6 +5,7 @@ import { useTransition } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { sasPartnershipTermsAndConditions } from "@scandic-hotels/common/constants/routes/customerService"
import { profileEdit } from "@scandic-hotels/common/constants/routes/myPages"
import { Button } from "@scandic-hotels/design-system/Button"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
@@ -13,8 +14,6 @@ import Image from "@scandic-hotels/design-system/Image"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { sasPartnershipTermsAndConditions } from "@/constants/webHrefs"
import styles from "./link-sas.module.css"
import type { LangParams } from "@/types/params"
@@ -1,9 +1,8 @@
import { redirect } from "next/navigation"
import { z } from "zod"
import Footnote from "@scandic-hotels/design-system/Footnote"
import Image from "@scandic-hotels/design-system/Image"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { env } from "@/env/server"
@@ -94,27 +93,24 @@ export default async function SASxScandicLoginPage(
{intentDescriptions[parsedParams.intent]}
</p>
</Typography>
<Footnote textAlign="center">
{intl.formatMessage(
{
id: "linkEuroBonusAccount.manualRedirectLinkMessage",
defaultMessage:
"If you are not redirected automatically, please <loginLink>click here</loginLink>.",
},
{
loginLink: (str) => (
<Link
href={loginLink}
color="red"
size="tiny"
textDecoration="underline"
>
{str}
</Link>
),
}
)}
</Footnote>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p style={{ textAlign: "center" }}>
{intl.formatMessage(
{
id: "linkEuroBonusAccount.manualRedirectLinkMessage",
defaultMessage:
"If you are not redirected automatically, please <loginLink>click here</loginLink>.",
},
{
loginLink: (str) => (
<TextLink typography="Link/sm" href={loginLink}>
{str}
</TextLink>
),
}
)}
</p>
</Typography>
</SASModal>
)
}
@@ -42,7 +42,8 @@
width: 34px;
height: 0px;
padding: var(--Space-x3) 0;
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
border: 1px solid var(--Base-Border-Normal);
border-radius: var(--Corner-Radius-md);
text-align: center;
@@ -1,5 +1,6 @@
.layout {
background-color: var(--Background-Primary);
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
min-height: 100dvh;
}
+6
View File
@@ -88,6 +88,12 @@ body:has([data-booking-widget-open="true"]) #kindly-chat-api {
z-index: -1 !important;
}
/* When a select in the booking widget is open, react-aria sets overflow:hidden
which breaks sticky positioning. Override with clip which doesn't break sticky. */
body:has([data-booking-widget-open] [data-open]) {
overflow: clip !important;
}
@media screen and (min-width: 768px) {
:root {
--max-width-single-spacing: var(--Layout-Tablet-Margin-Margin-min);
+2 -1
View File
@@ -1,3 +1,4 @@
.layout {
font-family: var(--typography-Body-Regular-fontFamily);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
}
@@ -1,13 +1,29 @@
import { Alert } from "@scandic-hotels/design-system/Alert"
import { getAlertPhoneContactData } from "@scandic-hotels/trpc/routers/contentstack/base/utils"
import { serverClient } from "@/lib/trpc/server"
import type { AlertBlock } from "@scandic-hotels/trpc/types/blocks"
interface AlertBlockProps extends Pick<AlertBlock, "alert"> {}
export function AlertBlock({ alert }: AlertBlockProps) {
export async function AlertBlock({ alert }: AlertBlockProps) {
const caller = await serverClient()
const contactConfig = await caller.contentstack.base.contact()
if (!alert) {
return null
}
return <Alert {...alert} />
const phoneContact =
alert.phoneContact && contactConfig
? getAlertPhoneContactData(alert, contactConfig)
: null
return (
<Alert
{...alert}
phoneContact={phoneContact}
sidepeekCtaText={alert.sidepeekButton?.cta_text}
/>
)
}
@@ -16,18 +16,14 @@
.iconTh {
padding: var(--Space-x5) var(--Space-x2) var(--Space-x2);
font-weight: var(--typography-Caption-Regular-fontWeight);
vertical-align: bottom;
}
.summaryTh {
font-size: var(--typography-Caption-Regular-fontSize);
font-weight: var(--typography-Caption-Regular-fontWeight);
padding: 0 var(--Space-x2) var(--Space-x2);
vertical-align: top;
}
.select {
font-weight: var(--typography-Caption-Regular-fontWeight);
padding: 0 var(--Space-x2) var(--Space-x2);
}
@@ -1,3 +1,5 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import MembershipLevelIcon from "@/components/Levels/Icon"
import LevelSummary from "../../LevelSummary"
@@ -37,12 +39,14 @@ export default function DesktopHeader({
<th />
{levels.map((level, idx) => {
return (
<th
key={"summary" + level.level_id + idx}
className={styles.summaryTh}
<Typography
variant="Body/Supporting text (caption)/smRegular"
key={"name" + level.level_id + idx}
>
<LevelSummary level={level} />
</th>
<th className={styles.summaryTh}>
<LevelSummary level={level} />
</th>
</Typography>
)
})}
</tr>
@@ -82,10 +82,12 @@ function RewardTableHeader({ name, description }: RewardTableHeaderProps) {
</span>
</hgroup>
</summary>
<p
className={styles.rewardDescription}
dangerouslySetInnerHTML={{ __html: description }}
/>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p
className={styles.rewardDescription}
dangerouslySetInnerHTML={{ __html: description }}
/>
</Typography>
</details>
)
}
@@ -15,14 +15,11 @@
}
.td {
font-size: var(--typography-Footnote-Regular-fontSize);
text-align: center;
}
.rewardTh {
padding: var(--Space-x3) var(--Space-x2);
font-size: var(--typography-Caption-Regular-fontSize);
font-weight: var(--typography-Caption-Regular-fontWeight);
}
.details[open] .chevron {
@@ -1,5 +1,7 @@
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./levelSummary.module.css"
import type { LevelSummaryProps } from "@/types/components/overviewTable"
@@ -32,7 +34,9 @@ export default function LevelSummary({
return (
<div className={styles.levelSummary}>
<span className={styles.levelRequirements}>{pointsMsg}</span>
<Typography variant="Label/xsRegular">
<span className={styles.levelRequirements}>{pointsMsg}</span>
</Typography>
{showDescription && (
<p className={styles.levelSummaryText}>{level.description}</p>
)}
@@ -8,16 +8,14 @@
.levelRequirements {
border-radius: var(--Corner-Radius-md);
background-color: var(--Scandic-Brand-Pale-Peach);
color: var(--Scandic-Peach-80);
background-color: var(--Surface-Brand-Primary-1-Default);
color: var(--Text-Interactive-Secondary);
padding: var(--Space-x05) var(--Space-x1);
text-align: center;
width: 100%;
}
.levelSummaryText {
font-size: var(--typography-Caption-Regular-fontSize);
line-height: var(--typography-Body-Regular-lineHeight);
margin: 0;
}
@@ -26,12 +24,3 @@
padding: var(--Space-x05) var(--Space-x1);
}
}
@media screen and (min-width: 1367px) {
.levelRequirements {
font-size: var(--typography-Footnote-Regular-fontSize);
}
.levelSummaryText {
font-size: var(--typography-Caption-Regular-fontSize);
}
}
@@ -27,10 +27,12 @@ export default function RewardCard({
</span>
</hgroup>
</summary>
<p
className={styles.rewardCardDescription}
dangerouslySetInnerHTML={{ __html: description }}
/>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p
className={styles.rewardCardDescription}
dangerouslySetInnerHTML={{ __html: description }}
/>
</Typography>
</details>
</div>
<div className={styles.rewardComparison}>
@@ -12,8 +12,6 @@
}
.rewardCardDescription {
font-size: var(--typography-Caption-Regular-fontSize);
line-height: 150%;
padding-right: var(--Space-x4);
}
@@ -1,6 +1,7 @@
import { Minus } from "react-feather"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./rewardValue.module.css"
@@ -21,8 +22,8 @@ export default function RewardValue({ reward }: RewardValueProps) {
)
}
return (
<div className={styles.rewardValueContainer}>
<span className={styles.rewardValue}>{reward.value}</span>
</div>
<Typography variant="Body/Paragraph/mdBold">
<div className={styles.rewardValueContainer}>{reward.value}</div>
</Typography>
)
}
@@ -7,17 +7,6 @@
text-wrap: balance;
}
.rewardValue {
font-size: var(--typography-Body-Bold-fontSize);
font-weight: var(--typography-Body-Bold-fontWeight);
}
.rewardValueDetails {
font-size: var(--typography-Footnote-Regular-fontSize);
text-align: center;
color: var(--UI-Grey-80);
}
.checkIcon {
display: inline-flex;
}
@@ -0,0 +1,430 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
/* TODO remove disable and add i18n */
/* TODO add analytics */
import { zodResolver } from "@hookform/resolvers/zod"
import { cx } from "class-variance-authority"
import { useState } from "react"
import { FormProvider, useForm, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import z from "zod"
import { dt } from "@scandic-hotels/common/dt"
import { Button } from "@scandic-hotels/design-system/Button"
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
import { MessageBanner } from "@scandic-hotels/design-system/MessageBanner"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
import useLang from "@/hooks/useLang"
import styles from "./claimPoints.module.css"
type PointClaimBookingInfo = {
from: string
to: string
city: string
hotel: string
}
export function ClaimPointsWizard({
onSuccess,
onClose,
}: {
onSuccess: () => void
onClose: () => void
}) {
const [state, setState] = useState<
"initial" | "loading" | "invalid" | "form"
>("initial")
const [bookingDetails, setBookingDetails] =
useState<PointClaimBookingInfo | null>(null)
const { data, isLoading } = trpc.user.getSafely.useQuery()
if (state === "invalid") {
return <InvalidBooking onClose={onClose} />
}
if (state === "form") {
if (isLoading) {
return null
}
return (
<ClaimPointsForm
onSuccess={onSuccess}
initialData={{
...bookingDetails,
firstName: data?.firstName ?? "",
lastName: data?.lastName ?? "",
email: data?.email ?? "",
phone: data?.phoneNumber ?? "",
}}
/>
)
}
const handleBookingNumberEvent = (event: BookingNumberEvent) => {
switch (event.type) {
case "submit":
setState("loading")
break
case "error":
setState("initial")
break
case "invalid":
setState("invalid")
break
case "success":
setBookingDetails(event.data)
setState("form")
break
}
}
return (
<div className={styles.introWrapper}>
{state === "loading" && (
<div
className={styles.spinner}
aria-live="polite"
aria-label="Loading booking details, please wait.."
>
<LoadingSpinner />
</div>
)}
<div
className={cx(styles.options, { [styles.hidden]: state === "loading" })}
>
<section className={styles.sectionCard}>
<div className={styles.sectionInfo}>
<Typography variant="Body/Paragraph/mdBold">
<h4>Claim points with booking number</h4>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
Enter a valid booking number to load booking details
automatically.
</p>
</Typography>
</div>
<BookingNumberInput onEvent={handleBookingNumberEvent} />
</section>
<Divider />
<section className={styles.sectionCard}>
<div className={styles.sectionInfo}>
<Typography variant="Body/Paragraph/mdBold">
<h4>Claim points without booking number</h4>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>You need to add booking details in a form.</p>
</Typography>
</div>
<Button variant="Secondary" onPress={() => setState("form")}>
Fill form to claim points
</Button>
</section>
</div>
<MessageBanner
type="info"
text="Points can be claimed up to 6 months back if you were a member at the time of your stay."
/>
</div>
)
}
type BookingNumberFormData = {
bookingNumber: string
}
type BookingNumberEvent =
| { type: "submit" }
| { type: "success"; data: PointClaimBookingInfo }
| { type: "error" }
| { type: "invalid" }
function BookingNumberInput({
onEvent,
}: {
onEvent: (event: BookingNumberEvent) => void
}) {
const lang = useLang()
const form = useForm<BookingNumberFormData>({
resolver: zodResolver(
z.object({
bookingNumber: z
.string()
// TODO Check UX for validation as different environments have different lengths
.min(9, { message: "Booking number must be 10 digits" })
.max(10, { message: "Booking number must be 10 digits" }),
})
),
defaultValues: {
bookingNumber: "",
},
})
const confirmationNumber = useWatch({
name: "bookingNumber",
control: form.control,
})
const { refetch, isFetching } =
trpc.booking.findBookingForCurrentUser.useQuery(
{
confirmationNumber,
lang,
},
{ enabled: false }
)
const handleSubmit = async () => {
onEvent({ type: "submit" })
const result = await refetch()
if (!result.data) {
onEvent({ type: "error" })
form.setError("bookingNumber", {
type: "manual",
message:
"We could not find a booking with this number registered in your name.",
})
return
}
const data = result.data
// TODO validate if this should be check out or check in date
const checkOutDate = dt(data.booking.checkOutDate)
const sixMonthsAgo = dt().subtract(6, "months")
if (checkOutDate.isBefore(sixMonthsAgo, "day")) {
onEvent({ type: "invalid" })
return
}
onEvent({
type: "success",
data: {
from: data.booking.checkInDate,
to: data.booking.checkOutDate,
city: data.hotel.cityName,
hotel: data.hotel.name,
},
})
}
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<FormInput
name="bookingNumber"
label="Booking number"
leftIcon={<MaterialIcon icon="edit_document" />}
description="Enter your 10-digit booking number"
maxLength={10}
showClearContentIcon
disabled={isFetching}
autoFocus
autoComplete="off"
onChange={(e) => {
const value = e.target.value
if (value.length !== 10) return
form.handleSubmit(handleSubmit)()
}}
/>
</form>
</FormProvider>
)
}
function InvalidBooking({ onClose }: { onClose: () => void }) {
return (
<div className={styles.invalidWrapper}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
We cant add these points to your account as it has been longer than 6
months since your stay.
</p>
</Typography>
<Button variant="Primary" fullWidth onPress={onClose}>
Close
</Button>
</div>
)
}
type PointClaimUserInfo = {
firstName: string
lastName: string
email: string
phone: string
}
function ClaimPointsForm({
onSuccess,
initialData,
}: {
onSuccess: () => void
initialData: Partial<PointClaimBookingInfo & PointClaimUserInfo> | null
}) {
const form = useForm({
resolver: zodResolver(
z.object({
from: z.string().min(1, { message: "Arrival date is required" }),
to: z.string().min(1, { message: "Departure date is required" }),
city: z.string().min(1, { message: "City is required" }),
hotel: z.string().min(1, { message: "Hotel is required" }),
firstName: z.string().min(1, { message: "First name is required" }),
lastName: z.string().min(1, { message: "Last name is required" }),
email: z
.string()
.email("Enter a valid email")
.min(1, { message: "Email is required" }),
phone: z.string().min(1, { message: "Phone is required" }),
})
),
defaultValues: {
from: initialData?.from || "",
to: initialData?.to || "",
city: initialData?.city || "",
hotel: initialData?.hotel || "",
firstName: initialData?.firstName || "",
lastName: initialData?.lastName || "",
email: initialData?.email || "",
phone: initialData?.phone || "",
},
mode: "all",
})
const { mutate, isPending } = trpc.user.claimPoints.useMutation({
onSuccess,
})
const autoFocusField = getAutoFocus(initialData)
return (
<FormProvider {...form}>
<form
className={styles.form}
onSubmit={form.handleSubmit((data) => mutate(data))}
>
<div className={styles.formInputs}>
{!initialData?.firstName && (
<FormInput
name="firstName"
label="First name"
autoFocus={autoFocusField === "firstName"}
readOnly={isPending}
registerOptions={{ required: true }}
/>
)}
{!initialData?.lastName && (
<FormInput
name="lastName"
label="Last name"
autoFocus={autoFocusField === "lastName"}
readOnly={isPending}
registerOptions={{ required: true }}
/>
)}
{!initialData?.email && (
<FormInput
name="email"
label="Email"
type="email"
autoFocus={autoFocusField === "email"}
readOnly={isPending}
registerOptions={{ required: true }}
/>
)}
{!initialData?.phone && (
<FormInput
name="phone"
label="Phone"
type="tel"
autoFocus={autoFocusField === "phone"}
readOnly={isPending}
registerOptions={{ required: true }}
/>
)}
<FormInput
name="from"
label="Arrival (YYYY-MM-DD)"
leftIcon={<MaterialIcon icon="calendar_today" />}
autoFocus={autoFocusField === "from"}
readOnly={isPending}
registerOptions={{ required: true }}
/>
<FormInput
name="to"
label="Departure (YYYY-MM-DD)"
leftIcon={<MaterialIcon icon="calendar_today" />}
readOnly={isPending}
registerOptions={{ required: true }}
/>
<FormInput
name="city"
label="City"
readOnly={isPending}
registerOptions={{ required: true }}
/>
<FormInput
name="hotel"
label="Hotel"
readOnly={isPending}
registerOptions={{ required: true }}
/>
</div>
<MessageBanner
type="info"
text="Points can be claimed up to 6 months back if you were a member at the time of your stay."
/>
<Button
type="submit"
variant="Primary"
fullWidth
isDisabled={!form.formState.isValid}
isPending={isPending}
className={styles.formSubmit}
>
Send points claim
</Button>
</form>
</FormProvider>
)
}
function getAutoFocus(userInfo: Partial<PointClaimUserInfo> | null) {
if (!userInfo?.firstName) {
return "firstName"
}
if (!userInfo?.lastName) {
return "lastName"
}
if (!userInfo?.email) {
return "email"
}
if (!userInfo?.phone) {
return "phone"
}
return "from"
}
function Divider() {
const intl = useIntl()
return (
<div className={styles.divider}>
<Typography variant="Body/Paragraph/mdRegular">
<span>
{intl.formatMessage({
id: "common.or",
defaultMessage: "or",
})}
</span>
</Typography>
</div>
)
}
@@ -6,3 +6,100 @@
gap: var(--Space-x2);
white-space: nowrap;
}
.dialog {
max-width: 560px;
}
.introWrapper {
position: relative;
display: flex;
flex-direction: column;
gap: var(--Space-x3);
}
.options {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
}
.sectionCard {
display: flex;
flex-direction: column;
gap: var(--Space-x3);
padding: var(--Space-x2);
background-color: var(--Surface-Primary-OnSurface-Default);
border-radius: var(--Corner-Radius-md);
}
.sectionInfo {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
}
.spinner {
background-color: var(--Base-Surface-Primary-light-Normal);
position: absolute;
inset: 0;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
}
.bookingInputDescription {
display: flex;
align-items: center;
gap: var(--Space-x05);
}
.form {
display: flex;
flex-direction: column;
gap: var(--Space-x3);
}
.formInputs {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.formSubmit {
margin-top: auto;
}
.divider {
width: 100%;
position: relative;
display: flex;
justify-content: center;
& > span {
position: relative;
padding: 0 var(--Space-x2);
background-color: white;
}
&::before {
position: absolute;
bottom: calc(50% - 1px);
content: "";
display: block;
height: 1px;
width: 100%;
background-color: var(--Border-Default);
}
}
.hidden {
visibility: hidden;
}
.invalidWrapper {
display: flex;
flex-direction: column;
gap: var(--Space-x3);
}
@@ -1,18 +1,110 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
/* TODO remove disable and add i18n */
"use client"
import { useEffect, useState } from "react"
import { Dialog } from "react-aria-components"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Modal from "@scandic-hotels/design-system/Modal"
import { toast } from "@scandic-hotels/design-system/Toast"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { missingPoints } from "@/constants/missingPointsHrefs"
import { env } from "@/env/client"
import useLang from "@/hooks/useLang"
import { ClaimPointsWizard } from "./ClaimPointsWizard"
import styles from "./claimPoints.module.css"
export default function ClaimPoints() {
const intl = useIntl()
const [openModal, setOpenModal] = useLinkableModalState("claim-points")
const useNewFlow = env.NEXT_PUBLIC_NEW_POINTCLAIMS
if (!useNewFlow) {
return <OldClaimPointsLink />
}
return (
<>
<div className={styles.claim}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
id: "points.claimPoints.missingPreviousStay",
defaultMessage: "Missing a previous stay?",
})}
</p>
</Typography>
<Button variant="Text" size="sm" onPress={() => setOpenModal(true)}>
{intl.formatMessage({
id: "points.claimPoints.cta",
defaultMessage: "Claim points",
})}
</Button>
</div>
<Modal
title="Add missing points"
isOpen={openModal}
onToggle={(open) => setOpenModal(open)}
>
<Dialog aria-label="TODO" className={styles.dialog}>
{({ close }) => (
<ClaimPointsWizard
onSuccess={() => {
toast.info(
<>
<Typography variant="Body/Paragraph/mdBold">
<p>We&apos;re on it!</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
If your points have not been added to your account
within 2 weeks, please contact us.
</p>
</Typography>
</>,
{
duration: Infinity,
}
)
close()
}}
onClose={close}
/>
)}
</Dialog>
</Modal>
</>
)
}
function useLinkableModalState(target: string) {
const [openModal, setOpenModal] = useState(false)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const claimPoints = params.get("target") === target
if (claimPoints) {
params.delete("target")
const newUrl = `${window.location.pathname}?${params.toString()}`
window.history.replaceState({}, "", newUrl)
// eslint-disable-next-line react-hooks/set-state-in-effect
setOpenModal(true)
}
}, [target])
return [openModal, setOpenModal] as const
}
function OldClaimPointsLink() {
const intl = useIntl()
const lang = useLang()
@@ -26,14 +26,14 @@ export function PointTransactionRow({
focusRef: React.Ref<HTMLAnchorElement>
}) {
const intl = useIntl()
const { confirmationNumber, bookingUrl, checkinDate, awardPoints } =
const { confirmationNumber, bookingUrl, transactionDate, awardPoints } =
transaction.attributes
const balfwd = confirmationNumber === BALFWD
const nonTransactional = confirmationNumber === NON_TRANSACTIONAL
const day = checkinDate.split("-")[2].replace(/^0/, "")
const month = dt(checkinDate.split("-")[1]).locale(lang).format("MMM")
const date = dt.utc(transactionDate).locale(lang)
const day = date.format("D")
const month = date.format("MMM")
const formattedPoints = intl.formatNumber(Math.abs(awardPoints))
const calculatedPoints =
@@ -41,7 +41,12 @@ export function PointTransactionRow({
? formattedPoints
: `${awardPoints > 0 ? "+" : "-"} ${formattedPoints}`
const canLinkBookingUrl = !balfwd && !nonTransactional
const canLinkBookingUrl =
!balfwd &&
!nonTransactional &&
!!transaction.attributes.bookingUrl &&
(transaction.type === Transactions.rewardType.stay ||
transaction.type === Transactions.rewardType.rewardNight)
const description = getDescription(transaction, intl)
@@ -93,26 +98,29 @@ export function PointTransactionRow({
function getDescription(transaction: Transaction, intl: IntlShape) {
const hotelInformation = transaction.attributes.hotelInformation
const balfwd = transaction.attributes.confirmationNumber === BALFWD
const nonTransactional =
const isBalfwd = transaction.attributes.confirmationNumber === BALFWD
const isNonTransactional =
transaction.attributes.confirmationNumber === NON_TRANSACTIONAL
if (isNonTransactional && transaction.attributes.nights === 0) {
return intl.formatMessage({
id: "earnAndBurn.journeyTable.pointsActivity",
defaultMessage: "Point activity",
})
}
switch (transaction.type) {
case Transactions.rewardType.stay:
return nonTransactional && transaction.attributes.nights === 0
? intl.formatMessage({
id: "myPoints.pointTransactions.pointsActivity",
defaultMessage: "Point activity",
})
: hotelInformation?.name
? intl.formatMessage(
{
id: "myPoints.pointTransactions.stayAt",
defaultMessage: "Stay at {hotelName}",
},
{ hotelName: hotelInformation?.name }
)
: ""
if (hotelInformation?.name) {
return intl.formatMessage(
{
id: "earnAndBurn.journeyTable.stayAt",
defaultMessage: "Stay at {hotelName}",
},
{ hotelName: hotelInformation.name }
)
} else {
return ""
}
case Transactions.rewardType.stayAdj:
if (transaction.attributes.hotelOperaId === "ORS") {
return intl.formatMessage({
@@ -120,42 +128,52 @@ function getDescription(transaction: Transaction, intl: IntlShape) {
defaultMessage: "Former Scandic Hotel",
})
}
if (balfwd) {
if (isBalfwd) {
return intl.formatMessage({
id: "myPoints.pointTransactions.pointsEarnedPriorMay2021",
id: "earnAndBurn.journeyTable.pointsEarnedPriorMay2021",
defaultMessage: "Points earned prior to May 1, 2021",
})
}
break
case Transactions.rewardType.redgift:
return intl.formatMessage({
id: "earnAndBurn.journeyTable.redGift",
defaultMessage: "Reward Gift",
})
case Transactions.rewardType.rewardNight:
return intl.formatMessage({
id: "earnAndBurn.journeyTable.rewardNight",
defaultMessage: "Reward Night",
})
case Transactions.rewardType.ancillary:
return intl.formatMessage({
id: "myPoints.pointTransactions.extrasToBooking",
id: "earnAndBurn.journeyTable.extrasToBooking",
defaultMessage: "Extras to your booking",
})
case Transactions.rewardType.enrollment:
return intl.formatMessage({
id: "myPoints.pointTransactions.signUpBonus",
id: "earnAndBurn.journeyTable.signUpBonus",
defaultMessage: "Sign up bonus",
})
case Transactions.rewardType.mastercard_points:
return intl.formatMessage({
id: "myPoints.pointTransactions.scandicFriendsMastercard",
id: "earnAndBurn.journeyTable.scandicFriendsMastercard",
defaultMessage: "Scandic Friends Mastercard",
})
case Transactions.rewardType.tui_points:
return intl.formatMessage({
id: "myPoints.pointTransactions.tuiPoints",
id: "earnAndBurn.journeyTable.tuiPoints",
defaultMessage: "TUI Points",
})
case Transactions.rewardType.pointShop:
return intl.formatMessage({
id: "myPoints.pointTransactions.pointShop",
id: "earnAndBurn.journeyTable.pointShop",
defaultMessage: "Scandic Friends Point Shop",
})
default:
return undefined
}
}
@@ -2,6 +2,7 @@
import { Fragment, useCallback, useRef } from "react"
import { useIntl } from "react-intl"
import { dt } from "@scandic-hotels/common/dt"
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
import { StickyElementNameEnum } from "@scandic-hotels/common/stores/sticky-position"
import { Divider } from "@scandic-hotels/design-system/Divider"
@@ -40,9 +41,9 @@ export function PointTransactionList() {
.flatMap((page) => page.data)
const groupedTransactions =
transactions?.reduce<Record<number, typeof transactions>>(
transactions?.reduce<Record<string, typeof transactions>>(
(acc, transaction) => {
const year = new Date(transaction.attributes.checkinDate).getFullYear()
const year = dt.utc(transaction.attributes.transactionDate).year()
if (!acc[year]) acc[year] = []
acc[year].push(transaction)
return acc
@@ -1,5 +1,3 @@
import { env } from "@/env/server"
import SignupForm from "@/components/Forms/Signup"
import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
@@ -7,10 +5,5 @@ import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicCo
export default async function SignupFormWrapper({
dynamic_content,
}: SignupFormWrapperProps) {
return (
<SignupForm
{...dynamic_content}
enableProfileConsent={env.ENABLE_PROFILE_CONSENT}
/>
)
return <SignupForm {...dynamic_content} />
}
@@ -33,7 +33,6 @@
align-items: center;
}
/* Styles for new empty upcoming stays design */
.emptyUpcomingStaysContainer {
display: flex;
padding: var(--Space-x6);
@@ -10,6 +10,7 @@ import { useMediaQuery } from "usehooks-ts"
import { useMarkerHover } from "@scandic-hotels/common/hooks/map/useMarkerHover"
import { InfoWindow } from "@scandic-hotels/design-system/Map/InfoWindow"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
@@ -79,7 +80,10 @@ export default function CityClusterMarker({
})}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
>
<span className={styles.count}>{sizeAsText}</span>
<Typography variant="Title/Subtitle/md">
<span>{sizeAsText}</span>
</Typography>
{isDesktop && isHovered ? (
<InfoWindow
position={position}
@@ -20,9 +20,3 @@
height: 46px !important;
}
}
.count {
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Subtitle-2-fontSize);
font-weight: var(--typography-Subtitle-2-fontWeight);
}
@@ -19,12 +19,13 @@ div.months {
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
font-size: var(--Body-Paragraph-Size);
font-weight: var(--Body-Paragraph-Font-weight-2);
letter-spacing: var(--Body-Paragraph-Letter-spacing);
line-height: 1.5;
text-decoration: none;
}
td.rangeEnd,
@@ -90,14 +91,16 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
}
.weekDay {
color: var(--UI-Text-Placeholder);
font-family: var(--typography-Footnote-Labels-fontFamily);
font-size: var(--typography-Footnote-Labels-fontSize);
font-weight: var(--typography-Footnote-Labels-fontWeight);
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
line-height: var(--typography-Footnote-Labels-lineHeight);
text-decoration: var(--typography-Footnote-Labels-textDecoration);
text-transform: uppercase;
color: var(--Text-Tertiary);
font-family:
var(--Title-Overline-sm-Font-family), var(--Title-Overline-sm-Font-fallback);
font-size: var(--Title-Overline-sm-Size);
font-style: normal;
font-weight: var(--Title-Overline-sm-Font-weight);
line-height: 1.5;
letter-spacing: var(--Title-Overline-sm-Letter-spacing);
text-transform: var(--Title-Overline-sm-Text-Transform);
text-decoration: none;
}
.footer {
@@ -89,12 +89,13 @@ div.months {
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
font-family:
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
font-size: var(--Body-Paragraph-Size);
font-weight: var(--Body-Paragraph-Font-weight-2);
letter-spacing: var(--Body-Paragraph-Letter-spacing);
line-height: 1.5;
text-decoration: none;
}
td.rangeEnd,
@@ -156,14 +157,16 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
}
.weekDay {
color: var(--UI-Text-Placeholder);
font-family: var(--typography-Caption-Labels-fontFamily);
font-size: var(--typography-Caption-Labels-fontSize);
font-weight: var(--typography-Caption-Labels-fontWeight);
letter-spacing: var(--typography-Caption-Labels-letterSpacing);
line-height: var(--typography-Caption-Labels-lineHeight);
text-decoration: var(--typography-Caption-Labels-textDecoration);
text-transform: uppercase;
color: var(--Text-Tertiary);
font-family:
var(--Title-Overline-sm-Font-family), var(--Title-Overline-sm-Font-fallback);
font-size: var(--Title-Overline-sm-Size);
font-style: normal;
font-weight: var(--Title-Overline-sm-Font-weight);
line-height: 1.5;
letter-spacing: var(--Title-Overline-sm-Letter-spacing);
text-transform: var(--Title-Overline-sm-Text-Transform);
text-decoration: none;
}
@media screen and (min-width: 1367px) {
@@ -107,7 +107,6 @@ export default function Form({ user }: EditFormProps) {
} else {
router.push(profile[lang])
}
router.refresh() // Can be removed on NextJs 15
}
break
}
@@ -48,14 +48,9 @@ import styles from "./form.module.css"
interface SignUpFormProps {
title: string
enableProfileConsent?: boolean
}
export default function SignupForm({
title,
// Handled as a prop rather than a client env var due to limits in Netlify env var size.
enableProfileConsent = false,
}: SignUpFormProps) {
export default function SignupForm({ title }: SignUpFormProps) {
const intl = useIntl()
const router = useRouter()
const lang = useLang()
@@ -140,7 +135,7 @@ export default function SignupForm({
return (
<div className={styles.formWrapper}>
{enableProfileConsent && <ProfilingConsentModalReadOnly />}
<ProfilingConsentModalReadOnly />
{title ? (
<Typography variant="Title/md">
<h2>{title}</h2>
@@ -293,41 +288,39 @@ export default function SignupForm({
/>
</section>
{enableProfileConsent && (
<section className={styles.personalization}>
<header>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({
id: "signup.UnlockYourPersonalizedExperience",
defaultMessage: "Unlock your personalized experience!",
})}
</h3>
</Typography>
</header>
<Checkbox
name="profilingConsent"
registerOptions={{ required: true }}
>
{intl.formatMessage({
id: "signup.yesConsent",
defaultMessage:
"I consent to Scandic using my information to give me even more personalized travel inspiration and offers from Scandic and trusted Scandic Friends partners. This means Scandic may use information about my interactions with Scandic Friends partners, and share details of my interactions with Scandic with those partners, to make the experience even more relevant to me.",
})}
</Checkbox>
<TextLinkButton
typography="Link/sm"
color="Primary"
className={styles.personalizationButton}
onClick={openPersonalizationModal}
>
{intl.formatMessage({
id: "signup.ReadMoreAboutPersonalization",
defaultMessage: "Read more about personalization at Scandic",
})}
</TextLinkButton>
</section>
)}
<section className={styles.personalization}>
<header>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({
id: "signup.UnlockYourPersonalizedExperience",
defaultMessage: "Unlock your personalized experience!",
})}
</h3>
</Typography>
</header>
<Checkbox
name="profilingConsent"
registerOptions={{ required: true }}
>
{intl.formatMessage({
id: "signup.yesConsent",
defaultMessage:
"I consent to Scandic using my information to give me even more personalized travel inspiration and offers from Scandic and trusted Scandic Friends partners. This means Scandic may use information about my interactions with Scandic Friends partners, and share details of my interactions with Scandic with those partners, to make the experience even more relevant to me.",
})}
</Checkbox>
<TextLinkButton
typography="Link/sm"
color="Primary"
className={styles.personalizationButton}
onClick={openPersonalizationModal}
>
{intl.formatMessage({
id: "signup.ReadMoreAboutPersonalization",
defaultMessage: "Read more about personalization at Scandic",
})}
</TextLinkButton>
</section>
<section className={styles.terms}>
<header>
@@ -1,16 +1,18 @@
.menuButton {
display: flex;
align-items: center;
justify-content: center;
gap: var(--Space-x05);
cursor: pointer;
width: 100%;
background-color: transparent;
color: var(--Text-Interactive-Default);
border-width: 0;
padding: var(--Space-x05) 0;
@layer component {
.menuButton {
display: flex;
align-items: center;
justify-content: center;
gap: var(--Space-x05);
cursor: pointer;
width: 100%;
background-color: transparent;
color: var(--Text-Interactive-Default);
border-width: 0;
padding: var(--Space-x05) 0;
&.loading {
cursor: progress;
&.loading {
cursor: progress;
}
}
}
@@ -1,4 +1,5 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { usePathname, useRouter } from "next/navigation"
import { useState } from "react"
@@ -25,7 +26,7 @@ import ModifyContact from "../ModifyContact"
import styles from "./guestDetails.module.css"
import type { Guest } from "@scandic-hotels/trpc/routers/booking/output"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import {
type ModifyContactSchema,
@@ -34,9 +35,9 @@ import {
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
import type { SafeUser } from "@/types/user"
interface GuestDetailsProps {
type GuestDetailsProps = {
refId: string
guest: Guest
guest: BookingConfirmation["booking"]["guest"]
isCancelled: boolean
user: SafeUser
}
@@ -76,6 +77,7 @@ export default function GuestDetails({
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
const isMemberBooking =
!!user?.membership?.membershipNumber &&
guest.membershipNumber === user?.membership?.membershipNumber
const updateGuest = trpc.booking.update.useMutation({
@@ -196,7 +198,7 @@ export default function GuestDetails({
{guest.firstName} {guest.lastName}
</p>
</Typography>
{isMemberBooking && user.membership && (
{isMemberBooking && user?.membership && (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.memberNumber} data-hj-suppress>
{intl.formatMessage(
@@ -23,6 +23,7 @@
text-decoration-skip-ink: none;
text-decoration-thickness: auto;
text-underline-offset: auto;
text-align: center;
text-underline-position: from-font;
}
@@ -4,6 +4,7 @@ import { useIntl } from "react-intl"
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { dt } from "@scandic-hotels/common/dt"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { trpc } from "@scandic-hotels/trpc/client"
@@ -66,9 +67,14 @@ export default function Steps({ closeModal }: ChangeDatesStepsProps) {
setDates({ fromDate, toDate })
const numberOfNights = dt(toDate).diff(dt(fromDate), "days")
const pkgsSum = sumPackages(packages)
const extraPrice =
pkgsSum.price + ((breakfast && breakfast.localPrice.totalPrice) || 0)
const breakfastPrice = !!breakfast
? breakfast.localPrice.price * numberOfNights
: 0
const extraPrice = pkgsSum.price + breakfastPrice
if (isLoggedIn && "member" in data.product && data.product.member) {
const { currency, pricePerStay } = data.product.member.localPrice
setNewPrice(formatPrice(intl, pricePerStay + extraPrice, currency))
@@ -1,11 +1,11 @@
import { useIntl } from "react-intl"
import { CancellationRuleEnum } from "@scandic-hotels/common/constants/booking"
import { changeOrCancelDateFormat } from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt"
import { useMyStayStore } from "@/stores/my-stay"
import { hasModifiableRate } from "@/components/HotelReservation/MyStay/utils"
import useLang from "@/hooks/useLang"
import Row from "./Row"
@@ -14,14 +14,19 @@ export default function ModifyBy() {
const intl = useIntl()
const lang = useLang()
const { checkInDate, isModifyable } = useMyStayStore((state) => ({
checkInDate: state.bookedRoom.checkInDate,
isModifyable: hasModifiableRate(
state.bookedRoom.rateDefinition.cancellationRule
),
}))
const { checkInDate, isFlexBooking, isChangeBooking } = useMyStayStore(
(state) => ({
checkInDate: state.bookedRoom.checkInDate,
isFlexBooking:
state.bookedRoom.rateDefinition.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM,
isChangeBooking:
state.bookedRoom.rateDefinition.cancellationRule ===
CancellationRuleEnum.Changeable,
})
)
if (!isModifyable) {
if (!isFlexBooking && !isChangeBooking) {
return null
}
@@ -38,14 +43,15 @@ export default function ModifyBy() {
}
)
return (
<Row
icon="refresh"
text={text}
title={intl.formatMessage({
const title = isChangeBooking
? intl.formatMessage({
id: "booking.changeTitle",
defaultMessage: "Change",
})
: intl.formatMessage({
id: "booking.changeOrCancel",
defaultMessage: "Change or cancel",
})}
/>
)
})
return <Row icon="refresh" text={text} title={title} />
}
@@ -4,16 +4,18 @@ import { useMyStayStore } from "@/stores/my-stay"
import Price from "../PriceType/Price"
import type { PriceType as _PriceType } from "@/types/components/hotelReservation/myStay/myStay"
export default function TotalPrice() {
const { bookedRoom, totalPrice } = useMyStayStore((state) => ({
bookedRoom: state.bookedRoom,
totalPrice: state.totalPrice,
}))
const { bookedRoom, totalPrice, allRoomsAreCancelled } = useMyStayStore(
(state) => ({
bookedRoom: state.bookedRoom,
totalPrice: state.totalPrice,
allRoomsAreCancelled: state.allRoomsAreCancelled,
})
)
return (
<Price
isCancelled={bookedRoom.isCancelled}
isCancelled={allRoomsAreCancelled}
isMember={bookedRoom.rateDefinition.isMemberRate}
price={totalPrice}
/>
@@ -8,7 +8,7 @@ import accessBooking, {
} from "./accessBooking"
import type { AdditionalInfoCookieValue } from "@scandic-hotels/booking-flow/types/components/findMyBooking/additionalInfoCookieValue"
import type { Guest } from "@scandic-hotels/trpc/routers/booking/output"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { SafeUser } from "@/types/user"
@@ -201,7 +201,7 @@ const badAuthenticatedUser: SafeUser = {
profilingConsentUpdateDate: undefined,
}
const loggedOutGuest: Guest = {
const loggedOutGuest: BookingConfirmation["booking"]["guest"] = {
email: "logged+out@scandichotels.com",
firstName: "Anonymous",
lastName: "Booking",
@@ -210,7 +210,7 @@ const loggedOutGuest: Guest = {
countryCode: "SE",
}
const loggedInGuest: Guest = {
const loggedInGuest: BookingConfirmation["booking"]["guest"] = {
email: "logged+in@scandichotels.com",
firstName: "Authenticated",
lastName: "Booking",
@@ -1,5 +1,5 @@
import type { AdditionalInfoCookieValue } from "@scandic-hotels/booking-flow/types/components/findMyBooking/additionalInfoCookieValue"
import type { Guest } from "@scandic-hotels/trpc/routers/booking/output"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { SafeUser } from "@/types/user"
@@ -15,7 +15,7 @@ export {
* Whether a request can access a confirmed booking or not.
*/
function accessBooking(
guest: Guest,
guest: BookingConfirmation["booking"]["guest"],
lastName: string,
user: SafeUser | null,
cookie: string = ""
@@ -27,6 +27,7 @@ import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/Addi
import accessBooking, {
ACCESS_GRANTED,
ERROR_BAD_REQUEST,
ERROR_NOT_FOUND,
ERROR_UNAUTHORIZED,
} from "@/components/HotelReservation/MyStay/accessBooking"
import { Ancillaries } from "@/components/HotelReservation/MyStay/Ancillaries"
@@ -74,39 +75,23 @@ async function MyStay(props: {
notFound()
}
const { confirmationNumber, lastName } = parseRefId(refId)
const isLoggedIn = await isLoggedInUser()
const cookieStore = await cookies()
const bv = cookieStore.get("bv")?.value
let bookingConfirmation
if (isLoggedIn) {
bookingConfirmation = await getBookingConfirmation(refId)
} else if (bv) {
logger.debug(`MyStay: bv`, bv)
const {
firstName,
email,
confirmationNumber: bvConfirmationNo,
} = JSON.parse(bv) as AdditionalInfoCookieValue
if (firstName && email && bvConfirmationNo === confirmationNumber) {
bookingConfirmation = await findBooking(
confirmationNumber,
lastName,
firstName,
email
)
} else {
return (
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
}
} else {
const { confirmationNumber, lastName } = parseRefId(refId)
const isLoggedIn = await isLoggedInUser()
const [{ error, bookingConfirmation }, user] = await Promise.all([
getOrFindBookingConfirmation({
refId,
isLoggedIn,
confirmationNumber,
lastName,
bv,
}),
getProfileSafely(),
])
if (error === "MISSING_INFO") {
return (
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
@@ -121,208 +106,226 @@ async function MyStay(props: {
)
}
const { additionalData, booking, hotel, roomCategories } = bookingConfirmation
const { booking } = bookingConfirmation
const user = await getProfileSafely()
const { code } = accessBooking(booking.guest, lastName, user, bv)
const intl = await getIntl()
switch (code) {
case ACCESS_GRANTED.code:
return (
<MyStayPage
bookingConfirmation={bookingConfirmation}
user={user}
lang={lang}
isWebview={!!isWebview}
/>
)
case ERROR_NOT_FOUND.code:
return notFound()
case ERROR_BAD_REQUEST.code:
return (
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
case ERROR_UNAUTHORIZED.code: {
if (!bv) return notFound()
const access = accessBooking(booking.guest, lastName, user, bv)
if (access === ACCESS_GRANTED) {
const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD")
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
const linkedReservationsPromise = getLinkedReservations(booking.refId)
const packagesInput = {
adults: booking.adults,
children: booking.childrenAges.length,
endDate: toDate,
hotelId: hotel.operaId,
lang,
startDate: fromDate,
packageCodes: [
BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST,
BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST,
BreakfastPackageEnum.FREE_CHILD_BREAKFAST,
],
}
const supportedCards = hotel.merchantInformationData.cards
const savedPaymentCardsInput = { supportedCards }
const hasBreakfastPackage = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
const breakfastIncluded = booking.rateDefinition.breakfastIncluded
const shouldFetchBreakfastPackages =
!hasBreakfastPackage && !breakfastIncluded
if (shouldFetchBreakfastPackages) {
void getPackages(packagesInput)
}
const isOwnBooking = user?.email === booking.guest.email
if (user && isOwnBooking) {
void getSavedPaymentCardsSafely(savedPaymentCardsInput)
}
let breakfastPackages = null
if (shouldFetchBreakfastPackages) {
breakfastPackages = await getPackages(packagesInput)
}
let savedCreditCards = null
if (user && isOwnBooking) {
savedCreditCards = await getSavedPaymentCardsSafely(
savedPaymentCardsInput
return (
<RenderFindMyBookingForm
bv={bv}
lastName={lastName}
confirmationNumber={confirmationNumber}
/>
)
}
let ancillaryPackagesPromise = null
if (booking.showAncillaries) {
ancillaryPackagesPromise = getAncillaryPackages({
default:
const _exhaustiveCheck: never = code
throw new Error(`Unknown access code: ${code}`)
}
}
async function MyStayPage({
bookingConfirmation,
user,
lang,
isWebview,
}: {
bookingConfirmation: BookingConfirmation
user: SafeUser | null
lang: Lang
isWebview: boolean
}) {
const intl = await getIntl()
const { additionalData, booking, hotel, roomCategories } = bookingConfirmation
const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD")
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
const packagesInput = {
adults: booking.adults,
children: booking.childrenAges.length,
endDate: toDate,
hotelId: hotel.operaId,
lang,
startDate: fromDate,
packageCodes: [
BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST,
BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST,
BreakfastPackageEnum.FREE_CHILD_BREAKFAST,
],
}
const supportedCards = hotel.merchantInformationData.cards
const savedPaymentCardsInput = { supportedCards }
const hasBreakfastPackage = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
const breakfastIncluded = booking.rateDefinition.breakfastIncluded
const shouldFetchBreakfastPackages =
!hasBreakfastPackage && !breakfastIncluded
const isOwnBooking = user?.email === booking.guest.email
const shouldGetCards = user && isOwnBooking
const [breakfastPackages, savedCreditCards] = await Promise.all([
shouldFetchBreakfastPackages ? getPackages(packagesInput) : noop(),
shouldGetCards
? getSavedPaymentCardsSafely(savedPaymentCardsInput)
: noop(),
])
const imageSrc =
hotel.hotelContent.images.src ||
additionalData.gallery?.heroImages[0]?.src ||
hotel.galleryImages[0]?.src
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
const promoUrl = new URL(`${baseUrl}/${lang}/`)
const hotelUrl = new URL(`${baseUrl}${bookingConfirmation.url}/`)
promoUrl.searchParams.set("hotel", hotel.operaId)
const maskedBookingConfirmation = maskBookingConfirmation(bookingConfirmation)
const maskedUser = isOwnBooking ? maskUser(user) : null
const hotelWithFilteredAlerts = {
...hotel,
specialAlerts: filterOverlappingDates(
hotel.specialAlerts,
dt.utc(fromDate),
dt.utc(toDate)
),
}
const linkedReservationsPromise = getLinkedReservations(booking.refId)
const ancillaryPackagesPromise = booking.showAncillaries
? getAncillaryPackages({
fromDate,
hotelId: hotel.operaId,
toDate,
})
}
: null
const imageSrc =
hotel.hotelContent.images.src ||
additionalData.gallery?.heroImages[0]?.src ||
hotel.galleryImages[0]?.src
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
const promoUrl = new URL(`${baseUrl}/${lang}/`)
const hotelUrl = new URL(`${baseUrl}${bookingConfirmation.url}/`)
promoUrl.searchParams.set("hotel", hotel.operaId)
const maskedBookingConfirmation = {
...bookingConfirmation,
booking: {
...bookingConfirmation.booking,
guest: {
...bookingConfirmation.booking.guest,
email: maskValue.email(bookingConfirmation.booking.guest.email),
phoneNumber: maskValue.phone(
bookingConfirmation.booking.guest.phoneNumber ?? ""
),
},
},
} satisfies BookingConfirmation
const maskedUser =
user && isOwnBooking
? ({
...user,
email: maskValue.email(user.email),
phoneNumber: maskValue.phone(user.phoneNumber ?? ""),
} satisfies SafeUser)
: null
hotel.specialAlerts = filterOverlappingDates(
hotel.specialAlerts,
dt.utc(fromDate),
dt.utc(toDate)
)
return (
<MyStayProvider
bookingConfirmation={maskedBookingConfirmation}
breakfastPackages={breakfastPackages}
lang={lang}
linkedReservationsPromise={linkedReservationsPromise}
refId={booking.refId}
roomCategories={roomCategories}
savedCreditCards={savedCreditCards}
isLoggedIn={isLoggedIn}
>
<main className={styles.main}>
<div className={styles.imageContainer}>
<div className={styles.blurOverlay} />
{imageSrc && (
<Image
className={styles.image}
src={imageSrc}
alt={hotel.name}
fill
/>
)}
</div>
<div className={styles.content}>
<div className={styles.headerContainer}>
<Header cityName={hotel.cityName} name={hotel.name} />
<ReferenceCard />
</div>
{booking.showAncillaries && ancillaryPackagesPromise && (
<Ancillaries
ancillariesPromise={ancillaryPackagesPromise}
packages={breakfastPackages}
user={maskedUser}
savedCreditCards={savedCreditCards}
/>
)}
<SingleRoom user={maskedUser} />
<MultiRoom user={maskedUser} />
<BookingSummary hotelUrl={hotelUrl.toString()} hotel={hotel} />
{!isWebview && (
<Promo
title={intl.formatMessage({
id: "booking.bookNextStay.title",
defaultMessage: "Book your next stay",
})}
text={intl.formatMessage({
id: "booking.bookAnotherStayDescription",
defaultMessage:
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
})}
buttonText={intl.formatMessage({
id: "myStay.promo.bookNextStay.buttonText",
defaultMessage: "Explore Scandic hotels",
})}
href={promoUrl.toString()}
image={hotel.hotelContent.images}
/>
)}
</div>
</main>
</MyStayProvider>
)
}
if (access === ERROR_BAD_REQUEST) {
return (
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
}
if (access === ERROR_UNAUTHORIZED) {
if (bv) {
const { firstName, email } = JSON.parse(bv) as AdditionalInfoCookieValue
return (
<main className={styles.main}>
<div className={styles.form}>
<FindMyBooking
error={FindMyBookingErrorEnum.BOOKING_ACCESS_DENIED}
defaultValues={{
firstName,
lastName,
confirmationNumber,
email,
}}
return (
<MyStayProvider
bookingConfirmation={maskedBookingConfirmation}
breakfastPackages={breakfastPackages}
lang={lang}
linkedReservationsPromise={linkedReservationsPromise}
refId={booking.refId}
roomCategories={roomCategories}
savedCreditCards={savedCreditCards}
isLoggedIn={!!user}
>
<main className={styles.main}>
<div className={styles.imageContainer}>
<div className={styles.blurOverlay} />
{imageSrc && (
<Image
className={styles.image}
src={imageSrc}
alt={hotelWithFilteredAlerts.name}
fill
/>
)}
</div>
<div className={styles.content}>
<div className={styles.headerContainer}>
<Header
cityName={hotelWithFilteredAlerts.cityName}
name={hotelWithFilteredAlerts.name}
/>
<ReferenceCard />
</div>
</main>
)
} else {
}
}
{booking.showAncillaries && ancillaryPackagesPromise && (
<Ancillaries
ancillariesPromise={ancillaryPackagesPromise}
packages={breakfastPackages}
user={maskedUser}
savedCreditCards={savedCreditCards}
/>
)}
return notFound()
<SingleRoom user={maskedUser} />
<MultiRoom user={maskedUser} />
<BookingSummary
hotelUrl={hotelUrl.toString()}
hotel={hotelWithFilteredAlerts}
/>
{!isWebview && (
<Promo
title={intl.formatMessage({
id: "booking.bookNextStay.title",
defaultMessage: "Book your next stay",
})}
text={intl.formatMessage({
id: "booking.bookAnotherStayDescription",
defaultMessage:
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
})}
buttonText={intl.formatMessage({
id: "myStay.promo.bookNextStay.buttonText",
defaultMessage: "Explore Scandic hotels",
})}
href={promoUrl.toString()}
image={hotelWithFilteredAlerts.hotelContent.images}
/>
)}
</div>
</main>
</MyStayProvider>
)
}
function RenderFindMyBookingForm({
bv,
lastName,
confirmationNumber,
}: {
bv: string
lastName: string
confirmationNumber: string
}) {
const { firstName, email } = JSON.parse(bv) as AdditionalInfoCookieValue
return (
<main className={styles.main}>
<div className={styles.form}>
<FindMyBooking
error={FindMyBookingErrorEnum.BOOKING_ACCESS_DENIED}
defaultValues={{
firstName,
lastName,
confirmationNumber,
email,
}}
/>
</div>
</main>
)
}
function RenderAdditionalInfoForm({
@@ -343,3 +346,75 @@ function RenderAdditionalInfoForm({
</main>
)
}
function maskUser(user: SafeUser | null): SafeUser | null {
if (!user) return null
return {
...user,
email: maskValue.email(user.email),
phoneNumber: maskValue.phone(user.phoneNumber ?? ""),
}
}
function maskBookingConfirmation(
bookingConfirmation: BookingConfirmation
): BookingConfirmation {
return {
...bookingConfirmation,
booking: {
...bookingConfirmation.booking,
guest: {
...bookingConfirmation.booking.guest,
email: maskValue.email(bookingConfirmation.booking.guest.email),
phoneNumber: maskValue.phone(
bookingConfirmation.booking.guest.phoneNumber ?? ""
),
},
},
}
}
async function getOrFindBookingConfirmation({
refId,
confirmationNumber,
lastName,
isLoggedIn,
bv,
}: {
refId: string
confirmationNumber: string
lastName: string
isLoggedIn: boolean
bv?: string
}) {
if (isLoggedIn)
return { bookingConfirmation: await getBookingConfirmation(refId) } as const
if (!bv) return { error: "MISSING_INFO", bookingConfirmation: null } as const
logger.debug(`MyStay: bv`, bv)
const {
firstName,
email,
confirmationNumber: bvConfirmationNo,
} = JSON.parse(bv) as AdditionalInfoCookieValue
if (!firstName || !email || bvConfirmationNo !== confirmationNumber) {
return { error: "MISSING_INFO", bookingConfirmation: null } as const
}
return {
bookingConfirmation: await findBooking(
confirmationNumber,
lastName,
firstName,
email
),
} as const
}
// Helper function to handle conditional Promise.all calls
async function noop() {
return Promise.resolve(null)
}
@@ -4,13 +4,6 @@
padding: var(--Space-x3) var(--Space-x2);
}
.subtitle {
font-family: var(--typography-Subtitle-2-fontFamily);
font-size: var(--typography-Subtitle-2-Mobile-fontSize);
font-weight: var(--typography-Subtitle-2-fontWeight);
color: var(--Base-Text-High-contrast);
}
.list {
list-style: none;
}
@@ -16,8 +16,8 @@
cursor: pointer;
height: 32px;
width: 32px;
font-size: var(--typography-Body-Bold-fontSize);
font-weight: var(--typography-Body-Bold-fontWeight);
font-size: var(--Body-Paragraph-Size);
font-weight: var(--Body-Paragraph-Font-weight-2);
padding: 0;
display: flex;
align-items: center;
@@ -1,5 +1,3 @@
import { env } from "@/env/server"
import { getIntl } from "@/i18n"
import { Section } from "../Section"
@@ -17,7 +15,7 @@ export async function CommunicationSettings() {
})}
>
<EmailSlot />
{env.ENABLE_PROFILE_CONSENT && <PersonalizationSlot />}
<PersonalizationSlot />
</Section>
)
}
@@ -1,6 +1,5 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import { env } from "@/env/server"
import { getProfile, getProfilingConsent } from "@/lib/trpc/memoizedRequests"
import { GetMainIconByCSIdentifier, userHasConsent } from "../utils"
@@ -9,8 +8,6 @@ import { BannerButton } from "./Button"
import styles from "./profilingConsentBanner.module.css"
export async function ProfilingConsentBanner() {
if (!env.ENABLE_PROFILE_CONSENT) return null
const user = await getProfile()
if (!user || userHasConsent(user?.profilingConsent)) return null
@@ -1,6 +1,6 @@
# Profiling Consent
Profiling consent allows users to opt in/out of personalized experiences. The feature is controlled by the `ENABLE_PROFILE_CONSENT` environment variable.
Profiling consent allows users to opt in/out of personalized experiences.
## User Journey
@@ -121,11 +121,9 @@ Replace `<memberKey>` with the actual `membershipNumber` or `profileId`.
Required content for the feature:
1. **Profiling Consent (config)**
- Config needs to be created and published in each language
2. **/consent (account page)**
- Page needs to be created and published in each language
3. **/overview (account page)**
@@ -2,6 +2,7 @@
display: flex;
flex-direction: column;
gap: var(--Space-x05);
align-items: flex-start;
}
.link {
@@ -1,15 +1,13 @@
import Footnote from "@scandic-hotels/design-system/Footnote"
import {
MaterialIcon,
type MaterialIconSetIconProps,
} from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getValueFromContactConfig } from "@scandic-hotels/trpc/utils/contactConfig"
import { serverClient } from "@/lib/trpc/server"
// import { getValueFromContactConfig } from "@/utils/contactConfig"
import styles from "./contactRow.module.css"
import type { ContactRowProps } from "@/types/components/sidebar/joinLoyaltyContact"
@@ -46,22 +44,27 @@ export default async function ContactRow({ contact }: ContactRowProps) {
return (
<div className={styles.wrapper}>
<Typography
variant="Body/Paragraph/mdBold"
className={styles.displayText}
>
<p>{contact.display_text}</p>
</Typography>
<Link
{contact.display_text ? (
<Typography
variant="Body/Paragraph/mdBold"
className={styles.displayText}
>
<p>{contact.display_text}</p>
</Typography>
) : null}
<TextLink
typography="Link/sm"
className={styles.link}
href={openableLink}
textDecoration="underline"
size="small"
>
{Icon ? <Icon size={20} color="Icon/Interactive/Default" /> : null}
{val}
</Link>
{footnote && <Footnote color="burgundy">{footnote}</Footnote>}
</TextLink>
{footnote && (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{footnote}</p>
</Typography>
)}
</div>
)
}
@@ -13,18 +13,9 @@
gap: var(--Space-x15);
}
.contact > div {
display: flex;
justify-content: center;
}
@media screen and (min-width: 1367px) {
.contactContainer {
align-items: start;
padding-top: var(--Space-x2);
}
.contact > div {
justify-content: start;
}
}
@@ -1,4 +1,4 @@
import { scandicFriends } from "@scandic-hotels/common/constants/routes/myPages"
import { scandicFriends } from "@scandic-hotels/common/constants/routes/customerService"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
-9
View File
@@ -2,15 +2,6 @@ import { Lang } from "@scandic-hotels/common/constants/language"
import type { LangRoute } from "@scandic-hotels/common/constants/routes/langRoute"
export const sasPartnershipTermsAndConditions: LangRoute = {
da: `/${Lang.da}/kundeservice/politikker/scandic-friends-betingelser/sas-eurobonus`,
de: `/${Lang.de}/kundenbetreuung/richtlinien/scandic-friends-allgemeine-geschaeftsbedingungen/sas-eurobonus`,
en: `/${Lang.en}/customer-service/policies/scandic-friends/sas-eurobonus`,
fi: `/${Lang.fi}/asiakaspalvelu/ehdot/scandic-friends/sas-eurobonus`,
no: `/${Lang.no}/kundeservice/betingelser/scandic-friends-betingelser/sas-eurobonus`,
sv: `/${Lang.sv}/kundservice/villkor/scandic-friends/sas-eurobonus`,
}
export const faq: LangRoute = {
da: `/${Lang.da}/scandic-friends/hjalp-og-service/faq`,
de: `/${Lang.de}/scandic-friends/hilfe-und-service/faq`,
+6
View File
@@ -16,6 +16,11 @@ export const env = createEnv({
.transform((s) =>
getSemver("scandic-web", s, process.env.BRANCH || "development")
),
NEXT_PUBLIC_NEW_POINTCLAIMS: z
.string()
.optional()
.default("false")
.transform((s) => s === "true"),
},
emptyStringAsUndefined: true,
runtimeEnv: {
@@ -26,5 +31,6 @@ export const env = createEnv({
process.env.NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE,
NEXT_PUBLIC_PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL,
NEXT_PUBLIC_RELEASE_TAG: process.env.NEXT_PUBLIC_RELEASE_TAG,
NEXT_PUBLIC_NEW_POINTCLAIMS: process.env.NEXT_PUBLIC_NEW_POINTCLAIMS,
},
})
-6
View File
@@ -96,11 +96,6 @@ export const env = createEnv({
.refine((s) => s === "1" || s === "0")
.transform((s) => s === "1")
.default("0"),
ENABLE_PROFILE_CONSENT: z
.string()
.refine((s) => s === "true" || s === "false")
.transform((s) => s === "true")
.default("false"),
RELEASE_TAG: z
.string()
.optional()
@@ -160,7 +155,6 @@ export const env = createEnv({
DTMC_ENTRA_ID_SECRET: process.env.DTMC_ENTRA_ID_SECRET,
CHATBOT_LIVE_LANGS: process.env.CHATBOT_LIVE_LANGS,
SEO_INERT: process.env.SEO_INERT,
ENABLE_PROFILE_CONSENT: process.env.ENABLE_PROFILE_CONSENT,
RELEASE_TAG: process.env.NEXT_PUBLIC_RELEASE_TAG,
},
})
+135 -1
View File
@@ -231,6 +231,12 @@
"value": "Særlige ønsker (valgfrit)"
}
],
"ancillaries.label.quantity": [
{
"type": 0,
"value": "Antal"
}
],
"ancillaries.unableToDisplayBreakfastPrices": [
{
"type": 0,
@@ -249,6 +255,18 @@
"value": "Jeg accepterer booking- og annulleringsbetingelserne"
}
],
"booking.alert.extraBeds.text": [
{
"type": 0,
"value": "Ved booking for mere end 2 gæster vil der blive opkrævet et ekstra gebyr pr. person. Se link for detaljer."
}
],
"booking.alert.extraguests": [
{
"type": 0,
"value": "Ekstra gæst(er)"
}
],
"booking.approx": [
{
"type": 0,
@@ -953,6 +971,16 @@
"value": "Tilføj kode"
}
],
"bookingWidget.bookingCode.readMore": [
{
"type": 0,
"value": "Læs mere om brug af "
},
{
"type": 1,
"value": "codeVoucher"
}
],
"bookingWidget.bookingCode.remember": [
{
"type": 0,
@@ -1061,6 +1089,16 @@
"value": "details"
}
],
"bookingWidget.reward.readMore": [
{
"type": 0,
"value": "Læs mere om booking med "
},
{
"type": 1,
"value": "reward"
}
],
"bookingWidget.reward.rewardNight": [
{
"type": 0,
@@ -2510,6 +2548,44 @@
"value": "Datoer"
}
],
"designSystem.stepper.ariaLabel.currentCount": [
{
"type": 0,
"value": "Nuværende "
},
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "count"
}
],
"designSystem.stepper.ariaLabel.decrease": [
{
"type": 0,
"value": "Reducer "
},
{
"type": 1,
"value": "label"
}
],
"designSystem.stepper.ariaLabel.increase": [
{
"type": 0,
"value": "Øg "
},
{
"type": 1,
"value": "label"
}
],
"destination.backToCities": [
{
"type": 0,
@@ -5783,7 +5859,7 @@
"myPages.myStay.ancillaries.reachedMaxItemsStepperMessage": [
{
"type": 0,
"value": "Maksimal antal nået for denne vare."
"value": "Maksimalt antal nået for denne vare."
}
],
"myPages.myStay.ancillaries.reachedMaxPointsMessage": [
@@ -7569,6 +7645,58 @@
"value": "Samlede ophold"
}
],
"price.numberOfEuroBonusPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "EB-point"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "EB-point"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfEuroBonusPoints"
}
],
"price.numberOfScandicPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "Point"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "Point"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfScandicPoints"
}
],
"price.numberOfVouchers": [
{
"offset": 0,
@@ -8629,6 +8757,12 @@
"value": "). Se tilgængelige priser nedenfor."
}
],
"selectRate.alert.reservationPolicies": [
{
"type": 0,
"value": "Se prisdetaljer"
}
],
"selectRate.availableRooms.all": [
{
"offset": 0,
+138
View File
@@ -231,6 +231,12 @@
"value": "Sonderwünsche (optional)"
}
],
"ancillaries.label.quantity": [
{
"type": 0,
"value": "Menge"
}
],
"ancillaries.unableToDisplayBreakfastPrices": [
{
"type": 0,
@@ -249,6 +255,18 @@
"value": "Ich akzeptiere die Buchungs- und Stornierungsbedingungen"
}
],
"booking.alert.extraBeds.text": [
{
"type": 0,
"value": "Bei Buchungen für mehr als 2 Gäste fällt eine zusätzliche Gebühr pro Person an. Siehe Link für Details."
}
],
"booking.alert.extraguests": [
{
"type": 0,
"value": "Zusätzliche Gäste"
}
],
"booking.approx": [
{
"type": 0,
@@ -949,6 +967,16 @@
"value": "Code hinzufügen"
}
],
"bookingWidget.bookingCode.readMore": [
{
"type": 0,
"value": "Mehr erfahren über die Verwendung von "
},
{
"type": 1,
"value": "codeVoucher"
}
],
"bookingWidget.bookingCode.remember": [
{
"type": 0,
@@ -1057,6 +1085,20 @@
"value": "details"
}
],
"bookingWidget.reward.readMore": [
{
"type": 0,
"value": "Mehr über Buchungen mit "
},
{
"type": 1,
"value": "reward"
},
{
"type": 0,
"value": "erfahren"
}
],
"bookingWidget.reward.rewardNight": [
{
"type": 0,
@@ -2522,6 +2564,44 @@
"value": "Daten"
}
],
"designSystem.stepper.ariaLabel.currentCount": [
{
"type": 0,
"value": "Aktuell "
},
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "count"
}
],
"designSystem.stepper.ariaLabel.decrease": [
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": "verringern"
}
],
"designSystem.stepper.ariaLabel.increase": [
{
"type": 0,
"value": "Erhöhen "
},
{
"type": 1,
"value": "label"
}
],
"destination.backToCities": [
{
"type": 0,
@@ -7570,6 +7650,58 @@
"value": "Aufenthalte insgesamt"
}
],
"price.numberOfEuroBonusPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "EB-Punkt"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "EB-Punkte"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfEuroBonusPoints"
}
],
"price.numberOfScandicPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "Punkt"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "Punkte"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfScandicPoints"
}
],
"price.numberOfVouchers": [
{
"offset": 0,
@@ -8634,6 +8766,12 @@
"value": "). Verfügbare Übernachtungspreise sind unten zu sehen."
}
],
"selectRate.alert.reservationPolicies": [
{
"type": 0,
"value": "Tarifdetails anzeigen"
}
],
"selectRate.availableRooms.all": [
{
"offset": 0,
+290
View File
@@ -231,6 +231,12 @@
"value": "Special requests (optional)"
}
],
"ancillaries.label.quantity": [
{
"type": 0,
"value": "Quantity"
}
],
"ancillaries.unableToDisplayBreakfastPrices": [
{
"type": 0,
@@ -249,6 +255,18 @@
"value": "I accept the booking and cancellation terms"
}
],
"booking.alert.extraBeds.text": [
{
"type": 0,
"value": "When booking for more than 2 guests, an additional fee will apply per person. See link for details."
}
],
"booking.alert.extraguests": [
{
"type": 0,
"value": "Extra guest(s)"
}
],
"booking.approx": [
{
"type": 0,
@@ -337,6 +355,12 @@
"value": "Change or cancel"
}
],
"booking.changeTitle": [
{
"type": 0,
"value": "Change"
}
],
"booking.codeVoucher": [
{
"type": 0,
@@ -953,6 +977,16 @@
"value": "Add code"
}
],
"bookingWidget.bookingCode.readMore": [
{
"type": 0,
"value": "Read more about using "
},
{
"type": 1,
"value": "codeVoucher"
}
],
"bookingWidget.bookingCode.remember": [
{
"type": 0,
@@ -1061,6 +1095,16 @@
"value": "details"
}
],
"bookingWidget.reward.readMore": [
{
"type": 0,
"value": "Read more about booking with "
},
{
"type": 1,
"value": "reward"
}
],
"bookingWidget.reward.rewardNight": [
{
"type": 0,
@@ -2198,6 +2242,16 @@
"value": " points"
}
],
"common.pointsInLine": [
{
"type": 1,
"value": "points"
},
{
"type": 0,
"value": " points"
}
],
"common.pointsToSpend": [
{
"type": 0,
@@ -2510,6 +2564,44 @@
"value": "Dates"
}
],
"designSystem.stepper.ariaLabel.currentCount": [
{
"type": 0,
"value": "Current "
},
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "count"
}
],
"designSystem.stepper.ariaLabel.decrease": [
{
"type": 0,
"value": "Decrease "
},
{
"type": 1,
"value": "label"
}
],
"designSystem.stepper.ariaLabel.increase": [
{
"type": 0,
"value": "Increase "
},
{
"type": 1,
"value": "label"
}
],
"destination.backToCities": [
{
"type": 0,
@@ -3060,6 +3152,12 @@
"value": "When you confirm the booking the room will be guaranteed for late arrival. If you fail to arrive without cancelling in advance or if you cancel after 18:00 local time, you will be charged for one reward night."
}
],
"enterDetails.confirmBooking.rewardNightGuaranteeInfo": [
{
"type": 0,
"value": "When you complete the booking the room will be guaranteed for late arrival. The hotel will hold your booking, even if you arrive after 18:00. In case of a no-show, you will be charged for one reward night."
}
],
"enterDetails.details.description": [
{
"type": 0,
@@ -3344,6 +3442,40 @@
"value": "Select payment method"
}
],
"enterDetails.paymentStep.flexBookingTermsAndConditions": [
{
"type": 0,
"value": "To complete your booking, please accept the general "
},
{
"children": [
{
"type": 0,
"value": "Booking & Cancellation Terms"
}
],
"type": 8,
"value": "termsAndConditionsLink"
},
{
"type": 0,
"value": ", and acknowledge that your data will be processed in accordance with Scandic's "
},
{
"children": [
{
"type": 0,
"value": "Privacy policy"
}
],
"type": 8,
"value": "privacyPolicyLink"
},
{
"type": 0,
"value": "."
}
],
"enterDetails.priceChangeDialog.acceptButton": [
{
"type": 0,
@@ -5669,6 +5801,24 @@
"value": "!"
}
],
"myPages.l6progress.modal.title": [
{
"type": 0,
"value": "Level upgrade and membership year"
}
],
"myPages.l6progress.modal.youCanAlsoReach": [
{
"type": 0,
"value": "You can also reach Best Friend, our highest membership level, by staying 100 nights with us within a membership year."
}
],
"myPages.l6progress.modal.yourLevelDuring": [
{
"type": 0,
"value": "Your level during the current and next period is based on the points you earn during this 12-month period."
}
],
"myPages.leftToLevelUp": [
{
"type": 0,
@@ -6057,6 +6207,88 @@
"value": "Your membership"
}
],
"myPoints.pointTransactions.extrasToBooking": [
{
"type": 0,
"value": "Extras to your booking"
}
],
"myPoints.pointTransactions.formerScandicHotel": [
{
"type": 0,
"value": "Former Scandic Hotel"
}
],
"myPoints.pointTransactions.noTransactions": [
{
"type": 0,
"value": "No transactions available"
}
],
"myPoints.pointTransactions.pointShop": [
{
"type": 0,
"value": "Scandic Friends Point Shop"
}
],
"myPoints.pointTransactions.pointsActivity": [
{
"type": 0,
"value": "Point activity"
}
],
"myPoints.pointTransactions.pointsEarnedPriorMay2021": [
{
"type": 0,
"value": "Points earned prior to May 1, 2021"
}
],
"myPoints.pointTransactions.redGift": [
{
"type": 0,
"value": "Reward Gift"
}
],
"myPoints.pointTransactions.rewardNight": [
{
"type": 0,
"value": "Reward Night"
}
],
"myPoints.pointTransactions.scandicFriendsMastercard": [
{
"type": 0,
"value": "Scandic Friends Mastercard"
}
],
"myPoints.pointTransactions.showMoreTransactions": [
{
"type": 0,
"value": "Show more transactions"
}
],
"myPoints.pointTransactions.signUpBonus": [
{
"type": 0,
"value": "Sign up bonus"
}
],
"myPoints.pointTransactions.stayAt": [
{
"type": 0,
"value": "Stay at "
},
{
"type": 1,
"value": "hotelName"
}
],
"myPoints.pointTransactions.tuiPoints": [
{
"type": 0,
"value": "TUI Points"
}
],
"myStay.accessDenied.bookingNotFound": [
{
"type": 0,
@@ -7546,6 +7778,58 @@
"value": "Total stays"
}
],
"price.numberOfEuroBonusPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "EB Point"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "EB Points"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfEuroBonusPoints"
}
],
"price.numberOfScandicPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "Point"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "Points"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfScandicPoints"
}
],
"price.numberOfVouchers": [
{
"offset": 0,
@@ -8610,6 +8894,12 @@
"value": "). See available rates below."
}
],
"selectRate.alert.reservationPolicies": [
{
"type": 0,
"value": "See rate details"
}
],
"selectRate.availableRooms.all": [
{
"offset": 0,
+142
View File
@@ -231,6 +231,12 @@
"value": "Erityistoiveet (valinnainen)"
}
],
"ancillaries.label.quantity": [
{
"type": 0,
"value": "Määrä"
}
],
"ancillaries.unableToDisplayBreakfastPrices": [
{
"type": 0,
@@ -249,6 +255,18 @@
"value": "Hyväksyn varaus- ja peruutusehdot"
}
],
"booking.alert.extraBeds.text": [
{
"type": 0,
"value": "Kun varaat yli 2 vieraalle, lisämaksu veloitetaan per henkilö. Katso lisätietoja linkistä."
}
],
"booking.alert.extraguests": [
{
"type": 0,
"value": "Ylimääräinen vieras/vieraat"
}
],
"booking.approx": [
{
"type": 0,
@@ -953,6 +971,20 @@
"value": "Lisää koodi"
}
],
"bookingWidget.bookingCode.readMore": [
{
"type": 0,
"value": "Lue lisää "
},
{
"type": 1,
"value": "codeVoucher"
},
{
"type": 0,
"value": ":n käytöstä"
}
],
"bookingWidget.bookingCode.remember": [
{
"type": 0,
@@ -1061,6 +1093,20 @@
"value": "details"
}
],
"bookingWidget.reward.readMore": [
{
"type": 0,
"value": "Lue lisää "
},
{
"type": 1,
"value": "reward"
},
{
"type": 0,
"value": " varaamisesta"
}
],
"bookingWidget.reward.rewardNight": [
{
"type": 0,
@@ -2514,6 +2560,44 @@
"value": "Päivämäärät"
}
],
"designSystem.stepper.ariaLabel.currentCount": [
{
"type": 0,
"value": "Nykyinen "
},
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "count"
}
],
"designSystem.stepper.ariaLabel.decrease": [
{
"type": 0,
"value": "Vähennä "
},
{
"type": 1,
"value": "label"
}
],
"designSystem.stepper.ariaLabel.increase": [
{
"type": 0,
"value": "Lisää "
},
{
"type": 1,
"value": "label"
}
],
"destination.backToCities": [
{
"type": 0,
@@ -7586,6 +7670,58 @@
"value": "Majoitukset yhteensä"
}
],
"price.numberOfEuroBonusPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "EB-piste"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "EB-pistettä"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfEuroBonusPoints"
}
],
"price.numberOfScandicPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "Piste"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "pistettä"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfScandicPoints"
}
],
"price.numberOfVouchers": [
{
"offset": 0,
@@ -8658,6 +8794,12 @@
"value": "). Katso saatavilla olevat hinnat alla."
}
],
"selectRate.alert.reservationPolicies": [
{
"type": 0,
"value": "Katso hinnan tiedot"
}
],
"selectRate.availableRooms.all": [
{
"offset": 0,
+134
View File
@@ -231,6 +231,12 @@
"value": "Spesielle ønsker (valgfritt)"
}
],
"ancillaries.label.quantity": [
{
"type": 0,
"value": "Antall"
}
],
"ancillaries.unableToDisplayBreakfastPrices": [
{
"type": 0,
@@ -249,6 +255,18 @@
"value": "Jeg godtar bestillings- og avbestillingsvilkårene"
}
],
"booking.alert.extraBeds.text": [
{
"type": 0,
"value": "Ved bestilling for mer enn 2 gjester vil det påløpe et tilleggsgebyr per person. Se lenke for detaljer."
}
],
"booking.alert.extraguests": [
{
"type": 0,
"value": "Ekstra gjest(er)"
}
],
"booking.approx": [
{
"type": 0,
@@ -953,6 +971,16 @@
"value": "Legg til kode"
}
],
"bookingWidget.bookingCode.readMore": [
{
"type": 0,
"value": "Les mer om bruk av "
},
{
"type": 1,
"value": "codeVoucher"
}
],
"bookingWidget.bookingCode.remember": [
{
"type": 0,
@@ -1061,6 +1089,16 @@
"value": "details"
}
],
"bookingWidget.reward.readMore": [
{
"type": 0,
"value": "Les mer om bestilling med "
},
{
"type": 1,
"value": "reward"
}
],
"bookingWidget.reward.rewardNight": [
{
"type": 0,
@@ -2510,6 +2548,44 @@
"value": "Datoer"
}
],
"designSystem.stepper.ariaLabel.currentCount": [
{
"type": 0,
"value": "Gjeldende "
},
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "count"
}
],
"designSystem.stepper.ariaLabel.decrease": [
{
"type": 0,
"value": "Reduser "
},
{
"type": 1,
"value": "label"
}
],
"designSystem.stepper.ariaLabel.increase": [
{
"type": 0,
"value": "Øk "
},
{
"type": 1,
"value": "label"
}
],
"destination.backToCities": [
{
"type": 0,
@@ -7582,6 +7658,58 @@
"value": "Totalt antall opphold"
}
],
"price.numberOfEuroBonusPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "EB-poeng"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "EB-poeng"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfEuroBonusPoints"
}
],
"price.numberOfScandicPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "Poeng"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "Poeng"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfScandicPoints"
}
],
"price.numberOfVouchers": [
{
"offset": 0,
@@ -8654,6 +8782,12 @@
"value": " ). Se tilgjengelige priser nedenfor."
}
],
"selectRate.alert.reservationPolicies": [
{
"type": 0,
"value": "Se prisdetaljer"
}
],
"selectRate.availableRooms.all": [
{
"offset": 0,
+135 -1
View File
@@ -231,6 +231,12 @@
"value": "Särskilda önskemål (valfritt)"
}
],
"ancillaries.label.quantity": [
{
"type": 0,
"value": "Antal"
}
],
"ancillaries.unableToDisplayBreakfastPrices": [
{
"type": 0,
@@ -249,6 +255,18 @@
"value": "Jag accepterar boknings- och avbokningsvillkoren"
}
],
"booking.alert.extraBeds.text": [
{
"type": 0,
"value": "När du bokar för fler än 2 gäster tillkommer en extra avgift per person. Se länk för detaljer."
}
],
"booking.alert.extraguests": [
{
"type": 0,
"value": "Extra gäst(er)"
}
],
"booking.approx": [
{
"type": 0,
@@ -953,6 +971,16 @@
"value": "Lägg till kod"
}
],
"bookingWidget.bookingCode.readMore": [
{
"type": 0,
"value": "Läs mer om att använda "
},
{
"type": 1,
"value": "codeVoucher"
}
],
"bookingWidget.bookingCode.remember": [
{
"type": 0,
@@ -1061,6 +1089,16 @@
"value": "details"
}
],
"bookingWidget.reward.readMore": [
{
"type": 0,
"value": "Läs mer om bokning med "
},
{
"type": 1,
"value": "reward"
}
],
"bookingWidget.reward.rewardNight": [
{
"type": 0,
@@ -2510,6 +2548,44 @@
"value": "Datum"
}
],
"designSystem.stepper.ariaLabel.currentCount": [
{
"type": 0,
"value": "Aktuell "
},
{
"type": 1,
"value": "label"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "count"
}
],
"designSystem.stepper.ariaLabel.decrease": [
{
"type": 0,
"value": "Minska "
},
{
"type": 1,
"value": "label"
}
],
"designSystem.stepper.ariaLabel.increase": [
{
"type": 0,
"value": "Öka "
},
{
"type": 1,
"value": "label"
}
],
"destination.backToCities": [
{
"type": 0,
@@ -3845,7 +3921,7 @@
"findMyBooking.findYourStay": [
{
"type": 0,
"value": "Hitta ditt hotell"
"value": "Hitta din bokning"
}
],
"findMyBooking.manageBooking": [
@@ -7566,6 +7642,58 @@
"value": "Totalt antal vistelser"
}
],
"price.numberOfEuroBonusPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "EB-poäng"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "EB-poäng"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfEuroBonusPoints"
}
],
"price.numberOfScandicPoints": [
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "Punkt"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "Poäng"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "numberOfScandicPoints"
}
],
"price.numberOfVouchers": [
{
"offset": 0,
@@ -8626,6 +8754,12 @@
"value": "). Se tillgängliga priser nedan."
}
],
"selectRate.alert.reservationPolicies": [
{
"type": 0,
"value": "Se bokningsvillkor"
}
],
"selectRate.availableRooms.all": [
{
"offset": 0,
+5 -5
View File
@@ -30,7 +30,7 @@
"@internationalized/date": "^3.8.0",
"@netlify/blobs": "^8.1.0",
"@netlify/functions": "^3.0.0",
"@netlify/plugin-nextjs": "^5.15.1",
"@netlify/plugin-nextjs": "^5.15.7",
"@radix-ui/react-slot": "^1.2.2",
"@scandic-hotels/booking-flow": "workspace:*",
"@scandic-hotels/common": "workspace:*",
@@ -38,7 +38,7 @@
"@scandic-hotels/tracking": "workspace:*",
"@scandic-hotels/trpc": "workspace:*",
"@sentry/nextjs": "^10.33.0",
"@swc/plugin-formatjs": "^3.2.2",
"@swc/plugin-formatjs": "^8.1.0",
"@t3-oss/env-nextjs": "^0.13.4",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
@@ -66,12 +66,12 @@
"jsonwebtoken": "^9.0.2",
"md5": "^2.3.0",
"motion": "^12.10.0",
"next": "16.0.10",
"next": "16.1.6",
"next-auth": "5.0.0-beta.29",
"react": "19.2.1",
"react": "19.2.4",
"react-aria-components": "1.8.0",
"react-day-picker": "^9.6.7",
"react-dom": "19.2.1",
"react-dom": "19.2.4",
"react-feather": "^2.0.10",
"react-focus-lock": "^2.13.6",
"react-hook-form": "^7.56.2",
+3 -5
View File
@@ -13,22 +13,20 @@ import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkele
import { MyStayContext } from "@/contexts/MyStay"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type {
BookingConfirmation,
BookingConfirmationSchema,
} from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { RoomCategories } from "@scandic-hotels/trpc/types/hotel"
import type { CreditCard } from "@scandic-hotels/trpc/types/user"
import type { Packages } from "@/types/components/myPages/myStay/ancillaries"
import type { MyStayStore } from "@/types/contexts/my-stay"
import type { getLinkedReservations } from "@/lib/trpc/memoizedRequests"
interface MyStayProviderProps {
bookingConfirmation: BookingConfirmation
breakfastPackages: Packages | null
isLoggedIn?: boolean
lang: Lang
linkedReservationsPromise: Promise<BookingConfirmationSchema[]>
linkedReservationsPromise: ReturnType<typeof getLinkedReservations>
refId: string
roomCategories: RoomCategories
savedCreditCards: CreditCard[] | null
Binary file not shown.

Before

Width:  |  Height:  |  Size: 847 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

After

Width:  |  Height:  |  Size: 208 KiB

@@ -212,6 +212,12 @@ describe("getTimeAgoText", () => {
expect(result).toBe("")
})
it("should return Today for todays date", () => {
const todaysDate = dt().format("YYYY-MM-DD")
const result = getTimeAgoText(todaysDate, mockIntl)
expect(result).toBe("Today")
})
})
describe("boundary transitions", () => {
+7
View File
@@ -22,6 +22,13 @@ export function getTimeAgoText(checkoutDate: string, intl: IntlShape): string {
return ""
}
if (daysDiff === 0) {
return intl.formatMessage({
id: "nextStay.today",
defaultMessage: "Today",
})
}
if (daysDiff <= 30) {
// 1-30 days
return intl.formatMessage(
@@ -198,10 +198,15 @@ export function Room({
{isFlexBooking || isChangeBooking ? (
<li className={styles.listItem}>
<p className={styles.label}>
{intl.formatMessage({
id: "booking.changeOrCancel",
defaultMessage: "Change or cancel",
})}
{isChangeBooking
? intl.formatMessage({
id: "booking.changeTitle",
defaultMessage: "Change",
})
: intl.formatMessage({
id: "booking.changeOrCancel",
defaultMessage: "Change or cancel",
})}
</p>
<p>
{intl.formatMessage(
@@ -9,7 +9,7 @@
overflow-y: auto;
padding: var(--Space-x2) var(--Space-x3);
position: fixed;
top: calc(140px + max(var(--sitewide-alert-sticky-height), 25px));
top: calc(140px + max(var(--sitewide-alert-sticky-height), 15px));
width: 100%;
height: calc(100% - 200px);
z-index: 10010;
@@ -1,13 +1,11 @@
import Footnote from "@scandic-hotels/design-system/Footnote"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./list.module.css"
export default function Label({ children }: React.PropsWithChildren) {
return (
<li className={styles.label}>
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
{children}
</Footnote>
</li>
<Typography variant="Title/Overline/sm">
<li className={styles.label}>{children}</li>
</Typography>
)
}
@@ -5,5 +5,6 @@
}
.label {
padding: 0 var(--Space-x1);
padding: 0 var(--Space-x1) var(--Space-x05);
color: var(--Text-Tertiary);
}
@@ -6,7 +6,6 @@ import { useIntl } from "react-intl"
import { useDebounceValue } from "usehooks-ts"
import { Divider } from "@scandic-hotels/design-system/Divider"
import Footnote from "@scandic-hotels/design-system/Footnote"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
@@ -192,16 +191,14 @@ export default function SearchList({
{typeFilteredSearchHistory && typeFilteredSearchHistory.length > 0 && (
<>
<Divider className={styles.noResultsDivider} />
<Footnote
className={styles.text}
color="uiTextPlaceholder"
textTransform="uppercase"
>
{intl.formatMessage({
id: "bookingWidget.searchList.latestSearches",
defaultMessage: "Latest searches",
})}
</Footnote>
<Typography variant="Title/Overline/sm">
<p className={styles.text}>
{intl.formatMessage({
id: "bookingWidget.searchList.latestSearches",
defaultMessage: "Latest searches",
})}
</p>
</Typography>
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
@@ -226,12 +223,14 @@ export default function SearchList({
if (displaySearchHistory) {
return (
<Dialog getMenuProps={getMenuProps}>
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
{intl.formatMessage({
id: "bookingWidget.searchList.latestSearches",
defaultMessage: "Latest searches",
})}
</Footnote>
<Typography variant="Title/Overline/sm">
<p className={styles.text}>
{intl.formatMessage({
id: "bookingWidget.searchList.latestSearches",
defaultMessage: "Latest searches",
})}
</p>
</Typography>
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
@@ -33,6 +33,8 @@
.text {
padding: 0 var(--Space-x1);
color: var(--Text-Tertiary);
white-space: normal;
}
.textPlaceholderColor {
color: var(--UI-Text-Placeholder);
@@ -5,6 +5,7 @@ import { cx } from "class-variance-authority"
import { useSearchParams } from "next/navigation"
import { use, useEffect, useRef, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@scandic-hotels/common/dt"
import { useScrollLock } from "@scandic-hotels/common/hooks/useScrollLock"
@@ -12,7 +13,7 @@ import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
import { StickyElementNameEnum } from "@scandic-hotels/common/stores/sticky-position"
import { debounce } from "@scandic-hotels/common/utils/debounce"
import isValidJson from "@scandic-hotels/common/utils/isValidJson"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { trpc } from "@scandic-hotels/trpc/client"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
@@ -66,6 +67,7 @@ export default function BookingWidgetClient({
autoLock: false,
})
const shouldFetchAutoComplete = !!data.hotelId || !!data.city
const intl = useIntl()
const { data: destinationsData, isPending } =
trpc.autocomplete.destinations.useQuery(
@@ -257,13 +259,17 @@ export default function BookingWidgetClient({
/>
<div className={styles.backdrop} onClick={closeMobileSearch} />
<div className={formContainerClassNames}>
<button
<IconButton
className={styles.close}
onClick={closeMobileSearch}
type="button"
>
<MaterialIcon icon="close" />
</button>
variant="Muted"
emphasis
aria-label={intl.formatMessage({
id: "common.close",
defaultMessage: "Close",
})}
onPress={closeMobileSearch}
iconName="close"
/>
<Form
type={type}
onClose={closeMobileSearch}
@@ -65,7 +65,7 @@ export default function DatePickerRangeDesktop({
range_start: styles.rangeStart,
root: `${classNames.root} ${styles.container}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
weekday: styles.weekDay,
nav: `${classNames.nav} ${styles.nav}`,
button_next: `${classNames.button_next} ${styles.button_next}`,
button_previous: `${classNames.button_previous} ${styles.button_previous}`,
@@ -6,7 +6,7 @@ import { useIntl } from "react-intl"
import { Lang } from "@scandic-hotels/common/constants/language"
import { dt } from "@scandic-hotels/common/dt"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "../../../../hooks/useLang"
@@ -72,9 +72,17 @@ export default function DatePickerRangeMobile({
return (
<div className={styles.container} ref={monthsRef}>
<header className={styles.header}>
<button className={styles.close} onClick={close} type="button">
<MaterialIcon icon="close" />
</button>
<IconButton
className={styles.close}
variant="Muted"
emphasis
aria-label={intl.formatMessage({
id: "common.close",
defaultMessage: "Close",
})}
onPress={close}
iconName="close"
/>
</header>
<DayPicker
classNames={{
@@ -90,7 +98,7 @@ export default function DatePickerRangeMobile({
range_start: styles.rangeStart,
root: `${classNames.root} ${styles.root}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
weekday: styles.weekDay,
}}
disabled={[
{ from: lastDayOfPreviousMonth, to: yesterday },
@@ -20,12 +20,12 @@ div.months {
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
font-family: var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
font-size: var(--Body-Paragraph-Size);
font-weight: var(--Body-Paragraph-Font-weight-2);
letter-spacing: var(--Body-Paragraph-Letter-spacing);
line-height: 1.5;
text-decoration: none;
}
td.rangeEnd,
@@ -92,14 +92,15 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
}
.weekDay {
color: var(--UI-Text-Placeholder);
font-family: var(--typography-Footnote-Labels-fontFamily);
font-size: var(--typography-Footnote-Labels-fontSize);
font-weight: var(--typography-Footnote-Labels-fontWeight);
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
line-height: var(--typography-Footnote-Labels-lineHeight);
text-decoration: var(--typography-Footnote-Labels-textDecoration);
text-transform: uppercase;
color: var(--Text-Tertiary);
font-family: var(--Title-Overline-sm-Font-family), var(--Title-Overline-sm-Font-fallback);
font-size: var(--Title-Overline-sm-Size);
font-style: normal;
font-weight: var(--Title-Overline-sm-Font-weight);
line-height: 1.5;
letter-spacing: var(--Title-Overline-sm-Letter-spacing);
text-transform: var(--Title-Overline-sm-Text-Transform);
text-decoration: none;
}
.footer {
@@ -97,12 +97,12 @@ div.months {
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
font-family: var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
font-size: var(--Body-Paragraph-Size);
font-weight: var(--Body-Paragraph-Font-weight-2);
letter-spacing: var(--Body-Paragraph-Letter-spacing);
line-height: 1.5;
text-decoration: none;
}
td.rangeEnd,
@@ -165,15 +165,15 @@ td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
}
.weekDay {
color: var(--Base-Text-Medium-contrast);
opacity: 1;
font-family: var(--typography-Caption-Labels-fontFamily);
font-size: var(--typography-Caption-Labels-fontSize);
font-weight: var(--typography-Caption-Labels-fontWeight);
letter-spacing: var(--typography-Caption-Labels-letterSpacing);
line-height: var(--typography-Caption-Labels-lineHeight);
text-decoration: var(--typography-Caption-Labels-textDecoration);
text-transform: uppercase;
color: var(--Text-Tertiary);
font-family: var(--Title-Overline-sm-Font-family), var(--Title-Overline-sm-Font-fallback);
font-size: var(--Title-Overline-sm-Size);
font-style: normal;
font-weight: var(--Title-Overline-sm-Font-weight);
line-height: 1.5;
letter-spacing: var(--Title-Overline-sm-Letter-spacing);
text-transform: var(--Title-Overline-sm-Text-Transform);
text-decoration: none;
}
@media screen and (min-width: 1367px) {
@@ -5,6 +5,7 @@ import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
@@ -114,9 +115,17 @@ export default function GuestsRoomsPickerDialog({
<>
<section className={styles.contentWrapper}>
<header className={styles.header}>
<button type="button" className={styles.close} onClick={onClose}>
<MaterialIcon icon="close" />
</button>
<IconButton
className={styles.close}
variant="Muted"
emphasis
aria-label={intl.formatMessage({
id: "common.close",
defaultMessage: "Close",
})}
onPress={onClose}
iconName="close"
/>
</header>
<div className={styles.contentContainer}>
@@ -4,9 +4,8 @@ import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Footnote from "@scandic-hotels/design-system/Footnote"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useBookingFlowConfig } from "../../../../../bookingFlowConfig/bookingFlowConfigContext"
@@ -83,8 +82,8 @@ export default function JoinScandicFriendsCard({
</Typography>
</Checkbox>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.terms}>
{intl.formatMessage(
{
id: "enterDetails.joinScandicFriendsCard.terms",
@@ -93,19 +92,18 @@ export default function JoinScandicFriendsCard({
},
{
termsAndConditionsLink: (str) => (
<Link
textDecoration="underline"
size="tiny"
<TextLink
typography="Link/sm"
target="_blank"
href={routes.membershipTermsAndConditions[lang]}
>
{str}
</Link>
</TextLink>
),
}
)}
</Footnote>
</div>
</p>
</Typography>
</div>
)
}
@@ -28,6 +28,7 @@
.terms {
grid-area: terms;
color: var(--Text-Secondary);
}
@media screen and (min-width: 768px) {
@@ -4,9 +4,8 @@ import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Footnote from "@scandic-hotels/design-system/Footnote"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useBookingFlowConfig } from "../../../../../bookingFlowConfig/bookingFlowConfigContext"
@@ -97,8 +96,8 @@ export function PartnerSASJoinScandicFriendsCard({
/>
</div>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.terms}>
{intl.formatMessage(
{
id: "enterDetails.joinScandicFriendsCard.terms",
@@ -107,19 +106,18 @@ export function PartnerSASJoinScandicFriendsCard({
},
{
termsAndConditionsLink: (str) => (
<Link
textDecoration="underline"
size="tiny"
<TextLink
typography="Link/sm"
target="_blank"
href={routes.membershipTermsAndConditions[lang]}
>
{str}
</Link>
</TextLink>
),
}
)}
</Footnote>
</div>
</p>
</Typography>
</div>
)
}
@@ -31,6 +31,7 @@
.terms {
grid-area: terms;
color: var(--Text-Secondary);
}
@media screen and (min-width: 768px) {
@@ -4,10 +4,9 @@ import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { useLazyPathname } from "@scandic-hotels/common/hooks/useLazyPathname"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Footnote from "@scandic-hotels/design-system/Footnote"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import { LoginButton } from "@scandic-hotels/design-system/LoginButton"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trackEvent } from "@scandic-hotels/tracking/base"
import { trackLoginClick } from "@scandic-hotels/tracking/navigation"
@@ -101,8 +100,8 @@ export function JoinScandicFriendsCard({ name = "join" }: Props) {
})}
</LoginButton>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.terms}>
{intl.formatMessage(
{
id: "enterDetails.joinScandicFriendsCard.terms",
@@ -111,19 +110,18 @@ export function JoinScandicFriendsCard({ name = "join" }: Props) {
},
{
termsAndConditionsLink: (str) => (
<Link
textDecoration="underline"
size="tiny"
<TextLink
typography="Link/sm"
target="_blank"
href={routes.membershipTermsAndConditions[lang]}
>
{str}
</Link>
</TextLink>
),
}
)}
</Footnote>
</div>
</p>
</Typography>
</div>
)
}
@@ -34,6 +34,7 @@
.terms {
grid-area: terms;
color: var(--Text-Secondary);
}
@media screen and (min-width: 768px) {
@@ -4,9 +4,8 @@ import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Footnote from "@scandic-hotels/design-system/Footnote"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
@@ -94,8 +93,8 @@ export function PartnerSASJoinScandicFriendsCard({
/>
</div>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.terms}>
{intl.formatMessage(
{
id: "enterDetails.joinScandicFriendsCard.terms",
@@ -104,19 +103,18 @@ export function PartnerSASJoinScandicFriendsCard({
},
{
termsAndConditionsLink: (str) => (
<Link
textDecoration="underline"
size="tiny"
<TextLink
typography="Link/sm"
target="_blank"
href={routes.membershipTermsAndConditions[lang]}
>
{str}
</Link>
</TextLink>
),
}
)}
</Footnote>
</div>
</p>
</Typography>
</div>
)
}
@@ -31,6 +31,7 @@
.terms {
grid-area: terms;
color: var(--Text-Secondary);
}
@media screen and (min-width: 768px) {

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