From 823f20902fe4e22307060869e632dde12b8b8667 Mon Sep 17 00:00:00 2001 From: Saunier Martin Date: Wed, 22 Jan 2025 02:14:12 +0100 Subject: [PATCH 01/17] refactor(pages): reorganize trips pages for better hierarchy Moved the search page from to to improve the project structure. --- apps/web/src/app/trips/search/page.tsx | 16 +++ apps/web/src/app/trips/search/trips.tsx | 147 ++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 apps/web/src/app/trips/search/page.tsx create mode 100644 apps/web/src/app/trips/search/trips.tsx diff --git a/apps/web/src/app/trips/search/page.tsx b/apps/web/src/app/trips/search/page.tsx new file mode 100644 index 0000000..1e58304 --- /dev/null +++ b/apps/web/src/app/trips/search/page.tsx @@ -0,0 +1,16 @@ +import { Suspense } from "react" + +import Loading from "@/components/Loading" +import TripList from "./trips" + +export default function Search() { + return ( +
+

Search for trips

+ + }> + + +
+ ) +} diff --git a/apps/web/src/app/trips/search/trips.tsx b/apps/web/src/app/trips/search/trips.tsx new file mode 100644 index 0000000..2f596f9 --- /dev/null +++ b/apps/web/src/app/trips/search/trips.tsx @@ -0,0 +1,147 @@ +"use client" + +import { useEffect, useState } from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" + +import { TripSchema, type Trip } from "@karr/db/schemas/trips.js" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from "@karr/ui/components/card" + +import { QueryProvider } from "@/components/QueryProvider" +import { apiFetch } from "@/util/apifetch" + +export default function TripList({ userid }: { userid: string }) { + return ( + + + + ) +} + +function FetchTrips({ userid }: { userid: string }) { + // Access the client + const _queryClient = useQueryClient() + const [trips, setTrips] = useState([]) + + const { + data: stream, + isLoading, + isError, + error + } = useQuery({ + queryKey: ["user", userid], + retry: false, + queryFn: async () => + apiFetch("/trips/search", { + headers: { + authorization: userid + }, + responseType: "stream" + }) + }) + + useEffect(() => { + if (!stream) return + + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = "" + + async function processStream() { + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + + // Split by newlines and process complete JSON objects + const lines = buffer.split("\n") + buffer = lines.pop() || "" // Keep the last incomplete line in buffer + + const newTrips = lines + .filter((line) => line.startsWith("data: ")) + .map((line) => { + line = line.trim() + try { + line = line.substring(6) + const tripData = JSON.parse(line) + return TripSchema.parse(tripData) + } catch (e) { + console.error("Failed to parse trip:", e) + return null + } + }) + .filter((trip): trip is Trip => trip !== null) + + if (newTrips.length > 0) { + setTrips((prev) => [...prev, ...newTrips]) + } + } + + // Process any remaining data + if (buffer.trim()) { + try { + const tripData = JSON.parse(buffer) + const trip = TripSchema.parse(tripData) + setTrips((prev) => [...prev, trip]) + } catch (e) { + console.error("Failed to parse final trip:", e) + } + } + } catch (e) { + console.error("Stream reading error:", e) + } + } + + processStream() + + return () => { + reader.cancel() + } + }, [stream]) + + if (isLoading) { + return
Loading...
+ } + + if (isError || !stream) { + return
Error: {error?.message}
+ } + + return ( +
+ {trips.map((trip: Trip) => { + const t = TripSchema.parse(trip) + return + })} +
+ ) +} + +function TripCard({ trip }: { trip: Trip }) { + return ( + + + + {trip.from} to {trip.to} + + + {new Date(trip.departure).toLocaleDateString()} + + + +

{trip.price} €

+
+ +

{trip.account}

+
+
+ ) +} From 18382dcd5c3a3d1f38a23282125d6df96b616af0 Mon Sep 17 00:00:00 2001 From: Saunier Martin Date: Wed, 22 Jan 2025 02:14:50 +0100 Subject: [PATCH 02/17] feat(pages): create NewTripForm page for creating new trips --- apps/web/src/app/trips/new/newTripForm.tsx | 137 +++++++++++++++++++++ apps/web/src/app/trips/new/page.tsx | 16 +++ 2 files changed, 153 insertions(+) create mode 100644 apps/web/src/app/trips/new/newTripForm.tsx create mode 100644 apps/web/src/app/trips/new/page.tsx diff --git a/apps/web/src/app/trips/new/newTripForm.tsx b/apps/web/src/app/trips/new/newTripForm.tsx new file mode 100644 index 0000000..7428832 --- /dev/null +++ b/apps/web/src/app/trips/new/newTripForm.tsx @@ -0,0 +1,137 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { format } from "date-fns" +import { CalendarDays } from "lucide-react" +import { useForm } from "react-hook-form" + +import { NewTripInputSchema, type NewTripInput } from "@karr/db/schemas/trips.js" +import { Button } from "@karr/ui/components/button" +import { Calendar } from "@karr/ui/components/calendar" +import { CurrencyInput } from "@karr/ui/components/currencyInput" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@karr/ui/components/form" +import { Input } from "@karr/ui/components/input" +import { Popover, PopoverContent, PopoverTrigger } from "@karr/ui/components/popover" +import { cn } from "@karr/ui/lib/utils" + +export default function NewTripForm() { + const form = useForm({ + resolver: zodResolver(NewTripInputSchema), + defaultValues: { + account: undefined, + departure: new Date(Date.now()), + from: "", + to: "", + price: 6 + } + }) + + const onSubmit = async (data: NewTripInput) => { + console.log("Submitting...") + console.log(data) + } + + return ( +
+ + ( + + From + + + + + + + )} + /> + ( + + To + + + + + + + )} + /> + ( + + Departure + + + + + + + + + + + + + + )} + /> + ( + + Price (per passenger) + +
+ +
+
+ + +
+ )} + /> + + + + ) +} diff --git a/apps/web/src/app/trips/new/page.tsx b/apps/web/src/app/trips/new/page.tsx new file mode 100644 index 0000000..43cd508 --- /dev/null +++ b/apps/web/src/app/trips/new/page.tsx @@ -0,0 +1,16 @@ +import { Suspense } from "react" + +import Loading from "@/components/Loading" +import NewTripForm from "./newTripForm" + +export default function New() { + return ( +
+

Add new trip

+ + }> + + +
+ ) +} From 14601ceae17ebf8cef56bf80f7e0eac1843d1485 Mon Sep 17 00:00:00 2001 From: Saunier Martin Date: Wed, 22 Jan 2025 02:17:04 +0100 Subject: [PATCH 03/17] chore(css): update shadcn variable --- packages/ui/src/styles/globals.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index bc2c9fb..fe3ea7d 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -29,8 +29,8 @@ --accent: hsl(105 10% 91.9%); --accent-foreground: hsl(105 10% 0.95%); - --popover: hsl(224 71% 4%); - --popover-foreground: hsl(215 20.2% 65.1%); + --popover: hsl(105 100% 98.38%); + --popover-foreground: hsl(105 10% 0.95%); --border: hsl(105 15% 89.76%); --input: hsl(105 15% 89.76%); From 202cb12b1abd3832e4f17593b34aa0e394f001bb Mon Sep 17 00:00:00 2001 From: Saunier Martin Date: Wed, 22 Jan 2025 02:17:34 +0100 Subject: [PATCH 04/17] chore(config): add .prettierrc for better Windows compatibility --- .prettierrc | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..aeda187 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,25 @@ +{ + "arrowParens": "always", + "printWidth": 90, + "singleQuote": false, + "semi": false, + "trailingComma": "none", + "tabWidth": 4, + "bracketSpacing": true, + "plugins": ["prettier-plugin-tailwindcss", "@ianvs/prettier-plugin-sort-imports"], + "tailwindStylesheet": "./packages/ui/src/styles/globals.css", + "tailwindFunctions": ["cn", "clsx"], + "importOrderTypeScriptVersion": "4.4.0", + "importOrder": [ + "^(react/(.*)$)|^(react$)", + "^(next/(.*)$)|^(next$)", + "", + "", + "^@karr/(.*)$", + "^karr/(.*)$", + "", + "^@/(.*)$", + "^[./]" + ], + "proseWrap": "always" +} From d4c7b9826dc86afad89d702175895c1c1b903970 Mon Sep 17 00:00:00 2001 From: Saunier Martin Date: Wed, 22 Jan 2025 02:19:06 +0100 Subject: [PATCH 05/17] fix(layout): update link to point to the new trips search path --- apps/web/src/app/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index e90fa39..dfcdbd1 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -75,7 +75,7 @@ export default function RootLayout({ variant="link" className="text-md" > - Trips + Trips From 3771f6ed2d0443d604fd6c77a7ee2c3a038386ab Mon Sep 17 00:00:00 2001 From: Saunier Martin Date: Wed, 22 Jan 2025 02:19:25 +0100 Subject: [PATCH 06/17] chore(deps): add new dependencies for form handling and UI components --- packages/ui/package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui/package.json b/packages/ui/package.json index ea35480..b31a3e9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -15,15 +15,21 @@ "check-types": "tsc --noEmit" }, "dependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-separator": "catalog:", "@radix-ui/react-slot": "catalog:", "class-variance-authority": "catalog:", "clsx": "catalog:", + "date-fns": "^4.1.0", "lucide-react": "catalog:", "next": "catalog:", "next-themes": "catalog:", "react": "catalog:", + "react-day-picker": "^9.5.0", "react-dom": "catalog:", + "react-hook-form": "^7.54.2", "tailwind-merge": "catalog:", "tailwindcss-animate": "catalog:", "zod": "catalog:" From 88bfd54d71d12577b4ea137f86b47b8433438866 Mon Sep 17 00:00:00 2001 From: Saunier Martin Date: Wed, 22 Jan 2025 02:20:00 +0100 Subject: [PATCH 07/17] chore(deps): add date-fns and hookform resolvers to dependencies --- apps/web/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/package.json b/apps/web/package.json index ab7ffb5..1bab56c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,12 +11,14 @@ "check-types": "tsc --noEmit" }, "dependencies": { + "@hookform/resolvers": "^3.10.0", "@karr/config": "workspace:*", "@karr/db": "workspace:*", "@karr/ui": "workspace:*", "@tailwindcss/postcss": "catalog:", "@tanstack/react-query": "catalog:", "babel-plugin-react-compiler": "catalog:", + "date-fns": "^4.1.0", "lucide-react": "catalog:", "next": "catalog:", "next-themes": "catalog:", From ec4ee62036c05ac745a197d3126038489270c2f4 Mon Sep 17 00:00:00 2001 From: Saunier Martin Date: Wed, 22 Jan 2025 02:20:35 +0100 Subject: [PATCH 08/17] fix(schema): enforce minimum price validation and add NewTripInputSchema --- packages/db/src/schemas/trips.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/db/src/schemas/trips.ts b/packages/db/src/schemas/trips.ts index 559167c..4c3ae5e 100644 --- a/packages/db/src/schemas/trips.ts +++ b/packages/db/src/schemas/trips.ts @@ -21,7 +21,7 @@ export const TripSchema = z.object({ from: z.string(), to: z.string(), departure: z.string(), - price: z.number(), + price: z.number().min(0), createdAt: z.string().optional().nullable(), updatedAt: z.string().optional().nullable(), account: z.any().optional() @@ -33,8 +33,18 @@ export const NewTripSchema = z.object({ from: z.string(), to: z.string(), departure: z.string(), - price: z.number(), + price: z.number().min(0), account: z.any().optional() }) export type NewTrip = z.infer + +export const NewTripInputSchema = z.object({ + from: z.string().min(1), + to: z.string().min(1), + departure: z.date(), + price: z.number().min(0), + account: z.any().optional() +}) + +export type NewTripInput = z.infer From e9b81b2ea256014f2b6dda120f82d8f8d26dfa3d Mon Sep 17 00:00:00 2001 From: Saunier Martin Date: Wed, 22 Jan 2025 02:21:15 +0100 Subject: [PATCH 09/17] chore(vscode): configure Prettier settings for consistent code formatting --- .vscode/settings.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 498f666..251e014 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,10 @@ { "mode": "auto" } - ] + ], + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "prettier.configPath": ".prettierrc", + "prettier.resolveGlobalModules": true, + "prettier.requireConfig": true } From fe5ffc27c59b32d5b9f7eb4457fc0113142bb439 Mon Sep 17 00:00:00 2001 From: Saunier Martin Date: Wed, 22 Jan 2025 02:21:53 +0100 Subject: [PATCH 10/17] feat(ui): add Calendar, Form, Label, and Popover components from shadcnui --- packages/ui/src/components/calendar.tsx | 94 +++++++++++++ packages/ui/src/components/form.tsx | 175 ++++++++++++++++++++++++ packages/ui/src/components/label.tsx | 26 ++++ packages/ui/src/components/popover.tsx | 31 +++++ 4 files changed, 326 insertions(+) create mode 100644 packages/ui/src/components/calendar.tsx create mode 100644 packages/ui/src/components/form.tsx create mode 100644 packages/ui/src/components/label.tsx create mode 100644 packages/ui/src/components/popover.tsx diff --git a/packages/ui/src/components/calendar.tsx b/packages/ui/src/components/calendar.tsx new file mode 100644 index 0000000..de33eeb --- /dev/null +++ b/packages/ui/src/components/calendar.tsx @@ -0,0 +1,94 @@ +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, + ChevronUpIcon +} from "lucide-react" +import { DayFlag, DayPicker, SelectionState, UI } from "react-day-picker" + +import { buttonVariants } from "@karr/ui/components/button" +import { cn } from "@karr/ui/lib/utils" + +export type CalendarProps = React.ComponentProps + +export const Calendar = ({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) => { + return ( + ( + + ) + }} + {...props} + /> + ) +} + +type Direction = "left" | "right" | "up" | "down" + +const Chevron = ({ + className, + orientation = "left" +}: { + className: string | undefined + orientation: Direction +}) => { + switch (orientation) { + case "left": + return + case "right": + return + case "up": + return + case "down": + return + default: + return null + } +} diff --git a/packages/ui/src/components/form.tsx b/packages/ui/src/components/form.tsx new file mode 100644 index 0000000..cbcdcf4 --- /dev/null +++ b/packages/ui/src/components/form.tsx @@ -0,0 +1,175 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext +} from "react-hook-form" + +import { Label } from "@karr/ui/components/label" +import { cn } from "@karr/ui/lib/utils" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) + } +) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +