From 7317d6e21d24b6be9c22531110d9260293bd98db Mon Sep 17 00:00:00 2001 From: Ayon95 Date: Thu, 14 Mar 2024 23:51:16 -0400 Subject: [PATCH] add booking details page --- src/App.tsx | 2 + src/features/authentication/Logout.tsx | 2 +- src/features/bookings/BookingDetails.tsx | 166 ++++++++++++++++++ .../bookings/BookingGuestDetailsTable.tsx | 73 ++++++++ .../bookings/BookingPaymentSummary.tsx | 85 +++++++++ src/features/bookings/BookingRow.tsx | 24 ++- src/features/bookings/BookingTable.tsx | 3 + .../bookings/__tests__/Booking.test.tsx | 143 +++++++++++++++ .../bookings/__tests__/BookingTable.test.tsx | 50 +++++- src/features/bookings/hooks/useBooking.ts | 12 ++ src/features/cabins/CabinRow.tsx | 4 +- src/pages/Booking.tsx | 30 ++++ src/services/apiBookings.ts | 25 ++- src/styles/GlobalStyles.tsx | 7 + src/test/fixtures/bookings.ts | 37 ++-- src/test/mocks/domains/bookings.ts | 13 ++ src/test/mocks/models/booking.ts | 6 + src/types/bookings.ts | 16 +- src/ui/Table.tsx | 4 +- src/ui/button/ButtonIconText.tsx | 11 +- src/utils/constants.ts | 9 + 21 files changed, 675 insertions(+), 47 deletions(-) create mode 100644 src/features/bookings/BookingDetails.tsx create mode 100644 src/features/bookings/BookingGuestDetailsTable.tsx create mode 100644 src/features/bookings/BookingPaymentSummary.tsx create mode 100644 src/features/bookings/__tests__/Booking.test.tsx create mode 100644 src/features/bookings/hooks/useBooking.ts create mode 100644 src/pages/Booking.tsx diff --git a/src/App.tsx b/src/App.tsx index 00cc932..8f5a0ae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import GlobalStyles from '@/styles/GlobalStyles'; import Dashboard from '@/pages/Dashboard'; import Bookings from '@/pages/Bookings'; +import Booking from './pages/Booking'; import Cabins from '@/pages/Cabins'; import Settings from '@/pages/Settings'; import Account from '@/pages/Account'; @@ -35,6 +36,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/features/authentication/Logout.tsx b/src/features/authentication/Logout.tsx index 136c077..d2f7694 100644 --- a/src/features/authentication/Logout.tsx +++ b/src/features/authentication/Logout.tsx @@ -1,7 +1,7 @@ import { HiOutlineArrowRightOnRectangle } from 'react-icons/hi2'; import { useLogout } from './hooks/useLogout'; import SpinnerMini from '@/ui/spinner/SpinnerMini'; -import ButtonIconText from '@/ui/button/ButtonIconText'; +import { ButtonIconText } from '@/ui/button/ButtonIconText'; function Logout() { const { mutate, isLoading } = useLogout(); diff --git a/src/features/bookings/BookingDetails.tsx b/src/features/bookings/BookingDetails.tsx new file mode 100644 index 0000000..965956a --- /dev/null +++ b/src/features/bookings/BookingDetails.tsx @@ -0,0 +1,166 @@ +import styled from 'styled-components'; +import { HiArrowLeft, HiOutlineHomeModern } from 'react-icons/hi2'; + +import Heading from '@/ui/Heading'; +import { LinkButtonIconText } from '@/ui/button/ButtonIconText'; +import Badge from '@/ui/Badge'; +import { bookingStatusToBadgeColorMap } from '@/utils/constants'; +import { Booking, BookingStatus } from '@/types/bookings'; +import { formatDate } from '@/utils/helpers'; +import BookingGuestDetailsTable from './BookingGuestDetailsTable'; +import BookingPaymentSummary from './BookingPaymentSummary'; + +interface BookingProps { + booking: Booking; +} + +const dateFormatOptions: Intl.DateTimeFormatOptions = { + weekday: 'short', + month: 'short', + day: '2-digit', + year: 'numeric', +}; + +function BookingDetails({ booking }: BookingProps) { + return ( + +
+ + Booking #{booking.id} + + {booking.status} + + + + + Bookings + +
+ + Booked on{' '} + + + +
+ +
+ +

+ {booking.num_nights}{' '} + {booking.num_nights > 1 ? 'nights' : 'night'} in{' '} + Cabin {booking.cabin?.name} +

+
+
+

Check-in

+ +
+
+

Check-out

+ +
+
+ {booking.guest && ( +
+ Guest Details + +
+ )} + {booking.observations && ( + + Observations & Requests +

{booking.observations}

+
+ )} +
+ + +
+
+ ); +} + +export default BookingDetails; + +const Container = styled.div` + --section-spacing: 4rem; +`; + +const Header = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 0.6rem; +`; + +const HeadingContainer = styled.div` + display: flex; + align-items: center; + gap: 1.6rem; +`; + +const BookingDate = styled.p` + margin-bottom: 4rem; +`; + +const StyledBadge = styled(Badge)` + transform: translateY(3px); +`; + +const Grid = styled.div` + display: grid; + row-gap: var(--section-spacing); + column-gap: 3rem; + + @media only screen and (min-width: 75em) { + grid-template-columns: 2.4fr 1fr; + } +`; + +const Section = styled.section` + margin-top: var(--section-spacing); + + h2 { + margin-bottom: 4px; + } +`; + +const BookingDurationDetails = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(24rem, 1fr)); + align-items: center; + gap: 2rem; + padding: 2rem; + border-radius: var(--border-radius-sm); + text-align: center; + background-color: var(--color-brand-100); + + svg { + width: 2.2rem; + height: 2.2rem; + } +`; + +const Observations = styled(Section)` + p { + max-width: 75ch; + } +`; diff --git a/src/features/bookings/BookingGuestDetailsTable.tsx b/src/features/bookings/BookingGuestDetailsTable.tsx new file mode 100644 index 0000000..e740ba0 --- /dev/null +++ b/src/features/bookings/BookingGuestDetailsTable.tsx @@ -0,0 +1,73 @@ +import styled from 'styled-components'; + +import { Booking } from '@/types/bookings'; +import Table from '@/ui/Table'; + +interface BookingGuestDetailsTableProps { + guestDetails: Pick; +} + +function BookingGuestDetailsTable({ guestDetails }: BookingGuestDetailsTableProps) { + const { num_guests, guest } = guestDetails; + + if (!guest) return null; + + return ( + + + + Name + Email + Total Guests + Country + National ID + + + + + + {guest.full_name} + + {guest.email} + {num_guests} + + + {guest.country_flag && } + {guest.nationality && {guest.nationality}} + + + {guest.national_id ?? '-'} + + + + ); +} + +export default BookingGuestDetailsTable; + +const StyledTable = styled(Table)` + th, + td { + padding: 0.5rem; + } + + td img { + border-radius: var(--border-radius-tiny); + } +`; + +const Flag = styled.img` + width: 3rem; + border-radius: var(--border-radius-tiny); +`; + +const CountryDetails = styled.div` + display: flex; + align-items: center; + gap: 1rem; +`; + +const NumericTextCell = styled(Table.Cell)` + font-family: var(--fontFamily-numeric); + font-size: 1.5rem; +`; diff --git a/src/features/bookings/BookingPaymentSummary.tsx b/src/features/bookings/BookingPaymentSummary.tsx new file mode 100644 index 0000000..c203a39 --- /dev/null +++ b/src/features/bookings/BookingPaymentSummary.tsx @@ -0,0 +1,85 @@ +import { Booking } from '@/types/bookings'; +import Badge from '@/ui/Badge'; +import Heading from '@/ui/Heading'; +import { formatPrice } from '@/utils/helpers'; +import styled from 'styled-components'; + +interface BookingPaymentSummaryProps { + paymentInfo: Pick< + Booking, + 'cabin_price' | 'extra_price' | 'total_price' | 'has_breakfast' | 'is_paid' + >; +} + +function BookingPaymentSummary({ paymentInfo }: BookingPaymentSummaryProps) { + const { cabin_price, extra_price, total_price, has_breakfast, is_paid } = paymentInfo; + return ( +
+ + Payment Summary + {is_paid ? 'paid' : 'not paid'} + +
+ + + Cabin: {formatPrice(cabin_price)} + + + {extra_price && ( + + + Extra: {formatPrice(extra_price)} + + {has_breakfast && Includes breakfast} + + )} + + + Total:{' '} + + {formatPrice(total_price)} + + + +
+
+ ); +} + +export default BookingPaymentSummary; + +const HeadingContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 1.6rem; +`; + +const StyledBadge = styled(Badge)` + flex-shrink: 0; + transform: translateY(3px); +`; + +const PriceItemContainer = styled.div` + font-size: calc(var(--fontSize-base) + 0.2rem); + &:not(:last-child) { + margin-bottom: 1.2rem; + } +`; + +const PriceItem = styled.p` + display: flex; + justify-content: space-between; + gap: 1rem; + + data { + font-family: var(--fontFamily-numeric); + } +`; + +const PriceItemExtraInfo = styled.p` + font-size: 1.3rem; + font-weight: 600; + color: var(--color-brand-600); +`; diff --git a/src/features/bookings/BookingRow.tsx b/src/features/bookings/BookingRow.tsx index f870a09..cd5ad9d 100644 --- a/src/features/bookings/BookingRow.tsx +++ b/src/features/bookings/BookingRow.tsx @@ -1,20 +1,17 @@ import styled from 'styled-components'; +import { HiEye } from 'react-icons/hi2'; import { Booking, BookingStatus } from '@/types/bookings'; import Table from '@/ui/Table'; import { formatPrice, formatDate } from '@/utils/helpers'; import Badge from '@/ui/Badge'; +import { LinkButtonIconText } from '@/ui/button/ButtonIconText'; +import { bookingStatusToBadgeColorMap } from '@/utils/constants'; interface BookingRowProps { booking: Booking; } -const bookingStatusToBadgeColorMap: Record = { - unconfirmed: 'blue', - 'checked-in': 'green', - 'checked-out': 'silver', -}; - function BookingRow({ booking }: BookingRowProps) { const { cabin, guest, start_date, end_date, num_nights, total_price, status } = booking; return ( @@ -36,6 +33,14 @@ function BookingRow({ booking }: BookingRowProps) { {status} {formatPrice(total_price)} + + + + + Details + + + ); } @@ -43,6 +48,11 @@ function BookingRow({ booking }: BookingRowProps) { export default BookingRow; const DarkNumericTextCell = styled(Table.Cell)` - font-family: 'Sono', monospace; + font-family: var(--fontFamily-numeric); color: var(--color-grey-600); `; + +const ActionButtonsContainer = styled.div` + display: flex; + gap: 12px; +`; diff --git a/src/features/bookings/BookingTable.tsx b/src/features/bookings/BookingTable.tsx index ada9879..8d96a2e 100644 --- a/src/features/bookings/BookingTable.tsx +++ b/src/features/bookings/BookingTable.tsx @@ -16,6 +16,9 @@ function BookingTable({ bookings }: BookingTableProps) { Duration Status Price + + Actions + diff --git a/src/features/bookings/__tests__/Booking.test.tsx b/src/features/bookings/__tests__/Booking.test.tsx new file mode 100644 index 0000000..d79da3f --- /dev/null +++ b/src/features/bookings/__tests__/Booking.test.tsx @@ -0,0 +1,143 @@ +import { screen, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import Booking from '@/pages/Booking'; +import supabase from '@/services/supabase'; +import { userSession } from '@/test/fixtures/authentication'; +import { renderWithQueryClient } from '@/test/utils'; +import { bookings } from '@/test/fixtures/bookings'; +import { formatDate, formatPrice } from '@/utils/helpers'; + +const dateFormatOptions: Intl.DateTimeFormatOptions = { + weekday: 'short', + month: 'short', + day: '2-digit', + year: 'numeric', +}; + +function setup(bookingId: number = 1) { + vi.spyOn(supabase.auth, 'getSession').mockResolvedValue(userSession); + renderWithQueryClient( + + + } /> + + + ); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('Booking', () => { + it('should have a header with title, booking status, and link to Bookings page', async () => { + setup(); + const booking = bookings[0]; + const titleElement = await screen.findByRole('heading', { name: `Booking #${booking.id}` }); + const statusElement = screen.getByText(booking.status); + const bookingsLink = screen.getByRole('link', { name: /bookings/i }); + + [titleElement, statusElement, bookingsLink].forEach(element => { + expect(element).toBeInTheDocument(); + }); + }); + + it('should have booking date', async () => { + setup(); + + const bookingDate = await screen.findByText( + (_, element) => + element?.textContent === + `Booked on ${formatDate(bookings[0].created_at, dateFormatOptions)}` + ); + + expect(bookingDate).toBeInTheDocument(); + }); + + it('should have booking duration details', async () => { + setup(); + const booking = bookings[0]; + + const numNightsAndCabinTextElement = await screen.findByText( + (_, element) => + element?.tagName === 'P' && + element?.textContent === `${booking.num_nights} nights in Cabin ${booking.cabin?.name}` + ); + + const checkInDate = screen.getByText( + (_, element) => + element?.textContent === `Check-in${formatDate(booking.start_date, dateFormatOptions)}` + ); + + const checkOutDate = screen.getByText( + (_, element) => + element?.textContent === `Check-out${formatDate(booking.end_date, dateFormatOptions)}` + ); + + [numNightsAndCabinTextElement, checkInDate, checkOutDate].forEach(element => { + expect(element).toBeInTheDocument(); + }); + }); + + it('should have guest details', async () => { + setup(); + + const guestDetailsTitle = await screen.findByRole('heading', { name: /guest details/i }); + const guestDetailsTable = screen.getByRole('table', { name: /guest details/i }); + const guestDetailsTableColumnHeaders = within(guestDetailsTable).getAllByRole('columnheader'); + + expect(guestDetailsTitle).toBeInTheDocument(); + expect(guestDetailsTable).toBeInTheDocument(); + expect(guestDetailsTableColumnHeaders[0]).toHaveTextContent(/name/i); + expect(guestDetailsTableColumnHeaders[1]).toHaveTextContent(/email/i); + expect(guestDetailsTableColumnHeaders[2]).toHaveTextContent(/total guests/i); + expect(guestDetailsTableColumnHeaders[3]).toHaveTextContent(/country/i); + expect(guestDetailsTableColumnHeaders[4]).toHaveTextContent(/national id/i); + }); + + it('should have observations section if the booking has observations', async () => { + setup(); + + const observationsSectionTitle = await screen.findByRole('heading', { + name: /observations & requests/i, + }); + + const observations = screen.getByText(bookings[0].observations as string); + + expect(observationsSectionTitle).toBeInTheDocument(); + expect(observations).toBeInTheDocument(); + }); + + it('should have payment summary section', async () => { + setup(); + + const booking = bookings[0]; + const paymentSummaryTitle = await screen.findByRole('heading', { name: /payment summary/i }); + const paymentStatus = screen.getByText(booking.is_paid ? 'paid' : 'not paid'); + + const cabinPrice = screen.getByText( + (_, element) => + element?.tagName === 'P' && + element?.textContent === `Cabin: ${formatPrice(booking.cabin_price)}` + ); + + const extraPrice = screen.getByText( + (_, element) => + element?.tagName === 'P' && + element.textContent === `Extra: ${formatPrice(booking.extra_price)}` + ); + + const breakfastText = screen.getByText(/includes breakfast/i); + + const totalPrice = screen.getByText( + (_, element) => + element?.tagName === 'P' && + element.textContent === `Total: ${formatPrice(booking.total_price)}` + ); + + [paymentSummaryTitle, paymentStatus, cabinPrice, extraPrice, breakfastText, totalPrice].forEach( + element => expect(element).toBeInTheDocument() + ); + }); +}); diff --git a/src/features/bookings/__tests__/BookingTable.test.tsx b/src/features/bookings/__tests__/BookingTable.test.tsx index 40c0ca7..de59d05 100644 --- a/src/features/bookings/__tests__/BookingTable.test.tsx +++ b/src/features/bookings/__tests__/BookingTable.test.tsx @@ -1,14 +1,42 @@ import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { renderWithQueryClient } from '@/test/utils'; import BookingTable from '../BookingTable'; import { bookings } from '@/test/fixtures/bookings'; import { formatDate } from '@/utils/helpers'; +import Booking from '@/pages/Booking'; +import supabase from '@/services/supabase'; +import { userSession } from '@/test/fixtures/authentication'; function setup() { - renderWithQueryClient(); + renderWithQueryClient( + + + + } /> + + + ); } +function setupWithLogin() { + vi.spyOn(supabase.auth, 'getSession').mockResolvedValue(userSession); + renderWithQueryClient( + + + + } /> + + + ); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('BookingTable', () => { it('should have all column headers in the correct order', () => { setup(); @@ -19,6 +47,7 @@ describe('BookingTable', () => { expect(columnHeaders[2]).toHaveTextContent(/duration/i); expect(columnHeaders[3]).toHaveTextContent(/status/i); expect(columnHeaders[4]).toHaveTextContent(/price/i); + expect(columnHeaders[5]).toHaveTextContent(/actions/i); }); it('should have rows with booking details in the correct order', () => { @@ -42,6 +71,25 @@ describe('BookingTable', () => { ); expect(bookingCells[3]).toHaveTextContent(booking.status); expect(bookingCells[4]).toHaveTextContent(`$${booking.total_price}.00`); + + const bookingDetailsLink = within(bookingCells[5]).getByRole('link', { name: /details/i }); + + expect(bookingDetailsLink).toBeInTheDocument(); }); }); + + it('should go to the correct booking details page when the details link of a booking is clicked', async () => { + setupWithLogin(); + const user = userEvent.setup(); + const rows = screen.getAllByRole('row'); + + const firstBookingCells = within(rows[1]).getAllByRole('cell'); + const detailsPageLink = within(firstBookingCells[5]).getByRole('link', { name: /details/i }); + + await user.click(detailsPageLink); + + const bookingTitle = screen.getByRole('heading', { name: `Booking #${bookings[0].id}` }); + + expect(bookingTitle).toBeInTheDocument(); + }); }); diff --git a/src/features/bookings/hooks/useBooking.ts b/src/features/bookings/hooks/useBooking.ts new file mode 100644 index 0000000..f077c23 --- /dev/null +++ b/src/features/bookings/hooks/useBooking.ts @@ -0,0 +1,12 @@ +import { getBooking } from '@/services/apiBookings'; +import { BOOKING_QUERY_KEY } from '@/utils/constants'; +import { useQuery } from '@tanstack/react-query'; + +export function useBooking(bookingId: string | undefined, userId: string | null | undefined) { + return useQuery({ + queryKey: [BOOKING_QUERY_KEY, bookingId, userId], + queryFn: () => getBooking(bookingId, userId), + staleTime: Infinity, + cacheTime: Infinity, + }); +} diff --git a/src/features/cabins/CabinRow.tsx b/src/features/cabins/CabinRow.tsx index de44dd0..1f2c2ed 100644 --- a/src/features/cabins/CabinRow.tsx +++ b/src/features/cabins/CabinRow.tsx @@ -2,7 +2,7 @@ import styled from 'styled-components'; import { formatPrice } from '@/utils/helpers'; import { Tables } from '@/types/database'; -import ButtonIconText from '@/ui/button/ButtonIconText'; +import { ButtonIconText } from '@/ui/button/ButtonIconText'; import { HiOutlinePencilSquare, HiOutlineTrash } from 'react-icons/hi2'; import Table from '@/ui/Table'; @@ -47,7 +47,7 @@ function CabinRow({ cabin, onClickUpdate, onClickDelete }: CabinRowProps) { export default CabinRow; const AlternateFontCell = styled(Table.Cell)` - font-family: 'Sono', monospace; + font-family: var(--fontFamily-numeric); `; const Img = styled.img` diff --git a/src/pages/Booking.tsx b/src/pages/Booking.tsx new file mode 100644 index 0000000..bd8cb8b --- /dev/null +++ b/src/pages/Booking.tsx @@ -0,0 +1,30 @@ +import { useParams } from 'react-router-dom'; + +import { useBooking } from '@/features/bookings/hooks/useBooking'; +import Spinner from '@/ui/spinner/Spinner'; +import BookingDetails from '@/features/bookings/BookingDetails'; +import { useUser } from '@/features/authentication/hooks/useUser'; + +type BookingParams = { bookingId: string }; + +function Booking() { + const { bookingId } = useParams(); + const { data: user } = useUser(); + const { data: booking, isLoading, isError, error } = useBooking(bookingId, user?.id); + + return ( + <> + {isLoading ? ( + + ) : isError && error instanceof Error ? ( +

{error.message}

+ ) : !booking ? ( +

Booking could not be found.

+ ) : ( + + )} + + ); +} + +export default Booking; diff --git a/src/services/apiBookings.ts b/src/services/apiBookings.ts index 81865ca..f2f7b51 100644 --- a/src/services/apiBookings.ts +++ b/src/services/apiBookings.ts @@ -13,12 +13,7 @@ export async function getBookings(userId: string | null | undefined, config: Get const { filter, sort, pageNum } = config; - let query = supabase - .from('booking') - .select( - 'id, created_at, start_date, end_date, num_nights, num_guests, status, total_price, cabin(name, id), guest(id, full_name, email)' - ) - .eq('user_id', userId); + let query = supabase.from('booking').select('*, cabin(name, id), guest(*)').eq('user_id', userId); if (filter) { switch (filter.comparisonMethod) { @@ -69,3 +64,21 @@ export async function getBookings(userId: string | null | undefined, config: Get return { bookings, count: nonPaginatedBookings ? nonPaginatedBookings.length : 0 }; } + +export async function getBooking(bookingId: string | undefined, userId: string | null | undefined) { + if (!bookingId || !userId) return null; + + const { data: booking, error } = await supabase + .from('booking') + .select('*, cabin(*), guest(*)') + .eq('id', bookingId) + .eq('user_id', userId) + .single(); + + if (error) { + console.error(error); + throw new Error('Booking could not be found.'); + } + + return booking; +} diff --git a/src/styles/GlobalStyles.tsx b/src/styles/GlobalStyles.tsx index 30868ea..f178667 100644 --- a/src/styles/GlobalStyles.tsx +++ b/src/styles/GlobalStyles.tsx @@ -56,6 +56,8 @@ export default createGlobalStyle` --fontSize-base: 1.6rem; --fontSize-sm: calc(var(--fontSize-base) - 0.2rem); + --fontFamily-numeric: 'Sono', monospace; + --backdrop-color: rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); @@ -91,6 +93,7 @@ html { body { font-family: "Raleway", sans-serif; color: var(--color-grey-700); + font-weight: 500; transition: color 0.3s, background-color 0.3s; min-height: 100vh; @@ -233,6 +236,10 @@ FOR DARK MODE margin-top: 3rem; } +.mb-1 { + margin-bottom: 1rem; +} + .text-sm { font-size: var(--fontSize-sm); } diff --git a/src/test/fixtures/bookings.ts b/src/test/fixtures/bookings.ts index 4bd4c2e..39954f8 100644 --- a/src/test/fixtures/bookings.ts +++ b/src/test/fixtures/bookings.ts @@ -1,6 +1,7 @@ import { Booking } from '@/types/bookings'; import { cabins } from './cabins'; import { guests } from './guests'; +import { user } from './authentication'; export const bookings: Booking[] = [ { @@ -11,16 +12,18 @@ export const bookings: Booking[] = [ num_nights: 2, num_guests: cabins[0].max_capacity, status: 'checked-out', + cabin_price: cabins[0].regular_price, + extra_price: 50, total_price: cabins[0].regular_price + 50, + has_breakfast: true, + is_paid: true, cabin: { id: cabins[0].id, name: cabins[0].name, }, - guest: { - id: guests[0].id, - full_name: guests[0].full_name, - email: guests[0].email, - }, + guest: guests[0], + observations: 'Test observations', + user_id: user.id, }, { @@ -31,16 +34,18 @@ export const bookings: Booking[] = [ num_nights: 4, num_guests: cabins[1].max_capacity, status: 'checked-in', + cabin_price: cabins[1].regular_price, + extra_price: 100, total_price: cabins[1].regular_price + 100, + has_breakfast: true, + is_paid: true, cabin: { id: cabins[1].id, name: cabins[1].name, }, - guest: { - id: guests[1].id, - full_name: guests[1].full_name, - email: guests[1].email, - }, + guest: guests[1], + observations: null, + user_id: user.id, }, { @@ -51,15 +56,17 @@ export const bookings: Booking[] = [ num_nights: 5, num_guests: cabins[1].max_capacity, status: 'unconfirmed', + cabin_price: cabins[1].regular_price, + extra_price: 150, total_price: cabins[1].regular_price + 150, + is_paid: false, + has_breakfast: true, cabin: { id: cabins[1].id, name: cabins[1].name, }, - guest: { - id: guests[1].id, - full_name: guests[1].full_name, - email: guests[1].email, - }, + guest: guests[1], + observations: null, + user_id: user.id, }, ]; diff --git a/src/test/mocks/domains/bookings.ts b/src/test/mocks/domains/bookings.ts index 8774065..fae7783 100644 --- a/src/test/mocks/domains/bookings.ts +++ b/src/test/mocks/domains/bookings.ts @@ -8,6 +8,19 @@ export const bookingHandlers = [ rest.get(BOOKINGS_BASE_URL, (req, res, ctx) => { const url = new URL(req.url); + // In case of an individual booking, there will be an id query param, e.g. id=eq.1 + const bookingIdValue = url.searchParams.get('id')?.split('.')[1]; + + if (bookingIdValue) { + const booking = db.booking.findFirst({ + where: { + id: { equals: Number.parseFloat(bookingIdValue) }, + }, + }); + + return res(ctx.json(booking)); + } + // the query param value will be like 'eq.unconfirmed', ?status=eq.unconfirmed const statusFilterValue = url.searchParams.get('status')?.split('.')[1]; diff --git a/src/test/mocks/models/booking.ts b/src/test/mocks/models/booking.ts index c02cd11..fc10b4d 100644 --- a/src/test/mocks/models/booking.ts +++ b/src/test/mocks/models/booking.ts @@ -8,10 +8,16 @@ const Booking = { num_nights: Number, num_guests: Number, status: String, + cabin_price: Number, + extra_price: Number, total_price: Number, // Making it nullable to satisfy the type returned by Supabase cabin: nullable({ id: Number, name: String }), guest: nullable({ id: Number, full_name: String, email: String }), + user_id: String, + observations: nullable(String), + has_breakfast: Boolean, + is_paid: Boolean, }; export default Booking; diff --git a/src/types/bookings.ts b/src/types/bookings.ts index e12b863..5a67a9c 100644 --- a/src/types/bookings.ts +++ b/src/types/bookings.ts @@ -1,18 +1,10 @@ import { SortDirection } from '@mswjs/data/lib/query/queryTypes'; import { Tables } from './database'; -export type Booking = Pick< - Tables<'booking'>, - | 'id' - | 'created_at' - | 'start_date' - | 'end_date' - | 'num_nights' - | 'num_guests' - | 'status' - | 'total_price' -> & { cabin: Pick, 'id' | 'name'> | null } & { - guest: Pick, 'id' | 'full_name' | 'email'> | null; +export type Booking = Omit, 'cabin_id' | 'guest_id'> & { + cabin: Pick, 'id' | 'name'> | null; +} & { + guest: Tables<'guest'> | null; }; export type BookingFields = keyof Booking; diff --git a/src/ui/Table.tsx b/src/ui/Table.tsx index 676cf55..875c1ba 100644 --- a/src/ui/Table.tsx +++ b/src/ui/Table.tsx @@ -136,7 +136,8 @@ const StyledTable = styled.table` const ResponsiveCell = styled.td<{ $label?: string }>` @media only screen and (max-width: 62.5em) { display: grid; - grid-template-columns: 10ch auto; + grid-template-columns: 12ch auto; + column-gap: 1rem; > * { ${props => @@ -151,6 +152,7 @@ const ResponsiveCell = styled.td<{ $label?: string }>` props.$label && css` content: attr(data-cell) ': '; + font-family: 'Raleway', sans-serif; font-weight: bold; text-transform: capitalize; `} diff --git a/src/ui/button/ButtonIconText.tsx b/src/ui/button/ButtonIconText.tsx index f047173..5fd6bb1 100644 --- a/src/ui/button/ButtonIconText.tsx +++ b/src/ui/button/ButtonIconText.tsx @@ -1,3 +1,4 @@ +import { Link } from 'react-router-dom'; import styled, { css } from 'styled-components'; interface ButtonIconTextProps { @@ -13,7 +14,7 @@ const variants = { `, }; -const ButtonIconText = styled.button` +const commonButtonStyles = css` display: flex; align-items: center; gap: 5px; @@ -24,4 +25,10 @@ const ButtonIconText = styled.button` ${props => (!props.$variant ? variants['secondary'] : variants[props.$variant])} `; -export default ButtonIconText; +export const ButtonIconText = styled.button` + ${commonButtonStyles} +`; + +export const LinkButtonIconText = styled(Link)` + ${commonButtonStyles} +`; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index da09898..de14129 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,3 +1,5 @@ +import { BookingStatus } from '@/types/bookings'; + export const MIN_PASSWORD_LENGTH = 6; export const MIN_FULL_NAME_LENGTH = 3; export const USER_QUERY_KEY = 'user'; @@ -15,6 +17,13 @@ export const MAX_CABIN_IMAGE_SIZE = 5 * 1024 * 1024; export const CABIN_IMAGES_BUCKET = 'cabin-images'; export const BOOKINGS_QUERY_KEY = 'bookings'; +export const BOOKING_QUERY_KEY = 'booking'; export const BOOKINGS_BASE_URL = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/booking`; export const PAGE_SIZE = import.meta.env.MODE === 'test' ? 2 : 10; + +export const bookingStatusToBadgeColorMap: Record = { + unconfirmed: 'blue', + 'checked-in': 'green', + 'checked-out': 'silver', +};