)}
+ {/* Add a safe access check for notes */}
{flight.notes && (
)}
- {process.env.NODE_ENV !== 'production' && this.state.errorInfo && (
+ {import.meta.env.MODE !== 'production' && this.state.errorInfo && (
Stack Trace
diff --git a/webapp/src/components/icons.tsx b/webapp/src/components/icons.tsx
new file mode 100644
index 00000000..f5becd72
--- /dev/null
+++ b/webapp/src/components/icons.tsx
@@ -0,0 +1,37 @@
+import { Loader2 } from "lucide-react";
+import * as React from "react";
+
+// Google SVG icon
+export const Icons = {
+ google: (props: React.SVGProps) => (
+
+ ),
+ spinner: Loader2,
+};
\ No newline at end of file
diff --git a/webapp/src/components/ui/button.tsx b/webapp/src/components/ui/button.tsx
index 3d64d709..f16789c8 100644
--- a/webapp/src/components/ui/button.tsx
+++ b/webapp/src/components/ui/button.tsx
@@ -1,5 +1,6 @@
import React, { forwardRef } from 'react';
import { Slot } from '@radix-ui/react-slot';
+import { cn } from '../../lib/utils';
export interface ButtonProps extends React.ButtonHTMLAttributes {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
@@ -12,7 +13,22 @@ const Button = forwardRef(
const Comp = asChild ? Slot : 'button';
return (
@@ -22,5 +38,4 @@ const Button = forwardRef(
Button.displayName = 'Button';
-export { Button };
-export default Button;
\ No newline at end of file
+export { Button };
\ No newline at end of file
diff --git a/webapp/src/components/ui/checkbox.tsx b/webapp/src/components/ui/checkbox.tsx
index 43ac6c4a..53c7b664 100644
--- a/webapp/src/components/ui/checkbox.tsx
+++ b/webapp/src/components/ui/checkbox.tsx
@@ -2,7 +2,7 @@ import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
-import { cn } from "@/lib/utils"
+import { cn } from "../../lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef,
diff --git a/webapp/src/components/ui/dialog.tsx b/webapp/src/components/ui/dialog.tsx
new file mode 100644
index 00000000..a4648169
--- /dev/null
+++ b/webapp/src/components/ui/dialog.tsx
@@ -0,0 +1,124 @@
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "../../lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+// Fix DialogPortal to remove className from props
+const DialogPortal = ({
+ children,
+ ...props
+}: DialogPrimitive.DialogPortalProps) => (
+
+ {children}
+
+)
+DialogPortal.displayName = DialogPrimitive.Portal.displayName
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}
\ No newline at end of file
diff --git a/webapp/src/components/ui/dropdown-menu.tsx b/webapp/src/components/ui/dropdown-menu.tsx
index e0e5729e..ce9515fd 100644
--- a/webapp/src/components/ui/dropdown-menu.tsx
+++ b/webapp/src/components/ui/dropdown-menu.tsx
@@ -1,46 +1,158 @@
-import * as React from 'react';
-import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { Check, ChevronRight, Circle } from "lucide-react"
-const DropdownMenu = DropdownMenuPrimitive.Root;
-const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+import { cn } from "../../lib/utils"
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
+>(({ className, sideOffset = 4, ...props }, ref) => (
-));
-DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
-));
-DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
-));
-DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef,
@@ -48,17 +160,39 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
-));
-DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
-};
\ No newline at end of file
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
\ No newline at end of file
diff --git a/webapp/src/components/ui/input.tsx b/webapp/src/components/ui/input.tsx
index 522915bf..3e55d876 100644
--- a/webapp/src/components/ui/input.tsx
+++ b/webapp/src/components/ui/input.tsx
@@ -1,6 +1,6 @@
import * as React from "react"
-import { cn } from "@/lib/utils"
+import { cn } from "../../lib/utils"
export interface InputProps
extends React.InputHTMLAttributes {}
diff --git a/webapp/src/components/ui/label.tsx b/webapp/src/components/ui/label.tsx
index a7cafcd2..ef4a9512 100644
--- a/webapp/src/components/ui/label.tsx
+++ b/webapp/src/components/ui/label.tsx
@@ -2,7 +2,7 @@ import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
-import { cn } from "@/lib/utils"
+import { cn } from "../../lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
diff --git a/webapp/src/components/ui/progress.tsx b/webapp/src/components/ui/progress.tsx
index e5ae9759..409d2598 100644
--- a/webapp/src/components/ui/progress.tsx
+++ b/webapp/src/components/ui/progress.tsx
@@ -1,7 +1,7 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
-import { cn } from "@/lib/utils"
+import { cn } from "../../lib/utils"
const Progress = React.forwardRef<
React.ElementRef,
diff --git a/webapp/src/components/ui/radio-group.tsx b/webapp/src/components/ui/radio-group.tsx
index de739223..08b4d445 100644
--- a/webapp/src/components/ui/radio-group.tsx
+++ b/webapp/src/components/ui/radio-group.tsx
@@ -2,7 +2,7 @@ import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
-import { cn } from "@/lib/utils"
+import { cn } from "../../lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef,
diff --git a/webapp/src/components/ui/scroll-area.tsx b/webapp/src/components/ui/scroll-area.tsx
index ba610716..43d16ccb 100644
--- a/webapp/src/components/ui/scroll-area.tsx
+++ b/webapp/src/components/ui/scroll-area.tsx
@@ -1,7 +1,7 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
-import { cn } from "@/lib/utils"
+import { cn } from "../../lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef,
diff --git a/webapp/src/components/ui/separator.tsx b/webapp/src/components/ui/separator.tsx
index ac15d7bb..d6f6d274 100644
--- a/webapp/src/components/ui/separator.tsx
+++ b/webapp/src/components/ui/separator.tsx
@@ -1,7 +1,7 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
-import { cn } from "@/lib/utils"
+import { cn } from "../../lib/utils"
const Separator = React.forwardRef<
React.ElementRef,
diff --git a/webapp/src/components/ui/sheet.tsx b/webapp/src/components/ui/sheet.tsx
new file mode 100644
index 00000000..5990b79f
--- /dev/null
+++ b/webapp/src/components/ui/sheet.tsx
@@ -0,0 +1,142 @@
+import * as React from "react"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "../../lib/utils"
+
+const Sheet = SheetPrimitive.Root
+
+const SheetTrigger = SheetPrimitive.Trigger
+
+const SheetClose = SheetPrimitive.Close
+
+const SheetPortal = ({
+ className,
+ ...props
+}: SheetPrimitive.DialogPortalProps) => (
+
+)
+SheetPortal.displayName = SheetPrimitive.Portal.displayName
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
+
+const sheetVariants = cva(
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ bottom:
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+ right:
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ }
+)
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+))
+SheetContent.displayName = SheetPrimitive.Content.displayName
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetHeader.displayName = "SheetHeader"
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetFooter.displayName = "SheetFooter"
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetTitle.displayName = SheetPrimitive.Title.displayName
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetDescription.displayName = SheetPrimitive.Description.displayName
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
\ No newline at end of file
diff --git a/webapp/src/components/ui/slider.tsx b/webapp/src/components/ui/slider.tsx
index c18753d3..4dfab7bc 100644
--- a/webapp/src/components/ui/slider.tsx
+++ b/webapp/src/components/ui/slider.tsx
@@ -1,5 +1,7 @@
-import * as React from 'react';
-import * as SliderPrimitive from '@radix-ui/react-slider';
+import * as React from "react"
+import * as SliderPrimitive from "@radix-ui/react-slider"
+
+import { cn } from "../../lib/utils"
const Slider = React.forwardRef<
React.ElementRef,
@@ -7,20 +9,18 @@ const Slider = React.forwardRef<
>(({ className, ...props }, ref) => (
-
-
+
+
- {props.defaultValue?.map((_, i) => (
-
- ))}
+
-));
-Slider.displayName = SliderPrimitive.Root.displayName;
+))
+Slider.displayName = SliderPrimitive.Root.displayName
-export { Slider };
\ No newline at end of file
+export { Slider }
\ No newline at end of file
diff --git a/webapp/src/components/ui/switch.tsx b/webapp/src/components/ui/switch.tsx
index 29b0ac74..3855ec81 100644
--- a/webapp/src/components/ui/switch.tsx
+++ b/webapp/src/components/ui/switch.tsx
@@ -1,18 +1,27 @@
-import * as React from 'react';
-import * as SwitchPrimitives from '@radix-ui/react-switch';
+import * as React from "react"
+import * as SwitchPrimitives from "@radix-ui/react-switch"
+
+import { cn } from "../../lib/utils"
const Switch = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
+
-));
-Switch.displayName = SwitchPrimitives.Root.displayName;
+))
+Switch.displayName = SwitchPrimitives.Root.displayName
-export { Switch };
\ No newline at end of file
+export { Switch }
\ No newline at end of file
diff --git a/webapp/src/components/ui/table.tsx b/webapp/src/components/ui/table.tsx
index 2739a451..11eb43c1 100644
--- a/webapp/src/components/ui/table.tsx
+++ b/webapp/src/components/ui/table.tsx
@@ -1,12 +1,12 @@
import * as React from "react"
-import { cn } from "@/lib/utils"
+import { cn } from "../../lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
(({ className, ...props }, ref) => (
tr]:last:border-b-0",
+ className
+ )}
{...props}
/>
))
diff --git a/webapp/src/components/ui/tabs.tsx b/webapp/src/components/ui/tabs.tsx
index 82badaa5..17f5d8f1 100644
--- a/webapp/src/components/ui/tabs.tsx
+++ b/webapp/src/components/ui/tabs.tsx
@@ -1,7 +1,7 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
-import { cn } from "@/lib/utils"
+import { cn } from "../../lib/utils"
const Tabs = TabsPrimitive.Root
diff --git a/webapp/src/components/ui/toast.tsx b/webapp/src/components/ui/toast.tsx
new file mode 100644
index 00000000..58c7586e
--- /dev/null
+++ b/webapp/src/components/ui/toast.tsx
@@ -0,0 +1,127 @@
+import * as React from "react"
+import * as ToastPrimitives from "@radix-ui/react-toast"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "../../lib/utils"
+
+const ToastProvider = ToastPrimitives.Provider
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName
+
+const toastVariants = cva(
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
+ {
+ variants: {
+ variant: {
+ default: "border bg-background text-foreground",
+ destructive:
+ "destructive group border-destructive bg-destructive text-destructive-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Toast = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+type ToastProps = React.ComponentPropsWithoutRef
+
+type ToastActionElement = React.ReactElement
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+}
\ No newline at end of file
diff --git a/webapp/src/components/ui/toaster.tsx b/webapp/src/components/ui/toaster.tsx
new file mode 100644
index 00000000..5cca26ec
--- /dev/null
+++ b/webapp/src/components/ui/toaster.tsx
@@ -0,0 +1,33 @@
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from "./toast"
+import { useToast } from "./use-toast"
+
+export function Toaster() {
+ const { toasts } = useToast()
+
+ return (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title}}
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ )
+ })}
+
+
+ )
+}
\ No newline at end of file
diff --git a/webapp/src/components/ui/tooltip.tsx b/webapp/src/components/ui/tooltip.tsx
index 61b717e0..1fc76891 100644
--- a/webapp/src/components/ui/tooltip.tsx
+++ b/webapp/src/components/ui/tooltip.tsx
@@ -1,7 +1,7 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
-import { cn } from "@/lib/utils"
+import { cn } from "../../lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
diff --git a/webapp/src/components/ui/use-toast.ts b/webapp/src/components/ui/use-toast.ts
index 8ff6db1f..60078487 100644
--- a/webapp/src/components/ui/use-toast.ts
+++ b/webapp/src/components/ui/use-toast.ts
@@ -1,42 +1,179 @@
-import { useState, useCallback } from 'react';
-
-export interface ToastProps {
- title?: string;
- description?: string;
- action?: React.ReactNode;
- variant?: 'default' | 'destructive';
- duration?: number;
+import { useCallback, useEffect, useState } from "react"
+
+const TOAST_LIMIT = 5
+const TOAST_REMOVE_DELAY = 5000
+
+type ToastProps = {
+ id: string
+ title?: string
+ description?: string
+ action?: React.ReactNode
+ variant?: "default" | "destructive" // Added variant prop
+ open: boolean
+ onOpenChange: (open: boolean) => void
}
-interface Toast extends ToastProps {
- id: string;
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
+} as const
+
+let count = 0
+
+function genId() {
+ count = (count + 1) % Number.MAX_SAFE_INTEGER
+ return count.toString()
}
-export const useToast = () => {
- const [toasts, setToasts] = useState([]);
-
- const toast = useCallback((props: ToastProps) => {
- const id = Math.random().toString(36).substring(2, 9);
- const newToast = { ...props, id };
-
- setToasts((prevToasts) => [...prevToasts, newToast]);
-
- if (props.duration !== Infinity) {
- setTimeout(() => {
- setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id));
- }, props.duration || 5000);
+type ActionType = typeof actionTypes
+
+type Action =
+ | {
+ type: ActionType["ADD_TOAST"]
+ toast: ToastProps
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"]
+ toast: Partial
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"]
+ toastId?: string
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"]
+ toastId?: string
+ }
+
+interface State {
+ toasts: ToastProps[]
+}
+
+const toastTimeouts = new Map>()
+
+const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case actionTypes.ADD_TOAST:
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ }
+
+ case actionTypes.UPDATE_TOAST:
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
+ ),
+ }
+
+ case actionTypes.DISMISS_TOAST: {
+ const { toastId } = action
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ const timeout = setTimeout(() => {
+ dispatch({
+ type: actionTypes.REMOVE_TOAST,
+ toastId,
+ })
+ }, TOAST_REMOVE_DELAY)
+
+ toastTimeouts.set(toastId, timeout)
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t
+ ),
+ }
}
-
- return id;
- }, []);
-
- const dismiss = useCallback((toastId?: string) => {
- if (toastId) {
- setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== toastId));
- } else {
- setToasts([]);
+ case actionTypes.REMOVE_TOAST:
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ }
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ }
+ }
+}
+
+const listeners: Array<(state: State) => void> = []
+
+let memoryState: State = { toasts: [] }
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action)
+ listeners.forEach((listener) => {
+ listener(memoryState)
+ })
+}
+
+type Toast = Omit
+
+export function useToast() {
+ const [state, setState] = useState(memoryState)
+
+ useEffect(() => {
+ listeners.push(setState)
+ return () => {
+ const index = listeners.indexOf(setState)
+ if (index > -1) {
+ listeners.splice(index, 1)
+ }
}
- }, []);
+ }, [])
+
+ const toast = useCallback(
+ (props: Toast) => {
+ const id = genId()
+
+ const update = (props: ToastProps) =>
+ dispatch({
+ type: actionTypes.UPDATE_TOAST,
+ toast: { ...props, id },
+ })
+ const dismiss = () =>
+ dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id })
+
+ dispatch({
+ type: actionTypes.ADD_TOAST,
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss()
+ },
+ },
+ })
+
+ return {
+ id,
+ dismiss,
+ update,
+ }
+ },
+ []
+ )
- return { toast, dismiss, toasts };
-};
\ No newline at end of file
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) =>
+ dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
+ }
+}
\ No newline at end of file
diff --git a/webapp/src/components/voice-search.tsx b/webapp/src/components/voice-search.tsx
index 42aa7ebd..2381b3ad 100644
--- a/webapp/src/components/voice-search.tsx
+++ b/webapp/src/components/voice-search.tsx
@@ -1,178 +1,270 @@
import React, { useState, useRef, useEffect } from 'react';
-import { Mic, MicOff, Loader2 } from 'lucide-react';
+import { Mic, MicOff, Loader2, Info } from 'lucide-react';
import { useVoiceRecognition } from '../hooks/use-voice-recognition';
import { Button } from './ui/button';
import { useToast } from './ui/use-toast';
import { cn } from '../lib/utils';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
+import { Progress } from './ui/progress';
+import { useTranslations } from '../hooks/use-translations';
interface VoiceSearchProps {
- onTranscript: (transcript: string) => void;
- className?: string;
- placeholder?: string;
+ onSearch: (query: string) => void;
+ onClose: () => void;
+ open: boolean;
}
-export function VoiceSearch({ onTranscript, className, placeholder = 'Speak to search for flights...' }: VoiceSearchProps) {
+// A map of UI lang to speech recognition language codes (expand as needed)
+const speechLangMap: Record = {
+ en: 'en-US',
+ fr: 'fr-FR',
+ de: 'de-DE',
+ es: 'es-ES',
+ it: 'it-IT',
+ pt: 'pt-PT',
+ ru: 'ru-RU',
+ nl: 'nl-NL',
+ ja: 'ja-JP',
+ ko: 'ko-KR',
+ zh: 'zh-CN',
+ // Add more as needed
+};
+
+export function VoiceSearch({ onSearch, onClose, open }: VoiceSearchProps) {
+ const { t, lang } = useTranslations();
+ const [isListening, setIsListening] = useState(false);
+ const [progressValue, setProgressValue] = useState(0);
+ const [showTips, setShowTips] = useState(false);
const [finalizedTranscript, setFinalizedTranscript] = useState('');
const [processingVoice, setProcessingVoice] = useState(false);
- const { toast } = useToast();
+ const progressIntervalRef = useRef(null);
const micButtonRef = useRef(null);
-
- // Animation for voice visualization
- const [amplitude, setAmplitude] = useState(0);
- const animationRef = useRef(null);
-
+ const { toast } = useToast();
+ const MAX_LISTENING_TIME = 15000; // 15 seconds max listening time
+
+ // Determine browser language code for speech recognition
+ // Fallback to en-US if current lang not in the map
+ const speechLang = speechLangMap[lang as string] || 'en-US';
+
const {
transcript,
- isListening,
startListening,
stopListening,
hasRecognitionSupport,
- error
+ error: recognitionError
} = useVoiceRecognition({
- language: 'en-US',
+ language: speechLang,
continuous: true,
interimResults: true,
- onResult: (newTranscript, isFinal) => {
- if (isFinal) {
- setFinalizedTranscript(newTranscript);
- setProcessingVoice(true);
-
- // Stop listening when we have a finalized result
- stopListening();
-
- // Process transcript
- setTimeout(() => {
- onTranscript(newTranscript);
- setProcessingVoice(false);
- }, 500);
+ onResult: (result, isFinal) => {
+ if (isFinal && result.trim()) {
+ setFinalizedTranscript(result);
+ handleVoiceResult(result);
}
},
- onError: (err) => {
+ onError: (error) => {
+ console.error('Voice recognition error:', error);
toast({
title: 'Voice Recognition Error',
- description: err.message,
+ description: error.message,
variant: 'destructive'
});
+ resetListening();
+ },
+ onEnd: () => {
+ // Only automatically search if we have a transcript and weren't manually stopped
+ if (transcript.trim() && isListening) {
+ handleVoiceResult(transcript);
+ }
+ resetListening();
}
});
-
- // Handle voice animation
+
+ // Auto stop after MAX_LISTENING_TIME
useEffect(() => {
if (isListening) {
- // Start animation when listening
- let direction = 1;
- let currentAmplitude = 0;
-
- const animate = () => {
- // Simulate voice amplitude
- if (direction > 0) {
- currentAmplitude += Math.random() * 2;
- if (currentAmplitude > 50) direction = -1;
- } else {
- currentAmplitude -= Math.random() * 2;
- if (currentAmplitude < 5) direction = 1;
+ const timeout = setTimeout(() => {
+ if (isListening) {
+ stopListening();
+ if (transcript.trim()) {
+ handleVoiceResult(transcript);
+ }
}
-
- setAmplitude(currentAmplitude);
- animationRef.current = requestAnimationFrame(animate);
- };
-
- animationRef.current = requestAnimationFrame(animate);
- } else {
- // Stop animation when not listening
- if (animationRef.current) {
- cancelAnimationFrame(animationRef.current);
- animationRef.current = null;
+ }, MAX_LISTENING_TIME);
+
+ return () => clearTimeout(timeout);
+ }
+ }, [isListening, transcript, stopListening]);
+
+ // Update progress bar
+ useEffect(() => {
+ if (isListening) {
+ if (progressIntervalRef.current) {
+ clearInterval(progressIntervalRef.current);
}
- setAmplitude(0);
+
+ setProgressValue(0);
+ const startTime = Date.now();
+
+ progressIntervalRef.current = window.setInterval(() => {
+ const elapsed = Date.now() - startTime;
+ const newProgress = Math.min((elapsed / MAX_LISTENING_TIME) * 100, 100);
+ setProgressValue(newProgress);
+
+ if (newProgress >= 100) {
+ clearInterval(progressIntervalRef.current as number);
+ }
+ }, 100);
+ } else if (progressIntervalRef.current) {
+ clearInterval(progressIntervalRef.current);
}
-
+
return () => {
- if (animationRef.current) {
- cancelAnimationFrame(animationRef.current);
+ if (progressIntervalRef.current) {
+ clearInterval(progressIntervalRef.current);
}
};
}, [isListening]);
-
+
+ // Auto-focus the microphone button when the dialog opens
+ useEffect(() => {
+ if (open) {
+ setTimeout(() => {
+ micButtonRef.current?.focus();
+ }, 100);
+ }
+ }, [open]);
+
+ // Clean up the voice recognition when dialog closes
+ useEffect(() => {
+ if (!open && isListening) {
+ stopListening();
+ resetListening();
+ }
+ }, [open, isListening, stopListening]);
+
// Show a notification if speech recognition is not supported
useEffect(() => {
- if (!hasRecognitionSupport) {
+ if (open && !hasRecognitionSupport) {
toast({
title: 'Speech Recognition Not Supported',
description: 'Your browser does not support speech recognition. Please try another browser or use text search.',
variant: 'destructive'
});
}
- }, [hasRecognitionSupport, toast]);
-
- // Show error message if there's an error
- useEffect(() => {
- if (error) {
- toast({
- title: 'Voice Recognition Error',
- description: error.message,
- variant: 'destructive'
- });
- }
- }, [error, toast]);
-
- // Toggle listening state
+ }, [open, hasRecognitionSupport, toast]);
+
const toggleListening = () => {
if (isListening) {
stopListening();
+ resetListening();
} else {
+ setIsListening(true);
startListening();
}
};
-
+
+ const resetListening = () => {
+ setIsListening(false);
+ setProgressValue(0);
+ if (progressIntervalRef.current) {
+ clearInterval(progressIntervalRef.current);
+ }
+ };
+
+ const handleVoiceResult = (text: string) => {
+ setProcessingVoice(true);
+
+ // Add a small delay to give visual feedback
+ setTimeout(() => {
+ onSearch(text.trim());
+ setProcessingVoice(false);
+ resetListening();
+ onClose();
+ }, 500);
+ };
+
+ if (!open) return null;
+
return (
-
-
-
- {processingVoice ? (
-
-
- Processing your search...
-
- ) : isListening ? (
-
- {transcript || {placeholder}}
-
- ) : (
-
- {finalizedTranscript || {placeholder}}
-
- )}
-
+
+
+ {/* Voice Tips Content */}
+ {showTips && (
+
+
{t('voice.exampleCommands')}:
+
+ - {t('voice.exampleCity')}
+ - {t('voice.exampleDate')}
+ - {t('voice.exampleComplete')}
+
+
+ )}
+
+
+
);
}
\ No newline at end of file
diff --git a/webapp/src/context/auth-context.tsx b/webapp/src/context/auth-context.tsx
index 86871d8f..0dc69599 100644
--- a/webapp/src/context/auth-context.tsx
+++ b/webapp/src/context/auth-context.tsx
@@ -1,263 +1,162 @@
-import React, { createContext, useContext, useEffect, useState } from 'react';
+import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
+import { supabase } from '../lib/supabase';
+import { debug, error as logError } from '../utils/logger';
import { Session, User } from '@supabase/supabase-js';
-import {
- supabase,
- getUserSubscription,
- UserSubscription,
- signInWithGoogle,
- AuthProvider
-} from '../lib/supabase';
-import { debug, error, info } from '../utils/logger';
+import { useNavigate } from 'react-router-dom';
+
+// Use a type instead of an enum to avoid conflicts
+export type AuthProviderType = 'google' | 'email';
+
+// Define AuthProvider object instead of enum
+export const AuthProvider = {
+ GOOGLE: 'google' as AuthProviderType,
+ EMAIL: 'email' as AuthProviderType
+};
+
+// Subscription
+export interface UserSubscription {
+ tier: 'free' | 'basic' | 'premium' | 'enterprise';
+ status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'incomplete';
+ currentPeriodEnd: string;
+ cancelAtPeriodEnd: boolean;
+ monthlyQuota: number;
+ remainingSearches: number;
+}
-interface AuthContextProps {
- session: Session | null;
+// Context shape
+interface AuthContextType {
user: User | null;
- subscription: UserSubscription | null;
- isLoading: boolean;
+ session: Session | null;
+ status: string;
+ isAuthenticated: boolean;
+ signInWithGoogle: () => Promise;
+ signUp: (email: string, password: string) => Promise<{ error: Error | null }>;
signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
- signUp: (email: string, password: string, metadata?: object) => Promise<{ error: Error | null }>;
- signInWithGoogle: () => Promise<{ error: Error | null }>;
signOut: () => Promise;
- sendPasswordResetEmail: (email: string) => Promise<{ error: Error | null }>;
- loadSubscription: () => Promise;
- updateUser: (data: object) => Promise<{ error: Error | null }>;
- isAuthenticated: boolean;
- canPerformSearch: boolean;
- remainingQueries: number;
- authProvider: AuthProvider | null;
+ subscription: UserSubscription;
+ // Add missing properties needed in tests
+ isLoading?: boolean;
+ authProvider?: AuthProviderType | null;
}
-const AuthContext = createContext(undefined);
+const DEFAULT_SUBSCRIPTION: UserSubscription = {
+ tier: 'free',
+ status: 'active',
+ currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
+ cancelAtPeriodEnd: false,
+ monthlyQuota: 5,
+ remainingSearches: 5,
+};
+
+const AuthContext = createContext(undefined);
-export function AuthProvider({ children }: { children: React.ReactNode }) {
+export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState(null);
const [user, setUser] = useState(null);
- const [subscription, setSubscription] = useState(null);
+ const [status, setStatus] = useState<'loading' | 'authenticated' | 'unauthenticated'>('loading');
+ const [subscription, setSubscription] = useState(DEFAULT_SUBSCRIPTION);
const [isLoading, setIsLoading] = useState(true);
- const [authProvider, setAuthProvider] = useState(null);
+ const [authProvider, setAuthProvider] = useState(null);
+ const navigate = useNavigate();
- // Check if the user can perform a search based on subscription status
- const canPerformSearch = !subscription ? false :
- subscription.queriesUsed < subscription.monthlyQuota;
-
- // Calculate remaining queries
- const remainingQueries = subscription ?
- Math.max(0, subscription.monthlyQuota - subscription.queriesUsed) : 0;
-
- // Load the user's subscription data
- const loadSubscription = async () => {
- if (!user) return;
-
- try {
- const sub = await getUserSubscription(user.id);
- setSubscription(sub);
- } catch (err) {
- error('Failed to load user subscription:', err);
- }
- };
-
- // Determine authentication provider
- const determineAuthProvider = (user: User | null) => {
- if (!user) {
- setAuthProvider(null);
- return;
- }
-
- // Check if the user authenticated with Google
- const isGoogleAuth = user.app_metadata?.provider === 'google' ||
- user.identities?.some(identity => identity.provider === 'google');
-
- setAuthProvider(isGoogleAuth ? AuthProvider.GOOGLE : AuthProvider.EMAIL);
- info('Auth provider determined:', isGoogleAuth ? 'Google' : 'Email');
- };
-
- // Initialize the auth context
useEffect(() => {
- const initAuth = async () => {
- try {
- setIsLoading(true);
-
- // Get the current session
- const { data: { session }, error: sessionError } = await supabase.auth.getSession();
-
- if (sessionError) {
- throw sessionError;
- }
-
- if (session) {
- setSession(session);
- setUser(session.user);
- determineAuthProvider(session.user);
- await loadSubscription();
- info('User authenticated:', session.user.id);
- }
- } catch (err) {
- error('Error initializing auth:', err);
- } finally {
- setIsLoading(false);
- }
- };
-
- initAuth();
-
- // Listen for auth changes
- const { data: authListener } = supabase.auth.onAuthStateChange(
- async (event, session) => {
- debug('Auth state changed:', event);
+ let unsub: (() => void) | undefined;
+ let mount = true;
+ (async () => {
+ setStatus('loading');
+ setIsLoading(true);
+ const { data: { session }, error } = await supabase.auth.getSession();
+ if (error) {
+ setStatus('unauthenticated');
+ setSession(null);
+ setUser(null);
+ } else {
setSession(session);
- const user = session?.user ?? null;
- setUser(user);
- determineAuthProvider(user);
-
- if (user) {
- await loadSubscription();
- } else {
- setSubscription(null);
+ setUser(session?.user || null);
+ setStatus(session?.user ? 'authenticated' : 'unauthenticated');
+ // Set auth provider if user exists
+ if (session?.user) {
+ // Check auth provider (adjust this based on how your app determines provider)
+ const provider = session.user.app_metadata?.provider as AuthProviderType;
+ setAuthProvider(provider || 'email');
}
}
- );
-
- return () => {
- authListener.subscription.unsubscribe();
- };
+ setIsLoading(false);
+
+ // Set up auth state change listener
+ const { data: { subscription: authSub } } = supabase.auth.onAuthStateChange(
+ (event, newSession) => {
+ setSession(newSession);
+ setUser(newSession?.user || null);
+ setStatus(newSession?.user ? 'authenticated' : 'unauthenticated');
+ // Update auth provider when auth state changes
+ if (newSession?.user) {
+ const provider = newSession.user.app_metadata?.provider as AuthProviderType;
+ setAuthProvider(provider || 'email');
+ } else {
+ setAuthProvider(null);
+ }
+ }
+ );
+ unsub = authSub.unsubscribe;
+ })();
+ return () => { unsub && unsub(); mount = false; };
}, []);
// Sign in with Google
- const handleSignInWithGoogle = async () => {
- try {
- const { error: signInError } = await signInWithGoogle();
-
- if (signInError) {
- error('Google sign in error:', signInError);
- return { error: signInError };
- }
-
- info('Redirecting to Google for authentication');
- return { error: null };
- } catch (err) {
- error('Google sign in exception:', err);
- return { error: err instanceof Error ? err : new Error('Unknown Google sign in error') };
- }
- };
-
- // Sign in with email and password
- const signIn = async (email: string, password: string) => {
- try {
- const { error: signInError } = await supabase.auth.signInWithPassword({
- email,
- password,
- });
-
- if (signInError) {
- error('Sign in error:', signInError);
- return { error: signInError };
+ async function signInWithGoogle() {
+ await supabase.auth.signInWithOAuth({
+ provider: 'google',
+ options: {
+ redirectTo: `${window.location.origin}/auth/callback`
}
-
- info('User signed in successfully');
- return { error: null };
- } catch (err) {
- error('Sign in exception:', err);
- return { error: err instanceof Error ? err : new Error('Unknown sign in error') };
- }
- };
-
- // Sign up with email and password
- const signUp = async (email: string, password: string, metadata = {}) => {
- try {
- const { error: signUpError } = await supabase.auth.signUp({
- email,
- password,
- options: {
- data: metadata
- }
- });
-
- if (signUpError) {
- error('Sign up error:', signUpError);
- return { error: signUpError };
- }
-
- info('User signed up successfully');
- return { error: null };
- } catch (err) {
- error('Sign up exception:', err);
- return { error: err instanceof Error ? err : new Error('Unknown sign up error') };
- }
- };
-
+ });
+ }
+ // Sign up with email/password
+ async function signUp(email: string, password: string) {
+ const { error } = await supabase.auth.signUp({
+ email,
+ password,
+ options: { emailRedirectTo: `${window.location.origin}/auth/callback` }
+ });
+ return { error: error?.message ? new Error(error.message) : null };
+ }
+ // Sign in with email/password
+ async function signIn(email: string, password: string) {
+ const { error } = await supabase.auth.signInWithPassword({ email, password });
+ return { error: error?.message ? new Error(error.message) : null };
+ }
// Sign out
- const signOut = async () => {
- try {
- await supabase.auth.signOut();
- info('User signed out');
- } catch (err) {
- error('Sign out error:', err);
- }
- };
-
- // Send password reset email
- const sendPasswordResetEmail = async (email: string) => {
- try {
- const { error: resetError } = await supabase.auth.resetPasswordForEmail(email);
-
- if (resetError) {
- error('Password reset error:', resetError);
- return { error: resetError };
- }
-
- info('Password reset email sent');
- return { error: null };
- } catch (err) {
- error('Password reset exception:', err);
- return { error: err instanceof Error ? err : new Error('Unknown password reset error') };
- }
- };
-
- // Update user data
- const updateUser = async (data: object) => {
- try {
- const { error: updateError } = await supabase.auth.updateUser({
- data
- });
-
- if (updateError) {
- error('Update user error:', updateError);
- return { error: updateError };
- }
-
- info('User updated successfully');
- return { error: null };
- } catch (err) {
- error('Update user exception:', err);
- return { error: err instanceof Error ? err : new Error('Unknown update user error') };
- }
- };
-
- const value = {
- session,
- user,
- subscription,
- isLoading,
- signIn,
- signUp,
- signInWithGoogle: handleSignInWithGoogle,
- signOut,
- sendPasswordResetEmail,
- loadSubscription,
- updateUser,
- isAuthenticated: !!user,
- canPerformSearch,
- remainingQueries,
- authProvider
- };
+ async function signOut() {
+ await supabase.auth.signOut();
+ setSession(null);
+ setUser(null);
+ setStatus('unauthenticated');
+ setAuthProvider(null);
+ navigate('/');
+ }
- return {children};
+ return (
+
+ {children}
+
+ );
}
-export const useAuth = () => {
- const context = useContext(AuthContext);
- if (context === undefined) {
- throw new Error('useAuth must be used within an AuthProvider');
- }
- return context;
-};
\ No newline at end of file
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error("useAuth must be used within an AuthProvider");
+ return ctx;
+}
\ No newline at end of file
diff --git a/webapp/src/context/filter-context.tsx b/webapp/src/context/filter-context.tsx
new file mode 100644
index 00000000..bccb3b6b
--- /dev/null
+++ b/webapp/src/context/filter-context.tsx
@@ -0,0 +1,22 @@
+import React, { createContext, useContext, useState, ReactNode } from "react";
+
+export interface FilterContextType {
+ // Extend as needed
+ filters: Record;
+ setFilters: (filters: Record) => void;
+}
+
+export const FilterContext = createContext({
+ filters: {},
+ setFilters: () => {},
+});
+
+export const FilterProvider = ({ children }: { children: ReactNode }) => {
+ const [filters, setFilters] = useState>({});
+
+ return (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/webapp/src/hooks/use-native-integration.ts b/webapp/src/hooks/use-native-integration.ts
index 494d8b7b..36d38871 100644
--- a/webapp/src/hooks/use-native-integration.ts
+++ b/webapp/src/hooks/use-native-integration.ts
@@ -1,180 +1,79 @@
-import { useState, useEffect, useCallback } from 'react';
-import { debug, info, error } from '../utils/logger';
+import { useState, useEffect } from 'react';
-// Types for native app integration
-interface NativeAppMessage {
- type: 'LOGIN' | 'SEARCH' | 'BOOKING' | 'SHARE' | 'CONFIG';
- payload: any;
-}
-
-interface NativeAppInfo {
- platform: 'ios' | 'android' | 'unknown';
- version: string;
- device: string;
- isNative: boolean;
-}
-
-interface UseNativeIntegrationReturn {
- isInNativeApp: boolean;
- nativeAppInfo: NativeAppInfo | null;
- sendToNativeApp: (message: NativeAppMessage) => Promise;
- openDeepLink: (path: string, params?: Record) => void;
+export interface UseNativeIntegrationReturn {
+ isAppInstalled: boolean;
+ showAppBanner: boolean;
+ appVersion: string | null;
+ isNativeApp: boolean;
+ openAppStore: () => void;
+ dismissBanner: () => void;
}
/**
- * Hook for integrating with native mobile apps
- * Provides detection of native app webview and methods for communication
+ * Hook to handle native app integration
+ * Detects if the website is running in a native app wrapper
+ * and provides functionality for app banner management
*/
export function useNativeIntegration(): UseNativeIntegrationReturn {
- const [isInNativeApp, setIsInNativeApp] = useState(false);
- const [nativeAppInfo, setNativeAppInfo] = useState(null);
+ const [isAppInstalled, setIsAppInstalled] = useState(false);
+ const [showAppBanner, setShowAppBanner] = useState(false);
+ const [appVersion, setAppVersion] = useState(null);
+ const [isNativeApp, setIsNativeApp] = useState(false);
- // Detect if running in a native app's webview
+ // Check if running in a native app context
useEffect(() => {
- const detectNativeApp = () => {
- try {
- // Check for native app's specific properties in window or userAgent
- const userAgent = navigator.userAgent.toLowerCase();
-
- // Check if a native app identifier is present
- const isIosNative = userAgent.includes('flightfinderapp_ios');
- const isAndroidNative = userAgent.includes('flightfinderapp_android');
-
- // Check if we're running in an app webview
- const isInWebView = /(iPhone|iPod|iPad).*AppleWebKit(?!.*Safari)/i.test(navigator.userAgent) ||
- /Android.*wv/.test(navigator.userAgent);
-
- if (isIosNative || isAndroidNative || (isInWebView && window['ReactNativeWebView'])) {
- setIsInNativeApp(true);
-
- // Determine platform details
- const platform = isIosNative ? 'ios' :
- isAndroidNative ? 'android' : 'unknown';
-
- // Extract version if available
- const versionMatch = userAgent.match(/flightfinderapp_(ios|android)\/([0-9.]+)/);
- const version = versionMatch ? versionMatch[2] : '1.0.0';
-
- // Extract device info
- const deviceMatch = userAgent.match(/\(([^)]+)\)/);
- const device = deviceMatch ? deviceMatch[1] : 'Unknown Device';
-
- setNativeAppInfo({
- platform,
- version,
- device,
- isNative: true
- });
-
- info(`Running in native app: ${platform} ${version} on ${device}`);
- } else {
- debug('Not running in native app environment');
- }
- } catch (err) {
- error('Error detecting native app:', err);
- }
- };
+ // Check for native app by looking for custom user agent or injected properties
+ const userAgent = navigator.userAgent.toLowerCase();
+ const isRunningInApp =
+ userAgent.includes('flightfinder') ||
+ typeof (window as any).FlightFinderApp !== 'undefined';
+
+ setIsNativeApp(isRunningInApp);
+
+ // Try to get app version if available
+ if (isRunningInApp && (window as any).FlightFinderApp?.getVersion) {
+ setAppVersion((window as any).FlightFinderApp.getVersion());
+ }
+
+ // Check if app is installed (using local storage to remember user's preference)
+ const appInstalledStatus = localStorage.getItem('app_installed');
+ setIsAppInstalled(appInstalledStatus === 'true');
- detectNativeApp();
+ // Determine if we should show the app banner
+ // Don't show if already in the app or if user has dismissed it
+ const bannerDismissed = localStorage.getItem('app_banner_dismissed');
+ setShowAppBanner(!isRunningInApp && bannerDismissed !== 'true');
}, []);
-
- // Function to send messages to the native app
- const sendToNativeApp = useCallback(async (message: NativeAppMessage): Promise => {
- return new Promise((resolve, reject) => {
- if (!isInNativeApp) {
- debug('Not in native app, message not sent', message);
- reject(new Error('Not running in native app environment'));
- return;
- }
-
- try {
- const messageString = JSON.stringify(message);
-
- // Set up response handler
- const messageHandler = (event: MessageEvent) => {
- try {
- const response = JSON.parse(event.data);
-
- if (response.id === message.type) {
- window.removeEventListener('message', messageHandler);
- resolve(response.data);
- }
- } catch (err) {
- // Ignore non-JSON messages
- }
- };
-
- // Listen for response
- window.addEventListener('message', messageHandler);
-
- // Send message to native app
- if (window['ReactNativeWebView']) {
- // React Native WebView
- window['ReactNativeWebView'].postMessage(messageString);
- } else if (window['webkit'] && window['webkit'].messageHandlers) {
- // iOS WKWebView
- window['webkit'].messageHandlers.flightFinderApp.postMessage(messageString);
- } else if (window['FlightFinderApp']) {
- // Android WebView
- window['FlightFinderApp'].postMessage(messageString);
- } else {
- window.removeEventListener('message', messageHandler);
- reject(new Error('Native app messaging interface not found'));
- }
-
- // Set timeout for response
- setTimeout(() => {
- window.removeEventListener('message', messageHandler);
- reject(new Error('Native app message timeout'));
- }, 5000);
-
- } catch (err) {
- error('Error sending message to native app:', err);
- reject(err);
- }
- });
- }, [isInNativeApp]);
-
- // Function to open a deep link
- const openDeepLink = useCallback((path: string, params: Record = {}): void => {
- try {
- // Construct deep link URL
- const baseUrl = 'flightfinder://';
- const queryParams = Object.entries(params)
- .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
- .join('&');
-
- const deepLinkUrl = `${baseUrl}${path}${queryParams ? `?${queryParams}` : ''}`;
-
- // If in native app, use native navigation
- if (isInNativeApp) {
- sendToNativeApp({
- type: 'CONFIG',
- payload: { action: 'navigate', path, params }
- }).catch(err => error('Failed to navigate in native app:', err));
- return;
- }
-
- // Otherwise try to open deep link
- const iframe = document.createElement('iframe');
- iframe.style.display = 'none';
- iframe.src = deepLinkUrl;
- document.body.appendChild(iframe);
-
- // Clean up iframe after attempt
- setTimeout(() => {
- document.body.removeChild(iframe);
- }, 1000);
-
- } catch (err) {
- error('Error opening deep link:', err);
+
+ // Function to open app store
+ const openAppStore = () => {
+ const userAgent = navigator.userAgent.toLowerCase();
+
+ // Determine platform and open appropriate store
+ if (userAgent.includes('android')) {
+ window.open('https://play.google.com/store/apps/details?id=com.flightfinder.app', '_blank');
+ } else if (userAgent.includes('iphone') || userAgent.includes('ipad') || userAgent.includes('ipod')) {
+ window.open('https://apps.apple.com/app/flightfinder/id123456789', '_blank');
+ } else {
+ // Default to website app page
+ window.open('/download-app', '_blank');
}
- }, [isInNativeApp, sendToNativeApp]);
-
+ };
+
+ // Function to dismiss app banner
+ const dismissBanner = () => {
+ setShowAppBanner(false);
+ localStorage.setItem('app_banner_dismissed', 'true');
+ };
+
return {
- isInNativeApp,
- nativeAppInfo,
- sendToNativeApp,
- openDeepLink
+ isAppInstalled,
+ showAppBanner,
+ appVersion,
+ isNativeApp,
+ openAppStore,
+ dismissBanner
};
-}
\ No newline at end of file
+}
+
+export default useNativeIntegration;
\ No newline at end of file
diff --git a/webapp/src/hooks/use-voice-recognition.ts b/webapp/src/hooks/use-voice-recognition.ts
index cce653b5..5817d0be 100644
--- a/webapp/src/hooks/use-voice-recognition.ts
+++ b/webapp/src/hooks/use-voice-recognition.ts
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
-import { debug, error, info } from '../utils/logger';
+import { debug, error as loggerError, info } from '../utils/logger';
interface VoiceRecognitionOptions {
language?: string;
@@ -49,7 +49,7 @@ export function useVoiceRecognition(options: VoiceRecognitionOptions = {}): UseV
debug('Voice recognition initialized successfully');
} catch (err) {
- error('Error initializing voice recognition:', err);
+ loggerError('Error initializing voice recognition:', err);
setError(err instanceof Error ? err : new Error('Failed to initialize voice recognition'));
setHasRecognitionSupport(false);
}
@@ -94,7 +94,7 @@ export function useVoiceRecognition(options: VoiceRecognitionOptions = {}): UseV
options.onResult(currentTranscript, !!finalTranscript);
}
} catch (err) {
- error('Error handling recognition result:', err);
+ loggerError('Error handling recognition result:', err);
}
};
@@ -134,7 +134,9 @@ export function useVoiceRecognition(options: VoiceRecognitionOptions = {}): UseV
// Start listening
const startListening = useCallback(() => {
if (!recognition || !hasRecognitionSupport) {
- error('Cannot start listening: Speech recognition not supported or not initialized');
+ // Properly create a new Error object
+ setError(new Error('Cannot start listening: Speech recognition not supported or not initialized'));
+ loggerError('Cannot start listening: Speech recognition not supported or not initialized');
return;
}
@@ -149,7 +151,7 @@ export function useVoiceRecognition(options: VoiceRecognitionOptions = {}): UseV
info('Voice recognition started');
} catch (err) {
- error('Error starting voice recognition:', err);
+ loggerError('Error starting voice recognition:', err);
setError(err instanceof Error ? err : new Error('Failed to start voice recognition'));
}
}, [recognition, hasRecognitionSupport, options]);
@@ -164,7 +166,8 @@ export function useVoiceRecognition(options: VoiceRecognitionOptions = {}): UseV
recognition.stop();
info('Voice recognition stopped');
} catch (err) {
- error('Error stopping voice recognition:', err);
+ loggerError('Error stopping voice recognition:', err);
+ setError(err instanceof Error ? err : new Error('Failed to stop voice recognition'));
}
}, [recognition, hasRecognitionSupport, isListening]);
diff --git a/webapp/src/lib/stripe.ts b/webapp/src/lib/stripe.ts
index 5849fb56..dfe0b5d2 100644
--- a/webapp/src/lib/stripe.ts
+++ b/webapp/src/lib/stripe.ts
@@ -1,22 +1,6 @@
import { loadStripe, Stripe } from '@stripe/stripe-js';
-import { debug, error } from '../utils/logger';
-let stripePromise: Promise;
-
-// Get Stripe instance
-export const getStripe = () => {
- if (!stripePromise) {
- const key = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
- if (!key) {
- error('Missing Stripe publishable key');
- throw new Error('Missing Stripe publishable key');
- }
- stripePromise = loadStripe(key);
- }
- return stripePromise;
-};
-
-// Subscription plans
+// SubscriptionPlan type
export interface SubscriptionPlan {
id: string;
name: string;
@@ -36,11 +20,7 @@ export const subscriptionPlans: SubscriptionPlan[] = [
id: 'basic',
name: 'Basic',
description: 'Perfect for casual travelers',
- features: [
- '20 searches per month',
- 'Basic flight details',
- 'Email support',
- ],
+ features: ['20 searches per month', 'Basic flight details', 'Email support'],
priceMonthly: 4.99,
priceYearly: 49.99,
monthlyQuota: 20,
@@ -89,46 +69,34 @@ export const subscriptionPlans: SubscriptionPlan[] = [
},
];
-// Create Stripe checkout session
+// Helper: get Stripe instance
+let stripePromise: Promise | undefined;
+export function getStripe() {
+ if (!stripePromise) {
+ const key =
+ import.meta.env?.VITE_STRIPE_PUBLISHABLE_KEY ||
+ 'pk_test_stripe_publishable_key_placeholder';
+ stripePromise = loadStripe(key);
+ }
+ return stripePromise;
+}
+
export async function createCheckoutSession(
priceId: string,
successUrl: string,
cancelUrl: string,
customerId?: string
): Promise {
- try {
- // In a real app, this would be a serverless function call
- // Here we're mocking it for demonstration
-
- debug('Creating checkout session', { priceId, customerId });
-
- // Mock checkout session URL for demo purposes
- const sessionUrl = `https://checkout.stripe.com/mock-session?price=${priceId}`;
-
- return sessionUrl;
- } catch (err) {
- error('Error creating checkout session:', err);
- return null;
- }
+ // Example: Should use actual endpoint or API
+ return `https://checkout.stripe.com/mock-session?price=${priceId}`;
}
-// Create customer portal session
export async function createCustomerPortalSession(
customerId: string,
returnUrl: string
): Promise {
- try {
- // In a real app, this would be a serverless function call
- // Here we're mocking it for demonstration
-
- debug('Creating customer portal session', { customerId });
-
- // Mock portal URL for demo purposes
- const portalUrl = `https://billing.stripe.com/mock-portal?customer=${customerId}`;
-
- return portalUrl;
- } catch (err) {
- error('Error creating customer portal session:', err);
- return null;
- }
-}
\ No newline at end of file
+ // Example: Should use actual endpoint or API
+ return `https://billing.stripe.com/mock-portal?customer=${customerId}`;
+}
+
+export default getStripe;
\ No newline at end of file
diff --git a/webapp/src/lib/supabase.ts b/webapp/src/lib/supabase.ts
index fe34066e..cfdc83b4 100644
--- a/webapp/src/lib/supabase.ts
+++ b/webapp/src/lib/supabase.ts
@@ -1,10 +1,10 @@
import { createClient } from '@supabase/supabase-js';
// Create a single supabase client for interacting with your database
-const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || '';
-const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || '';
-
-export const supabase = createClient(supabaseUrl, supabaseAnonKey);
+export const supabase = createClient(
+ import.meta.env?.VITE_SUPABASE_URL || 'https://example.supabase.co',
+ import.meta.env?.VITE_SUPABASE_ANON_KEY || 'placeholder-anon-key'
+);
// Define AuthError for test mocking
export class AuthError extends Error {
@@ -39,6 +39,7 @@ export interface UserSubscription {
tier: 'free' | 'premium' | 'pro';
queriesUsed: number;
queriesLimit: number;
+ monthlyQuota: number;
validUntil: string;
}
@@ -51,6 +52,7 @@ export const getUserSubscription = async (userId: string): Promise {
+ // Stub implementation - you should connect to a backend or storage here.
+ // For now, just resolve immediately.
+ return Promise.resolve();
+}
\ No newline at end of file
diff --git a/webapp/src/setupTests.ts b/webapp/src/setupTests.ts
index 091a846c..1e70bdd5 100644
--- a/webapp/src/setupTests.ts
+++ b/webapp/src/setupTests.ts
@@ -1,86 +1,105 @@
-// setupTests.ts - Setup for testing environment
-
-import { expect, afterEach, vi } from 'vitest';
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
-import { cleanup } from '@testing-library/react';
-import React from 'react';
-
-// Setup global mocks
-global.React = React;
+import { vi } from 'vitest';
-// Create mock for fetch
-global.fetch = vi.fn();
+// Mock supabase
+vi.mock('./lib/supabase', () => ({
+ supabase: {
+ auth: {
+ getSession: vi.fn().mockResolvedValue({ data: { session: null }, error: null }),
+ onAuthStateChange: vi.fn().mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } }),
+ signInWithOAuth: vi.fn(),
+ signInWithPassword: vi.fn(),
+ signUp: vi.fn(),
+ signOut: vi.fn()
+ }
+ },
+ incrementQueriesUsed: vi.fn(),
+ getUserSubscription: vi.fn(),
+ AuthProvider: {
+ GOOGLE: 'google',
+ EMAIL: 'email'
+ }
+}));
-// Create mock for localStorage
-const localStorageMock = {
- getItem: vi.fn(),
- setItem: vi.fn(),
- removeItem: vi.fn(),
- clear: vi.fn(),
-};
-global.localStorage = localStorageMock as any;
+// Mock stripe
+vi.mock('./lib/stripe', () => ({
+ default: Promise.resolve({}),
+ getStripe: vi.fn().mockResolvedValue({}),
+ subscriptionPlans: [
+ {
+ id: 'basic',
+ name: 'Basic',
+ description: 'Basic plan',
+ features: ['Feature 1'],
+ priceMonthly: 9.99,
+ priceYearly: 99.99,
+ monthlyQuota: 20,
+ stripePriceId: {
+ monthly: 'price_123',
+ yearly: 'price_456'
+ }
+ }
+ ],
+ createCheckoutSession: vi.fn().mockResolvedValue('https://test.stripe.com'),
+ createCustomerPortalSession: vi.fn().mockResolvedValue('https://test.stripe.com')
+}));
-// Create mock for sessionStorage
-global.sessionStorage = { ...localStorageMock } as any;
+// Setup test environment
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
-// Create mock for SpeechRecognition
-global.SpeechRecognition = vi.fn(() => ({
+// Mock window.SpeechRecognition
+global.SpeechRecognition = vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
+ removeEventListener: vi.fn()
}));
-global.webkitSpeechRecognition = global.SpeechRecognition;
-// Setup afterAll for Vitest
-if (!global.afterAll) {
- global.afterAll = (fn) => {
- fn();
- }
-}
+// Mock window.localStorage
+const localStorageMock = (function() {
+ let store: Record = {};
+ return {
+ getItem: function(key: string) {
+ return store[key] || null;
+ },
+ setItem: function(key: string, value: string) {
+ store[key] = value.toString();
+ },
+ removeItem: function(key: string) {
+ delete store[key];
+ },
+ clear: function() {
+ store = {};
+ }
+ };
+})();
-// Cleanup after each test
-afterEach(() => {
- cleanup();
- vi.resetAllMocks();
+Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock
});
-// Mock Supabase
-vi.mock('@supabase/supabase-js', () => ({
- createClient: vi.fn(() => ({
- auth: {
- signUp: vi.fn(),
- signIn: vi.fn(),
- signOut: vi.fn(),
- getSession: vi.fn(() => ({ data: { session: null }, error: null })),
- onAuthStateChange: vi.fn(() => ({ data: { subscription: { unsubscribe: vi.fn() } } })),
- },
- from: vi.fn(() => ({
- select: vi.fn(() => ({
- eq: vi.fn(() => ({
- single: vi.fn(() => ({ data: null, error: null })),
- maybeSingle: vi.fn(() => ({ data: null, error: null })),
- })),
- })),
- insert: vi.fn(() => ({ select: vi.fn(() => ({ single: vi.fn() })) })),
- update: vi.fn(() => ({ eq: vi.fn() })),
- delete: vi.fn(() => ({ eq: vi.fn() })),
- })),
- rpc: vi.fn(() => ({ data: null, error: null })),
- })),
-}));
-
-// Mock react-router-dom
-vi.mock('react-router-dom', () => ({
- useNavigate: vi.fn(() => vi.fn()),
- useLocation: vi.fn(() => ({ pathname: '/', search: '', hash: '', state: null })),
- useParams: vi.fn(() => ({})),
- Link: ({ children, to, ...props }) => React.createElement('a', { href: to, ...props }, children),
- Outlet: ({ children }) => React.createElement('div', { 'data-testid': 'outlet' }, children),
- Navigate: ({ to }) => React.createElement('div', { 'data-testid': 'navigate', to }, null),
- BrowserRouter: ({ children }) => React.createElement('div', { 'data-testid': 'browser-router' }, children),
- Routes: ({ children }) => React.createElement('div', { 'data-testid': 'routes' }, children),
- Route: () => null,
-}));
+// Set up Intersection Observer mock
+class MockIntersectionObserver {
+ constructor(callback: IntersectionObserverCallback) {}
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+}
-export {};
\ No newline at end of file
+window.IntersectionObserver = MockIntersectionObserver as any;
\ No newline at end of file
diff --git a/webapp/src/types/index.ts b/webapp/src/types/index.ts
index c82b6001..690a09a5 100644
--- a/webapp/src/types/index.ts
+++ b/webapp/src/types/index.ts
@@ -1,2 +1,64 @@
-// Export all types from FlightTypes.ts to make importing easier
-export * from './FlightTypes';
\ No newline at end of file
+export interface FlightSearch {
+ origin: string;
+ destination: string;
+ departureDate: string; // ISO/Date String
+ returnDate: string; // ISO/Date String
+ passengers: number;
+ cabinClass: string;
+}
+
+// Add missing types mentioned in error messages
+export interface DetailedFlightInfo {
+ price: string;
+ duration: string;
+ stops: number;
+ airline: string;
+ departure: string;
+ arrival: string;
+ origin: string;
+ destination: string;
+ departureDate: string;
+ returnDate: string;
+ flightNumber?: string;
+ operatingAirline?: string;
+ aircraftType?: string;
+ cabinClass?: string;
+ fareType?: string;
+ distance?: string;
+ layovers?: {
+ airport: string;
+ duration: string;
+ arrivalTime: string;
+ departureTime: string;
+ }[];
+ luggage?: {
+ carryOn: string;
+ checkedBags: string;
+ };
+ amenities?: {
+ wifi: boolean;
+ powerOutlets: boolean;
+ seatPitch: string;
+ entertainment: string;
+ meals: string;
+ };
+ environmentalImpact?: string;
+ cancellationPolicy?: string;
+ changePolicy?: string;
+ notes?: string;
+}
+
+// Add FlightResult type for virtualized-flight-list component
+export interface FlightResult extends DetailedFlightInfo {
+ id?: string;
+}
+
+// Add FlightQuery type for priceAlertService
+export interface FlightQuery {
+ origin: string;
+ destination: string;
+ departureDate: string;
+ returnDate?: string;
+ passengers?: number;
+ cabinClass?: string;
+}
\ No newline at end of file
diff --git a/webapp/src/utils/logger.ts b/webapp/src/utils/logger.ts
index 90f0ea9b..8356b3fa 100644
--- a/webapp/src/utils/logger.ts
+++ b/webapp/src/utils/logger.ts
@@ -1,61 +1,45 @@
+// Logger utility to standardize logging across the application
+
/**
- * Logger utility for consistent logging across the application
+ * Log a debug message
+ * @param message The message to log
+ * @param data Optional additional data
*/
-
-// Named exports for logger functions
-export const debug = (message: string, ...args: any[]) => {
- if (process.env.NODE_ENV !== 'production') {
- console.debug(`[DEBUG] ${message}`, ...args);
- }
+export const debug = (message: string, data?: any) => {
+ console.debug('[DEBUG]', message, ...(data ? [data] : []));
};
-export const info = (message: string, ...args: any[]) => {
- console.info(`[INFO] ${message}`, ...args);
-};
-
-export const error = (message: string, ...args: any[]) => {
- console.error(`[ERROR] ${message}`, ...args);
-};
-
-export const warn = (message: string, ...args: any[]) => {
- console.warn(`[WARN] ${message}`, ...args);
-};
-
-// Additional logging methods for API interactions
-export const logApiRequest = (endpoint: string, params: any) => {
- if (process.env.NODE_ENV !== 'production') {
- console.debug(`[API REQUEST] ${endpoint}`, params);
- }
+/**
+ * Log an info message
+ * @param message The message to log
+ * @param data Optional additional data
+ */
+export const info = (message: string, data?: any) => {
+ console.info('[INFO]', message, ...(data ? [data] : []));
};
-export const logApiResponse = (endpoint: string, response: any, status: number = 200) => {
- if (process.env.NODE_ENV !== 'production') {
- console.debug(`[API RESPONSE] ${endpoint} (${status})`, response);
- }
+/**
+ * Log a warning message
+ * @param message The message to log
+ * @param data Optional additional data
+ */
+export const warn = (message: string, data?: any) => {
+ console.warn('[WARN]', message, ...(data ? [data] : []));
};
-export const logUserActivity = (activity: string, details?: any) => {
- console.info(`[USER ACTIVITY] ${activity}`, details || '');
+/**
+ * Log an error message
+ * @param message The message to log
+ * @param error Optional error object
+ * @param data Optional additional data
+ */
+export const error = (message: string, error?: Error | any) => {
+ console.error('[ERROR]', message, ...(error ? [error] : []));
};
-// Factory function for creating contextual loggers
-export const createLogger = (context: string) => ({
- debug: (message: string, ...args: any[]) => debug(`[${context}] ${message}`, ...args),
- info: (message: string, ...args: any[]) => info(`[${context}] ${message}`, ...args),
- error: (message: string, ...args: any[]) => error(`[${context}] ${message}`, ...args),
- warn: (message: string, ...args: any[]) => warn(`[${context}] ${message}`, ...args),
-});
-
-// Default export for backward compatibility
-const logger = {
+export default {
debug,
info,
- error,
warn,
- logApiRequest,
- logApiResponse,
- logUserActivity,
- createLogger
-};
-
-export default logger;
\ No newline at end of file
+ error
+};
\ No newline at end of file
diff --git a/webapp/src/utils/tests/logger.test.ts b/webapp/src/utils/tests/logger.test.ts
new file mode 100644
index 00000000..bf81cfd7
--- /dev/null
+++ b/webapp/src/utils/tests/logger.test.ts
@@ -0,0 +1,60 @@
+import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { debug, info, warn, error } from '../logger';
+
+describe('Logger', () => {
+ beforeEach(() => {
+ // Mock console methods
+ console.debug = vi.fn();
+ console.info = vi.fn();
+ console.warn = vi.fn();
+ console.error = vi.fn();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should log debug messages', () => {
+ debug('Test debug message');
+ expect(console.debug).toHaveBeenCalledWith('[DEBUG]', 'Test debug message');
+ });
+
+ it('should log debug messages with additional data', () => {
+ const data = { user: 'test', id: 123 };
+ debug('Test debug message', data);
+ expect(console.debug).toHaveBeenCalledWith('[DEBUG]', 'Test debug message', data);
+ });
+
+ it('should log info messages', () => {
+ info('Test info message');
+ expect(console.info).toHaveBeenCalledWith('[INFO]', 'Test info message');
+ });
+
+ it('should log info messages with additional data', () => {
+ const data = { user: 'test', id: 123 };
+ info('Test info message', data);
+ expect(console.info).toHaveBeenCalledWith('[INFO]', 'Test info message', data);
+ });
+
+ it('should log warning messages', () => {
+ warn('Test warning message');
+ expect(console.warn).toHaveBeenCalledWith('[WARN]', 'Test warning message');
+ });
+
+ it('should log warning messages with additional data', () => {
+ const data = { user: 'test', id: 123 };
+ warn('Test warning message', data);
+ expect(console.warn).toHaveBeenCalledWith('[WARN]', 'Test warning message', data);
+ });
+
+ it('should log error messages', () => {
+ error('Test error message');
+ expect(console.error).toHaveBeenCalledWith('[ERROR]', 'Test error message');
+ });
+
+ it('should log error messages with an error object', () => {
+ const err = new Error('Something went wrong');
+ error('Test error message', err);
+ expect(console.error).toHaveBeenCalledWith('[ERROR]', 'Test error message', err);
+ });
+});
\ No newline at end of file
diff --git a/webapp/src/vite-env.d.ts b/webapp/src/vite-env.d.ts
new file mode 100644
index 00000000..0612bd73
--- /dev/null
+++ b/webapp/src/vite-env.d.ts
@@ -0,0 +1,12 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_SUPABASE_URL: string
+ readonly VITE_SUPABASE_ANON_KEY: string
+ readonly VITE_STRIPE_PUBLISHABLE_KEY: string
+ // more env variables...
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
\ No newline at end of file