Merged in feat/sw-3225-move-parking-information-to-booking-flow (pull request #2614)

feat(SW-3225): Move ParkingInformation to design-system

* Inline ParkingInformation types to remove trpc dependency

* Move ParkingInformation to design-system

* Move numberFormatting to common package

* Add deps to external

* Fix imports and i18n script

* Add common as dependency

* Merge branch 'master' into feat/sw-3225-move-parking-information-to-booking-flow


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-08-12 12:36:31 +00:00
parent 8518d018f8
commit 800dc5c3c1
74 changed files with 188 additions and 171 deletions

View File

@@ -0,0 +1,88 @@
'use client'
import { useIntl } from 'react-intl'
import { Typography } from '../../Typography'
import styles from './parkingList.module.css'
import type { Parking } from '../parkingInformationTypes'
type ParkingListProps = Pick<
Parking,
| 'address'
| 'canMakeReservation'
| 'distanceToHotel'
| 'numberOfChargingSpaces'
| 'numberOfParkingSpots'
>
export default function ParkingList({
numberOfChargingSpaces,
canMakeReservation,
numberOfParkingSpots,
distanceToHotel,
address,
}: ParkingListProps) {
const intl = useIntl()
const canMakeReservationYesMsg = intl.formatMessage({
defaultMessage: 'Parking can be reserved in advance: Yes',
})
const canMakeReservationNoMsg = intl.formatMessage({
defaultMessage: 'Parking can be reserved in advance: No',
})
return (
<Typography variant="Body/Paragraph/mdRegular">
<ul className={styles.listStyling}>
{numberOfChargingSpaces ? (
<li>
{intl.formatMessage(
{
defaultMessage:
'Number of charging points for electric cars: {number}',
},
{ number: numberOfChargingSpaces }
)}
</li>
) : null}
<li>
{canMakeReservation
? canMakeReservationYesMsg
: canMakeReservationNoMsg}
</li>
{numberOfParkingSpots ? (
<li>
{intl.formatMessage(
{
defaultMessage: 'Number of parking spots: {number}',
},
{ number: numberOfParkingSpots }
)}
</li>
) : null}
{distanceToHotel ? (
<li>
{intl.formatMessage(
{
defaultMessage: 'Distance to hotel: {distanceInM} m',
},
{ distanceInM: distanceToHotel }
)}
</li>
) : null}
{address ? (
<li>
{intl.formatMessage(
{
defaultMessage: 'Address: {address}',
},
{ address }
)}
</li>
) : null}
</ul>
</Typography>
)
}

View File

@@ -0,0 +1,11 @@
.listStyling {
list-style-type: none;
}
.listStyling > li::before {
content: url('/_static/icons/heart.svg');
position: relative;
height: 8px;
top: 3px;
margin-right: var(--Spacing-x1);
}

View File

@@ -0,0 +1,84 @@
'use client'
import { useIntl } from 'react-intl'
import { Typography } from '../../Typography'
import { formatPrice } from '@scandic-hotels/common/utils/numberFormatting'
import { type Parking, ParkingPricePeriods } from '../parkingInformationTypes'
import { getPeriod } from './utils'
import styles from './parkingPrices.module.css'
interface ParkingPricesProps
extends Pick<Parking['pricing'], 'freeParking'>,
Pick<NonNullable<Parking['pricing']['localCurrency']>, 'currency'> {
pricing: NonNullable<Parking['pricing']['localCurrency']>['ordinary']
}
export default function ParkingPrices({
currency = '',
freeParking,
pricing,
}: ParkingPricesProps) {
const intl = useIntl()
if (freeParking) {
return (
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.wrapper}>
{intl.formatMessage({ defaultMessage: 'Free parking' })}
</p>
</Typography>
)
}
const filteredPricing = pricing.filter((price) => price.amount > 0)
if (filteredPricing.length === 0) {
return (
<dl className={styles.wrapper}>
<div className={styles.period}>
<div className={styles.information}>
<Typography variant="Body/Paragraph/mdBold">
<dt>{getPeriod(intl, 'Hour')}</dt>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<dd>
{intl.formatMessage({
defaultMessage: 'At a cost',
})}
</dd>
</Typography>
</div>
</div>
</dl>
)
}
return (
<dl className={styles.wrapper}>
{filteredPricing.map(({ period, amount, startTime, endTime }) => (
<div key={period} className={styles.period}>
<div className={styles.information}>
<Typography variant="Body/Paragraph/mdBold">
<dt>{getPeriod(intl, period)}</dt>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<dd>{formatPrice(intl, amount, currency)}</dd>
</Typography>
</div>
{startTime && endTime && period !== ParkingPricePeriods.allDay ? (
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.information}>
<dt>{intl.formatMessage({ defaultMessage: 'From' })}</dt>
<dd>{`${startTime}-${endTime}`}</dd>
</div>
</Typography>
) : null}
</div>
))}
</dl>
)
}

View File

@@ -0,0 +1,20 @@
.wrapper {
display: grid;
row-gap: var(--Spacing-x1);
margin: 0;
color: var(--Text-Default);
}
.period {
display: flex;
gap: var(--Spacing-x5);
}
.information {
margin: 0;
flex: 1;
}
.priceHeading {
color: var(--Text-Secondary);
}

View File

@@ -0,0 +1,26 @@
import { ParkingPricePeriods } from '../parkingInformationTypes'
import type { IntlShape } from 'react-intl'
export function getPeriod(intl: IntlShape, period?: string) {
switch (period) {
case ParkingPricePeriods.hour:
return intl.formatMessage({
defaultMessage: 'Price per hour',
})
case ParkingPricePeriods.day:
return intl.formatMessage({
defaultMessage: 'Price per day',
})
case ParkingPricePeriods.night:
return intl.formatMessage({
defaultMessage: 'Price per night',
})
case ParkingPricePeriods.allDay:
return intl.formatMessage({
defaultMessage: 'Price per 24 hours',
})
default:
return period
}
}

View File

@@ -0,0 +1,91 @@
'use client'
import { useIntl } from 'react-intl'
import { Divider } from '../Divider'
import { MaterialIcon } from '../Icons/MaterialIcon'
import { Typography } from '../Typography'
import ButtonLink from '../ButtonLink'
import ParkingList from './ParkingList'
import ParkingPrices from './ParkingPrices'
import styles from './parkingInformation.module.css'
import type { Parking } from './parkingInformationTypes'
type ParkingInformationProps = {
parking: Parking
showExternalParkingButton?: boolean
}
export default function ParkingInformation({
parking,
showExternalParkingButton = true,
}: ParkingInformationProps) {
const intl = useIntl()
const title = `${parking.type}${parking.name ? ` (${parking.name})` : ''}`
return (
<div className={styles.parkingInformation}>
<div className={styles.list}>
<Typography variant="Title/Subtitle/md">
<h4 className={styles.heading}>{title}</h4>
</Typography>
<ParkingList
numberOfChargingSpaces={parking.numberOfChargingSpaces}
canMakeReservation={parking.canMakeReservation}
numberOfParkingSpots={parking.numberOfParkingSpots}
distanceToHotel={parking.distanceToHotel}
address={parking.address}
/>
</div>
<div className={styles.prices}>
<Typography variant="Body/Paragraph/mdBold">
<h5 className={styles.heading}>
{intl.formatMessage({ defaultMessage: 'Prices' })}
</h5>
</Typography>
<div className={styles.priceWrapper}>
<Typography variant="Title/Overline/sm">
<h6 className={styles.priceHeading}>
{intl.formatMessage({ defaultMessage: 'Weekday prices' })}
</h6>
</Typography>
<Divider />
{parking.pricing.localCurrency ? (
<ParkingPrices
currency={parking.pricing.localCurrency.currency}
freeParking={parking.pricing.freeParking}
pricing={parking.pricing.localCurrency.ordinary}
/>
) : null}
</div>
<div className={styles.priceWrapper}>
<Typography variant="Title/Overline/sm">
<h6 className={styles.priceHeading}>
{intl.formatMessage({ defaultMessage: 'Weekend prices' })}
</h6>
</Typography>
<Divider />
{parking.pricing.localCurrency ? (
<ParkingPrices
currency={parking.pricing.localCurrency.currency}
freeParking={parking.pricing.freeParking}
pricing={parking.pricing.localCurrency.weekend}
/>
) : null}
</div>
</div>
{parking.externalParkingUrl && showExternalParkingButton && (
<ButtonLink
typography="Body/Paragraph/mdBold"
href={parking.externalParkingUrl}
target="_blank"
>
{intl.formatMessage({ defaultMessage: 'Book parking' })}
<MaterialIcon icon="open_in_new" color="CurrentColor" />
</ButtonLink>
)}
</div>
)
}

View File

@@ -0,0 +1,26 @@
.parkingInformation {
display: grid;
gap: var(--Spacing-x3);
}
.list,
.prices {
display: grid;
gap: var(--Spacing-x-one-and-half);
}
.priceWrapper {
background-color: var(--Base-Surface-Subtle-Normal);
border-radius: var(--Corner-radius-md);
padding: var(--Spacing-x2) var(--Spacing-x3);
display: grid;
gap: var(--Spacing-x1);
}
.heading {
color: var(--Text-Default);
}
.priceHeading {
color: var(--Text-Secondary);
}

View File

@@ -0,0 +1,35 @@
type Price = {
amount: number
endTime: string
period: string
startTime: string
}
type CurrencyPrice = {
currency: string
weekend: Price[]
ordinary: Price[]
}
export type Parking = {
address: string
canMakeReservation: boolean
distanceToHotel: number
externalParkingUrl: string
name: string
numberOfChargingSpaces: number
numberOfParkingSpots: number
type: string
pricing: {
freeParking: boolean
paymentType: string
localCurrency?: CurrencyPrice | null
}
}
export enum ParkingPricePeriods {
allDay = 'AllDay',
hour = 'Hour',
day = 'Day',
night = 'Night',
}

View File

@@ -41,6 +41,7 @@
"./RegularRateCard": "./lib/components/RateCard/Regular/index.tsx",
"./CampaignRateCard": "./lib/components/RateCard/Campaign/index.tsx",
"./CodeRateCard": "./lib/components/RateCard/Code/index.tsx",
"./ParkingInformation": "./lib/components/ParkingInformation/index.tsx",
"./PointsRateCard": "./lib/components/RateCard/Points/index.tsx",
"./Preamble": "./lib/components/Preamble/index.tsx",
"./NoRateAvailableCard": "./lib/components/RateCard/NoRateAvailable/index.tsx",
@@ -161,6 +162,9 @@
"prepare": "husky && yarn run build",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@scandic-hotels/common": "workspace:*"
},
"peerDependencies": {
"@internationalized/date": "^3.8.0",
"@radix-ui/react-slot": "^1.2.2",
@@ -170,6 +174,7 @@
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.2",
"react-international-phone": "^4.5.0",
"react-intl": "^7",
"usehooks-ts": "3.1.1"
},
"devDependencies": {

View File

@@ -47,6 +47,8 @@ export default defineConfig({
'react/jsx-runtime',
'react-aria-components',
'react-hook-form',
'react-intl',
'next',
],
onwarn(warning, defaultHandler) {
if (