Merged in SW-3396-move-my-saved-cards-to-design-system (pull request #2762)

SW-3396 move my saved cards to design system

* Move PaymentOption, PaymentOptionsGroup, PaymentIcons and MySavedCards (renamed SelectPaymentMethod) to design-system

* Remove unused svg payment icons

* cleanu

* cleanup

* trackUpdatePaymentMethod: remove hotelId argument that was never passed


Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-09-04 13:01:36 +00:00
parent 8e00769c64
commit 6fa301f8e7
57 changed files with 1687 additions and 583 deletions

View File

@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import Checkbox from './index'
import { FormDecorator } from '../../../../.storybook/decorators/FormDecorator'
const meta: Meta<typeof Checkbox> = {
title: 'Components/Form/Checkbox',
component: Checkbox,
decorators: [FormDecorator],
args: { name: 'checkbox' },
}
export default meta
type Story = StoryObj<typeof Checkbox>
export const Default: Story = {
args: {},
}

View File

@@ -0,0 +1,53 @@
import { cx } from 'class-variance-authority'
import { Label, Radio } from 'react-aria-components'
import styles from './paymentOption.module.css'
import type { PaymentMethodEnum } from '@scandic-hotels/common/constants/paymentMethod'
import { PaymentMethodIcon } from '../../Payment/PaymentMethodIcon'
import { Typography } from '../../Typography'
export type PaymentOptionProps = {
value: PaymentMethodEnum
label: string
cardNumber?: string
}
export function PaymentOption({
value,
label,
cardNumber,
}: PaymentOptionProps) {
return (
<Radio
value={value}
className={({ isFocusVisible }) =>
cx(styles.paymentOption, { [styles.focused]: isFocusVisible })
}
>
{({ isSelected }) => (
<>
<div className={styles.titleContainer}>
<span
className={cx(styles.radio, { [styles.selected]: isSelected })}
aria-hidden
/>
<Typography variant="Body/Paragraph/mdRegular">
<Label>{label}</Label>
</Typography>
</div>
{cardNumber ? (
<>
<Typography variant={'Body/Supporting text (caption)/smRegular'}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span> {cardNumber}</span>
</Typography>
</>
) : (
<PaymentMethodIcon paymentMethod={value} alt={label} />
)}
</>
)}
</Radio>
)
}

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { expect, fn } from 'storybook/test'
import { PaymentOptionsGroup } from './PaymentOptionsGroup'
import { PaymentOption } from './PaymentOption'
import { PaymentMethodEnum } from '@scandic-hotels/common/constants/paymentMethod'
import { FormDecorator } from '../../../../.storybook/decorators/FormDecorator'
const meta: Meta<typeof PaymentOptionsGroup> = {
title: 'Components/Payment/PaymentOptionsGroup',
component: PaymentOptionsGroup,
decorators: [FormDecorator],
}
export default meta
type Story = StoryObj<typeof PaymentOptionsGroup>
export const Default: Story = {
args: {
label: 'Select Payment Method',
name: 'paymentMethod',
onChange: fn(),
children: (
<>
<PaymentOption label="Visa" value={PaymentMethodEnum.visa} />
<PaymentOption
label="American Express"
value={PaymentMethodEnum.americanExpress}
/>
<PaymentOption
label="MasterCard"
value={PaymentMethodEnum.masterCard}
cardNumber="1234"
/>
</>
),
},
play: async ({ canvas, userEvent, args }) => {
const visaOption = await canvas.findByRole('radio', { name: 'Visa' })
expect(visaOption).toBeInTheDocument()
expect(args.onChange).not.toHaveBeenCalled()
await userEvent.click(visaOption)
expect(args.onChange).toHaveBeenCalledWith('visa')
},
}

View File

@@ -0,0 +1,48 @@
'use client'
import { Label, RadioGroup } from 'react-aria-components'
import { useController, useFormContext } from 'react-hook-form'
import type { ReactNode } from 'react'
import { Typography } from '../../../components/Typography'
interface PaymentOptionsGroupProps {
name: string
label?: string
children: ReactNode
className?: string
onChange?: (newValue: string) => void
}
export function PaymentOptionsGroup({
name,
label,
children,
className,
onChange,
}: PaymentOptionsGroupProps) {
const { control } = useFormContext()
const {
field: { value, onChange: formOnChange },
} = useController({
name,
control,
})
const handleChange = (newValue: string) => {
formOnChange(newValue)
onChange?.(newValue)
}
return (
<RadioGroup value={value} onChange={handleChange} className={className}>
{label ? (
<Typography variant="Title/Overline/sm">
<Label>{label}</Label>
</Typography>
) : null}
{children}
</RadioGroup>
)
}

View File

@@ -0,0 +1,41 @@
.paymentOption {
position: relative;
background-color: var(--UI-Input-Controls-Surface-Normal);
padding: var(--Space-x15) var(--Space-x2);
border: 1px solid var(--Base-Border-Normal);
border-radius: var(--Corner-radius-md);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--Spacing-x2);
cursor: pointer;
}
.paymentOption.focused {
outline: 2px solid var(--UI-Input-Controls-Border-Focus);
outline-offset: 2px;
}
.radio {
width: 24px;
height: 24px;
border: 1px solid var(--Base-Border-Normal);
border-radius: 50%;
cursor: pointer;
}
.radio.selected {
border: 8px solid var(--Surface-UI-Fill-Active);
}
.titleContainer {
display: flex;
align-items: center;
gap: var(--Spacing-x-one-and-half);
}
.paymentOptionIcon {
position: absolute;
right: var(--Spacing-x3);
top: calc(50% - 16px);
}

View File

@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { fn, expect } from 'storybook/test'
import { SelectPaymentMethod } from './index'
import { PaymentMethodEnum } from '@scandic-hotels/common/constants/paymentMethod'
import { FormDecorator } from '../../../../.storybook/decorators/FormDecorator'
const meta: Meta<typeof SelectPaymentMethod> = {
title: 'Components/Payment/SelectCreditCard',
component: SelectPaymentMethod,
argTypes: {},
decorators: [FormDecorator],
}
export default meta
type Story = StoryObj<typeof SelectPaymentMethod>
export const PrimaryDefault: Story = {
args: {
onChange: fn(),
paymentMethods: [
{
id: 'klarna',
alias: 'Card 1',
cardType: PaymentMethodEnum.klarna,
truncatedNumber: '1234',
},
{
id: 'applePay',
alias: 'Card 2',
cardType: PaymentMethodEnum.applePay,
truncatedNumber: '1234',
},
],
},
play: async ({ canvas, userEvent, args }) => {
const options = await canvas.findAllByRole('radio')
expect(options[0]).toBeInTheDocument()
expect(args.onChange).not.toHaveBeenCalled()
await userEvent.click(options[0])
expect(args.onChange).toHaveBeenCalledWith('klarna')
},
}

View File

@@ -0,0 +1,62 @@
import { useIntl } from 'react-intl'
import { PaymentOptionsGroup } from '../PaymentOption/PaymentOptionsGroup'
import { PaymentOption } from '../PaymentOption/PaymentOption'
import styles from './selectPaymentMethod.module.css'
import {
PAYMENT_METHOD_TITLES,
type PaymentMethodEnum,
} from '@scandic-hotels/common/constants/paymentMethod'
type PaymentMethod = {
id: string
truncatedNumber: string
alias: string
cardType: PaymentMethodEnum
}
type SelectPaymentMethodProps = {
paymentMethods: PaymentMethod[]
onChange: (value: string) => void
formName: string
}
export function SelectPaymentMethod({
paymentMethods,
onChange,
formName,
}: SelectPaymentMethodProps) {
const intl = useIntl()
const mySavedCardsLabel = intl.formatMessage({
defaultMessage: 'MY SAVED CARDS',
})
return (
<section className={styles.section}>
<PaymentOptionsGroup
name={formName}
label={mySavedCardsLabel}
className={styles.paymentOptionContainer}
onChange={onChange}
>
{paymentMethods?.map((paymentMethods) => {
const label =
PAYMENT_METHOD_TITLES[paymentMethods.cardType] ||
paymentMethods.alias ||
paymentMethods.cardType
return (
<PaymentOption
key={paymentMethods.id}
value={paymentMethods.id as PaymentMethodEnum}
label={label}
cardNumber={paymentMethods.truncatedNumber}
/>
)
})}
</PaymentOptionsGroup>
</section>
)
}

View File

@@ -0,0 +1,11 @@
.paymentOptionContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.section {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}