diff --git a/suncityla/app/booking/components/BookingCard/BookingEntry.tsx b/suncityla/app/booking/components/BookingCard/BookingEntry.tsx index 77daa72..71084d3 100644 --- a/suncityla/app/booking/components/BookingCard/BookingEntry.tsx +++ b/suncityla/app/booking/components/BookingCard/BookingEntry.tsx @@ -1,4 +1,4 @@ -import { format } from 'date-fns'; +import { add, format } from 'date-fns'; import { Booking } from '@prisma/client'; import { Table, @@ -28,6 +28,7 @@ const BookingEntry = ({ booking }: { booking: Booking }) => { Firstname Lastname Booking date + Booking time Address line 1 Address line 2 State @@ -39,7 +40,11 @@ const BookingEntry = ({ booking }: { booking: Booking }) => { {booking.firstname} {booking.lastname} - {format(new Date(booking.bookingDate), 'yyyy-MM-dd')} + {format(new Date(booking.bookingDate), 'EE dd/MM/yy')} + {`${format(new Date(booking.bookingDate), 'h:mm a')} - ${format( + add(new Date(booking.bookingDate), { hours: 1 }), + 'h:mm a', + )}`} {booking.streetAddress} {booking.postalCode} {booking.state} diff --git a/suncityla/app/booking/components/BookingForm/BookingForm.tsx b/suncityla/app/booking/components/BookingForm/BookingForm.tsx index 06a1343..55c4f88 100644 --- a/suncityla/app/booking/components/BookingForm/BookingForm.tsx +++ b/suncityla/app/booking/components/BookingForm/BookingForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useActionState, useRef, startTransition, useEffect } from 'react'; +import { useActionState, useRef, startTransition, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -13,6 +13,8 @@ import LoadingModal from '@/components/modals/Loading'; import onSubmitAction from '@/app/booking/actions/onSubmitAction'; import { bookingFormSchema } from '../../schemas/newBookingForm'; import { Booking } from '@prisma/client'; +import TimeSelect from '../TimeSelect/TimeSelect'; +import createAvailableTimes from '../TimeSelect/times'; export type BookingFormData = z.infer; @@ -32,15 +34,21 @@ export default function BookingForm({ booking }: { booking?: Booking | null }) { 'street-address': booking?.streetAddress ?? '', 'postal-code': booking?.postalCode ?? '', state: 'LA', - bookingDate: booking?.bookingDate.toISOString() ?? new Date().toISOString(), + bookingDate: booking?.bookingDate.toISOString() || '', ...(state.fields ?? {}), }, }); + // Time Stamp + const [time, setSelectedTime] = useState(booking?.bookingDate.getTime()); + const watchDate = form.watch('bookingDate'); + const availableTimes = createAvailableTimes(new Date(watchDate)); + const formRef = useRef(null); const handleClearForm = () => { form.reset(); + setSelectedTime(undefined); }; useEffect(() => { @@ -49,6 +57,16 @@ export default function BookingForm({ booking }: { booking?: Booking | null }) { } }, [state.bookingRef, isPending, router, state.message]); + useEffect(() => { + if (time) { + const newDateWithTime = new Date(watchDate); + const hours = new Date(time).getHours(); + newDateWithTime.setHours(hours); + form.setValue('bookingDate', newDateWithTime.toISOString()); + return; + } + }, [watchDate, time, form]); + return (
@@ -75,12 +93,21 @@ export default function BookingForm({ booking }: { booking?: Booking | null }) { - + setSelectedTime(undefined)} /> + {watchDate && ( + + )}
- - + )} +
diff --git a/suncityla/app/booking/components/TimeDatePicker/TimeDatePicker.tsx b/suncityla/app/booking/components/TimeDatePicker/TimeDatePicker.tsx index 4505cd7..462aff7 100644 --- a/suncityla/app/booking/components/TimeDatePicker/TimeDatePicker.tsx +++ b/suncityla/app/booking/components/TimeDatePicker/TimeDatePicker.tsx @@ -1,25 +1,21 @@ -"use client"; +'use client'; -import { useState } from "react"; -import { format } from "date-fns"; -import { Calendar as CalendarIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { Calendar } from "@/components/ui/calendar"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { useState } from 'react'; +import { format } from 'date-fns'; +import { Calendar as CalendarIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { FormControl, FormDescription, FormField, FormItem, FormMessage, -} from "@/components/ui/form"; +} from '@/components/ui/form'; -export function DateTimePickerForm() { +export function DateTimePickerForm({ onChange }: { onChange: () => void }) { const [open, setOpen] = useState(false); return ( @@ -34,16 +30,12 @@ export function DateTimePickerForm() { onClick={() => setOpen(true)} variant="outline" className={cn( - "w-full justify-start font-normal", - !field.value && "text-muted-foreground" + 'w-full justify-start font-normal', + !field.value && 'text-muted-foreground', )} > - {field.value ? ( - format(field.value, "PPP") - ) : ( - Pick a date - )} + {field.value ? format(field.value, 'PPP') : Pick a date} @@ -54,17 +46,12 @@ export function DateTimePickerForm() { onSelect={(date) => { field.onChange(date?.toISOString()); setOpen(false); + onChange(); }} - initialFocus /> - + diff --git a/suncityla/app/booking/components/TimeSelect/TimeSelect.tsx b/suncityla/app/booking/components/TimeSelect/TimeSelect.tsx new file mode 100644 index 0000000..7a41e70 --- /dev/null +++ b/suncityla/app/booking/components/TimeSelect/TimeSelect.tsx @@ -0,0 +1,59 @@ +'use client'; + +import * as React from 'react'; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { FormField, FormItem } from '@/components/ui/form'; +import { format } from 'date-fns'; + +export function TimeSelect({ + availableTimes, + selectedTime, + onTimeSelect, +}: { + availableTimes: { from: number; to: number }[]; + selectedTime?: number; + onTimeSelect: (time: number) => void; +}) { + return ( + { + return ( + + + + ); + }} + /> + ); +} + +export default TimeSelect; diff --git a/suncityla/app/booking/components/TimeSelect/times.ts b/suncityla/app/booking/components/TimeSelect/times.ts new file mode 100644 index 0000000..fcf93d5 --- /dev/null +++ b/suncityla/app/booking/components/TimeSelect/times.ts @@ -0,0 +1,12 @@ +const createAvailableTimes = (date: Date) => { + const availableTimes = []; + for (let i = 8; i < 20; i++) { + availableTimes.push({ + from: date.setHours(i, 0, 0, 0), + to: date.setHours(i + 1, 0, 0, 0), + }); + } + return availableTimes; +}; + +export default createAvailableTimes; diff --git a/suncityla/components/ui/calendar.tsx b/suncityla/components/ui/calendar.tsx index d465dc8..037ce79 100644 --- a/suncityla/components/ui/calendar.tsx +++ b/suncityla/components/ui/calendar.tsx @@ -1,67 +1,60 @@ -"use client"; +'use client'; -import * as React from "react"; -import { ChevronLeft, ChevronRight } from "lucide-react"; -import { DayPicker } from "react-day-picker"; +import * as React from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { DayPicker } from 'react-day-picker'; -import { cn } from "@/lib/utils"; -import { buttonVariants } from "@/components/ui/button"; +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; export type CalendarProps = React.ComponentProps; -function Calendar({ - className, - classNames, - showOutsideDays = true, - ...props -}: CalendarProps) { +function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { return ( .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" - : "[&:has([aria-selected])]:rounded-md" + 'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md', + props.mode === 'range' + ? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md' + : '[&:has([aria-selected])]:rounded-md', ), day: cn( - buttonVariants({ variant: "ghost" }), - "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + buttonVariants({ variant: 'ghost' }), + 'h-8 w-8 p-0 font-normal aria-selected:opacity-100', ), - day_range_start: "day-range-start", - day_range_end: "day-range-end", + day_range_start: 'day-range-start', + day_range_end: 'day-range-end', day_selected: - "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", - day_today: "bg-accent text-accent-foreground", + 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground', + day_today: 'bg-accent text-accent-foreground', day_outside: - "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", - day_disabled: "text-muted-foreground opacity-50", - day_range_middle: - "aria-selected:bg-accent aria-selected:text-accent-foreground", - day_hidden: "invisible", + 'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground', + day_disabled: 'text-muted-foreground opacity-50', + day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground', + day_hidden: 'invisible', ...classNames, }} components={{ Chevron(props) { - if (props.orientation === "left") { + if (props.orientation === 'left') { return ; } return ; @@ -71,6 +64,6 @@ function Calendar({ /> ); } -Calendar.displayName = "Calendar"; +Calendar.displayName = 'Calendar'; export { Calendar }; diff --git a/suncityla/components/ui/select.tsx b/suncityla/components/ui/select.tsx new file mode 100644 index 0000000..98d83d2 --- /dev/null +++ b/suncityla/components/ui/select.tsx @@ -0,0 +1,152 @@ +'use client'; + +import * as React from 'react'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1 dark:border-zinc-800 dark:ring-offset-zinc-950 dark:placeholder:text-zinc-400 dark:focus:ring-zinc-300', + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/suncityla/package-lock.json b/suncityla/package-lock.json index b01e9b2..3cd088a 100644 --- a/suncityla/package-lock.json +++ b/suncityla/package-lock.json @@ -16,6 +16,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", "@tanstack/react-table": "^8.20.5", @@ -2465,6 +2466,11 @@ "@prisma/debug": "5.22.0" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", @@ -3021,6 +3027,48 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz", + "integrity": "sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", @@ -3138,6 +3186,20 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", diff --git a/suncityla/package.json b/suncityla/package.json index 2333ff4..5d4e01f 100644 --- a/suncityla/package.json +++ b/suncityla/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", "@tanstack/react-table": "^8.20.5", diff --git a/suncityla/prisma/migrations/20241206115210_booking_time/migration.sql b/suncityla/prisma/migrations/20241206115210_booking_time/migration.sql new file mode 100644 index 0000000..ddd0f41 --- /dev/null +++ b/suncityla/prisma/migrations/20241206115210_booking_time/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "bookingTime" TEXT; diff --git a/suncityla/prisma/migrations/20241206173508_remove_bookingtime_column/migration.sql b/suncityla/prisma/migrations/20241206173508_remove_bookingtime_column/migration.sql new file mode 100644 index 0000000..f0d702d --- /dev/null +++ b/suncityla/prisma/migrations/20241206173508_remove_bookingtime_column/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `bookingTime` on the `Booking` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Booking" DROP COLUMN "bookingTime"; diff --git a/suncityla/prisma/schema.prisma b/suncityla/prisma/schema.prisma index 8b00fc2..0f1eb63 100644 --- a/suncityla/prisma/schema.prisma +++ b/suncityla/prisma/schema.prisma @@ -1,10 +1,10 @@ datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") } generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" } // This is an example schema. You can use it as a starting point to create your own schema. @@ -17,21 +17,21 @@ generator client { // added this to ensure authentication with next-auth and github model User { id String @id @default(cuid()) - username String? @unique + username String? @unique password String? name String? - email String? @unique + email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] // Optional for WebAuthn support Authenticator Authenticator[] - + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } - + model Account { userId String type String @@ -44,33 +44,33 @@ model Account { scope String? id_token String? session_state String? - + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - + @@id([provider, providerAccountId]) } - + model Session { sessionToken String @unique userId String expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) - + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } - + model VerificationToken { identifier String token String expires DateTime - + @@id([identifier, token]) } - + // Optional for WebAuthn support model Authenticator { credentialID String @unique @@ -81,26 +81,26 @@ model Authenticator { credentialDeviceType String credentialBackedUp Boolean transports String? - + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - + @@id([userId, credentialID]) } model Booking { - id String @id @unique @default(uuid()) - firstname String - lastname String - streetAddress String - postalCode String - state String - bookingDate DateTime - status BookingStatus @default(PENDING) + id String @id @unique @default(uuid()) + firstname String + lastname String + streetAddress String + postalCode String + state String + bookingDate DateTime + status BookingStatus @default(PENDING) } enum BookingStatus { - PENDING - CONFIRMED - CANCELLED - VISITED + PENDING + CONFIRMED + CANCELLED + VISITED }