Merged in feat/SW-459-payment-flow-ui-ux (pull request #657)
Feat/SW-459 payment flow ui ux * feat(SW-431): List payment methods and handle booking status and redirection * feat(SW-431): small fix * fix(SW-431): Added intl string and sorted dictionaries * fix(SW-431): add todo comments * feat(SW-459): Added payment method icons * feat(SW-459): refactored into new component and added form * feat(SW-459): Localized strings * feat(SW-459): added checkbox * feat(SW-459): Refactored payment options and updated payment form * feat(SW-459): update input bg color * feat(SW-459): add current web links and style fixes * fix(SW-459): fix issue with booking confirmation not being accessible * feat(SW-459): style changes * feat(SW-459): update max width of payment container * feat(SW-459): update create booking schema * feat(SW-459): fixes from PR Approved-by: Arvid Norlin
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import Image from "next/image"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { PAYMENT_METHOD_ICONS } from "@/constants/booking"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { PaymentOptionProps } from "./paymentOption"
|
||||
|
||||
import styles from "./paymentOption.module.css"
|
||||
|
||||
export default function PaymentOption({
|
||||
name,
|
||||
value,
|
||||
label,
|
||||
}: PaymentOptionProps) {
|
||||
const { register } = useFormContext()
|
||||
return (
|
||||
<label key={value} className={styles.paymentOption} htmlFor={value}>
|
||||
<div className={styles.titleContainer}>
|
||||
<input
|
||||
aria-hidden
|
||||
hidden
|
||||
type="radio"
|
||||
id={value}
|
||||
value={value}
|
||||
{...register(name)}
|
||||
/>
|
||||
<span className={styles.radio} />
|
||||
<Body asChild>
|
||||
<label htmlFor={value}>{label}</label>
|
||||
</Body>
|
||||
</div>
|
||||
<Image
|
||||
className={styles.paymentOptionIcon}
|
||||
src={PAYMENT_METHOD_ICONS[value]}
|
||||
alt={label}
|
||||
width={48}
|
||||
height={32}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.paymentOption {
|
||||
position: relative;
|
||||
background-color: var(--UI-Input-Controls-Surface-Normal);
|
||||
padding: var(--Spacing-x3);
|
||||
border: 1px solid var(--Base-Border-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paymentOption .radio {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--Base-Border-Normal);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paymentOption input:checked + .radio {
|
||||
border: 8px solid var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.paymentOptionIcon {
|
||||
position: absolute;
|
||||
right: var(--Spacing-x3);
|
||||
top: calc(50% - 16px);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RegisterOptions } from "react-hook-form"
|
||||
|
||||
import { PaymentMethodEnum } from "@/constants/booking"
|
||||
|
||||
export interface PaymentOptionProps {
|
||||
name: string
|
||||
value: PaymentMethodEnum
|
||||
label: string
|
||||
registerOptions?: RegisterOptions
|
||||
}
|
||||
@@ -1,21 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Label as AriaLabel } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
BOOKING_CONFIRMATION_NUMBER,
|
||||
BookingStatusEnum,
|
||||
PAYMENT_METHOD_TITLES,
|
||||
PaymentMethodEnum,
|
||||
} from "@/constants/booking"
|
||||
import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Checkbox from "@/components/TempDesignSystem/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import PaymentOption from "./PaymentOption"
|
||||
import { PaymentFormData, paymentSchema } from "./schema"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
@@ -28,7 +43,17 @@ export default function Payment({ hotel }: PaymentProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>("")
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
defaultValues: {
|
||||
paymentMethod: PaymentMethodEnum.card,
|
||||
smsConfirmation: false,
|
||||
termsAndConditions: false,
|
||||
},
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(paymentSchema),
|
||||
})
|
||||
|
||||
const initiateBooking = trpc.booking.booking.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
@@ -38,7 +63,6 @@ export default function Payment({ hotel }: PaymentProps) {
|
||||
BOOKING_CONFIRMATION_NUMBER,
|
||||
result.confirmationNumber
|
||||
)
|
||||
|
||||
setConfirmationNumber(result.confirmationNumber)
|
||||
} else {
|
||||
// TODO: add proper error message
|
||||
@@ -60,12 +84,12 @@ export default function Payment({ hotel }: PaymentProps) {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.paymentUrl) {
|
||||
if (confirmationNumber && bookingStatus?.data?.paymentUrl) {
|
||||
router.push(bookingStatus.data.paymentUrl)
|
||||
}
|
||||
}, [bookingStatus, router])
|
||||
}, [confirmationNumber, bookingStatus, router])
|
||||
|
||||
function handleSubmit() {
|
||||
function handleSubmit(data: PaymentFormData) {
|
||||
initiateBooking.mutate({
|
||||
hotelId: hotel.operaId,
|
||||
checkInDate: "2024-12-10",
|
||||
@@ -91,11 +115,11 @@ export default function Payment({ hotel }: PaymentProps) {
|
||||
petFriendly: true,
|
||||
accessibility: true,
|
||||
},
|
||||
smsConfirmationRequested: true,
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
},
|
||||
],
|
||||
payment: {
|
||||
paymentMethod: selectedPaymentMethod,
|
||||
paymentMethod: data.paymentMethod,
|
||||
cardHolder: {
|
||||
email: "test.user@scandichotels.com",
|
||||
name: "Test User",
|
||||
@@ -117,45 +141,80 @@ export default function Payment({ hotel }: PaymentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className={styles.paymentItemContainer}>
|
||||
<button
|
||||
className={styles.paymentItem}
|
||||
onClick={() => setSelectedPaymentMethod("card")}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="payment-method"
|
||||
id="card"
|
||||
value="card"
|
||||
defaultChecked={selectedPaymentMethod === "card"}
|
||||
/>
|
||||
<label htmlFor="card">card</label>
|
||||
</button>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<div className={styles.paymentOptionContainer}>
|
||||
<PaymentOption
|
||||
name="paymentMethod"
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
/>
|
||||
{hotel.merchantInformationData.alternatePaymentOptions.map(
|
||||
(paymentOption) => (
|
||||
<button
|
||||
key={paymentOption}
|
||||
className={styles.paymentItem}
|
||||
onClick={() => setSelectedPaymentMethod(paymentOption)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="payment-method"
|
||||
id={paymentOption}
|
||||
value={paymentOption}
|
||||
defaultChecked={selectedPaymentMethod === paymentOption}
|
||||
/>
|
||||
<label htmlFor={paymentOption}>{paymentOption}</label>
|
||||
</button>
|
||||
(paymentMethod) => (
|
||||
<PaymentOption
|
||||
key={paymentMethod}
|
||||
name="paymentMethod"
|
||||
value={paymentMethod as PaymentMethodEnum}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button disabled={!selectedPaymentMethod} onClick={handleSubmit}>
|
||||
{intl.formatMessage({ id: "Complete booking & go to payment" })}
|
||||
</Button>
|
||||
</div>
|
||||
<Checkbox name="smsConfirmation">
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
id: "I would like to get my booking confirmation via sms",
|
||||
})}
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
|
||||
<AriaLabel className={styles.terms}>
|
||||
<Checkbox name="termsAndConditions" />
|
||||
<Caption>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "booking.terms",
|
||||
},
|
||||
{
|
||||
termsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</AriaLabel>
|
||||
<Button
|
||||
type="submit"
|
||||
className={styles.submitButton}
|
||||
disabled={
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "Complete booking & go to payment" })}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
.paymentItemContainer {
|
||||
max-width: 480px;
|
||||
.paymentContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding-bottom: var(--Spacing-x4);
|
||||
gap: var(--Spacing-x3);
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.paymentItem {
|
||||
background-color: var(--Base-Background-Normal);
|
||||
padding: var(--Spacing-x3);
|
||||
border: 1px solid var(--Base-Border-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
.paymentOptionContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
cursor: pointer;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.paymentContainer .link {
|
||||
font-weight: 500;
|
||||
font-size: var(--Typography-Caption-Regular-fontSize);
|
||||
}
|
||||
|
||||
.terms {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
13
components/HotelReservation/SelectRate/Payment/schema.ts
Normal file
13
components/HotelReservation/SelectRate/Payment/schema.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { PaymentMethodEnum } from "@/constants/booking"
|
||||
|
||||
export const paymentSchema = z.object({
|
||||
paymentMethod: z.nativeEnum(PaymentMethodEnum),
|
||||
smsConfirmation: z.boolean(),
|
||||
termsAndConditions: z.boolean().refine((value) => value === true, {
|
||||
message: "You must accept the terms and conditions",
|
||||
}),
|
||||
})
|
||||
|
||||
export interface PaymentFormData extends z.output<typeof paymentSchema> {}
|
||||
@@ -17,6 +17,10 @@
|
||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
|
||||
.wrapper:last-child .main {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user