diff --git a/CLAUDE.md b/CLAUDE.md index 54bdafd..1cbcf3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,9 +29,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Testing:** -- `pnpm test` - Run tests once -- `pnpm test:watch` - Run tests in watch mode -- `pnpm test:coverage` - Run tests with coverage report +- `pnpm test` - Run both Vitest and Playwright tests concurrently +- `pnpm test:vitest` - Run Vitest tests only +- `pnpm test:vitest:watch` - Run Vitest in watch mode +- `pnpm test:vitest:coverage` - Run Vitest with coverage report +- `pnpm test:coverage` - Run all tests with coverage **Markdown:** @@ -115,16 +117,28 @@ Environment variables are managed through `lib/env.ts` with .env.local file supp - Schedulers are optional and controlled by `SCHEDULER_ENABLED` environment variable - Use `pnpm dev:next-only` to run without schedulers for pure frontend development -- Three separate schedulers handle different pipeline stages +- Three separate schedulers handle different pipeline stages: + - CSV Import Scheduler (`lib/scheduler.ts`) + - Import Processing Scheduler (`lib/importProcessor.ts`) + - Session Processing Scheduler (`lib/processingScheduler.ts`) **Database Migrations:** - Always run `pnpm prisma:generate` after schema changes - Use `pnpm prisma:migrate` for production-ready migrations - Use `pnpm prisma:push` for development schema changes +- Database uses PostgreSQL with Prisma's driver adapter for connection pooling **AI Processing:** - All AI requests are tracked for cost analysis - Support for multiple AI models per company - Time-based pricing management for accurate cost calculation +- Processing stages can be retried on failure with retry count tracking + +**Code Quality Standards:** + +- Run `pnpm lint` and `pnpm format:check` before committing +- TypeScript with ES modules (type: "module" in package.json) +- React 19 with Next.js 15 App Router +- TailwindCSS 4 for styling diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index ce5f813..0754f3b 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -57,7 +57,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { } return ( -
+
{ return (
-
-

Loading dashboard...

+
+
+
+
+

Loading dashboard...

); @@ -121,39 +124,39 @@ const DashboardPage: FC = () => { return (
{/* Welcome Header */} - - +
+
+
+
-
+
-

+

Welcome back, {session?.user?.name || "User"}!

- + {session?.user?.role}
-

+

Choose a section below to explore your analytics dashboard

-
-
- - Secure Dashboard -
+
+ + Secure Dashboard
- - +
+
{/* Navigation Cards */}
{navigationCards.map((card, index) => ( router.push(card.href)} @@ -166,11 +169,11 @@ const DashboardPage: FC = () => {
- {card.icon} + {card.icon}
@@ -198,7 +201,7 @@ const DashboardPage: FC = () => { key={featureIndex} className="flex items-center gap-2 text-sm" > -
+ {feature}
))} @@ -206,7 +209,7 @@ const DashboardPage: FC = () => { {/* Action Button */} - -
- - Register company - +
+ {/* Left side - Branding and Features */} +
+
+
+
+ +
+
+ +
+ LiveDash Logo +
+ LiveDash + + +

+ Welcome back to your analytics dashboard +

+

+ Monitor, analyze, and optimize your customer conversations with AI-powered insights. +

+ +
+
+
+ +
+ Real-time analytics and insights +
+
+
+ +
+ Enterprise-grade security +
+
+
+ +
+ AI-powered conversation analysis +
+
+
+
-
- - Forgot password? - + + {/* Right side - Login Form */} +
+
+ +
+ +
+ {/* Mobile logo */} +
+ +
+ LiveDash Logo +
+ LiveDash + +
+ + + + Sign in + + Enter your email and password to access your dashboard + + + + {error && ( + + {error} + + )} + +
+
+ + setEmail(e.target.value)} + disabled={isLoading} + required + className="transition-all duration-200 focus:ring-2 focus:ring-primary/20" + /> +
+
+ + setPassword(e.target.value)} + disabled={isLoading} + required + className="transition-all duration-200 focus:ring-2 focus:ring-primary/20" + /> +
+ + +
+ +
+
+ + Don't have a company account? Register here + +
+
+ + Forgot your password? + +
+
+
+
+ +

+ By signing in, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + +

+
); diff --git a/app/providers.tsx b/app/providers.tsx index aecc7f9..1d01b91 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -2,16 +2,24 @@ import { SessionProvider } from "next-auth/react"; import { ReactNode } from "react"; +import { ThemeProvider } from "@/components/theme-provider"; export function Providers({ children }: { children: ReactNode }) { // Including error handling and refetch interval for better user experience return ( - - {children} - + + {children} + + ); } diff --git a/components/GeographicMap.tsx b/components/GeographicMap.tsx index 5e974ca..aabb555 100644 --- a/components/GeographicMap.tsx +++ b/components/GeographicMap.tsx @@ -63,7 +63,7 @@ const DEFAULT_COORDINATES = getCountryCoordinates(); const Map = dynamic(() => import("./Map"), { ssr: false, loading: () => ( -
+
Loading map...
), @@ -151,7 +151,7 @@ export default function GeographicMap({ // Show loading state during SSR or until client-side rendering takes over if (!isClient) { return ( -
+
Loading map...
); @@ -162,7 +162,7 @@ export default function GeographicMap({ {countryData.length > 0 ? ( ) : ( -
+
No geographic data available
)} diff --git a/components/Map.tsx b/components/Map.tsx index 2522ad6..b95f232 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -3,6 +3,8 @@ import { MapContainer, TileLayer, CircleMarker, Tooltip } from "react-leaflet"; import "leaflet/dist/leaflet.css"; import { getLocalizedCountryName } from "../lib/localization"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; interface CountryData { code: string; @@ -16,6 +18,29 @@ interface MapProps { } const Map = ({ countryData, maxCount }: MapProps) => { + const { theme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + // Don't render until mounted to avoid hydration mismatch + if (!mounted) { + return
; + } + + const isDark = theme === "dark"; + + // Use different tile layers based on theme + const tileLayerUrl = isDark + ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" + : "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"; + + const tileLayerAttribution = isDark + ? '© OpenStreetMap contributors © CARTO' + : '© OpenStreetMap contributors © CARTO'; + return ( { style={{ height: "100%", width: "100%", borderRadius: "0.5rem" }} > {countryData.map((country) => ( { center={country.coordinates} radius={5 + (country.count / maxCount) * 20} pathOptions={{ - fillColor: "#3B82F6", - color: "#1E40AF", - weight: 1, - opacity: 0.8, + fillColor: "hsl(var(--primary))", + color: "hsl(var(--primary))", + weight: 2, + opacity: 0.9, fillOpacity: 0.6, }} > -
-
+
+
{getLocalizedCountryName(country.code)}
-
Sessions: {country.count}
+
Sessions: {country.count}
diff --git a/components/ResponseTimeDistribution.tsx b/components/ResponseTimeDistribution.tsx index 255563a..a1417a4 100644 --- a/components/ResponseTimeDistribution.tsx +++ b/components/ResponseTimeDistribution.tsx @@ -114,7 +114,12 @@ export default function ResponseTimeDistribution({ /> } /> - + {chartData.map((entry, index) => ( ))} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index e8e4065..0c83393 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import Image from "next/image"; import { usePathname } from "next/navigation"; import { signOut } from "next-auth/react"; +import { SimpleThemeToggle } from "@/components/ui/theme-toggle"; // Icons for the sidebar const DashboardIcon = () => ( @@ -158,8 +159,8 @@ const NavItem: React.FC = ({ href={href} className={`relative flex items-center p-3 my-1 rounded-lg transition-all group ${ isActive - ? "bg-sky-100 text-sky-800 font-medium" - : "hover:bg-gray-100 text-gray-700 hover:text-gray-900" + ? "bg-primary/10 text-primary font-medium border border-primary/20" + : "hover:bg-muted text-muted-foreground hover:text-foreground" }`} onClick={() => { if (onNavigate) { @@ -175,7 +176,7 @@ const NavItem: React.FC = ({ ) : (
@@ -202,13 +203,13 @@ export default function Sidebar({ {/* Backdrop overlay when sidebar is expanded on mobile */} {isExpanded && isMobile && (
)}
@@ -248,7 +249,7 @@ export default function Sidebar({ />
{isExpanded && ( - + LiveDash )} @@ -261,7 +262,7 @@ export default function Sidebar({ e.preventDefault(); // Prevent any navigation onToggle(); }} - className="p-1.5 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors group" + className="p-1.5 rounded-md hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors group" title="Collapse sidebar" > @@ -327,10 +328,17 @@ export default function Sidebar({ onNavigate={onNavigate} /> -
+
+ {/* Theme Toggle */} +
+ {isExpanded && Theme} + +
+ + {/* Logout Button */} + ); +}; + +ConfettiButtonComponent.displayName = "ConfettiButton"; + +export const ConfettiButton = ConfettiButtonComponent; diff --git a/components/magicui/magic-card.tsx b/components/magicui/magic-card.tsx new file mode 100644 index 0000000..58b71ef --- /dev/null +++ b/components/magicui/magic-card.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { motion, useMotionTemplate, useMotionValue } from "motion/react"; +import React, { useCallback, useEffect, useRef } from "react"; + +import { cn } from "@/lib/utils"; + +interface MagicCardProps { + children?: React.ReactNode; + className?: string; + gradientSize?: number; + gradientColor?: string; + gradientOpacity?: number; + gradientFrom?: string; + gradientTo?: string; +} + +export function MagicCard({ + children, + className, + gradientSize = 200, + gradientColor = "#262626", + gradientOpacity = 0.8, + gradientFrom = "#9E7AFF", + gradientTo = "#FE8BBB", +}: MagicCardProps) { + const cardRef = useRef(null); + const mouseX = useMotionValue(-gradientSize); + const mouseY = useMotionValue(-gradientSize); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (cardRef.current) { + const { left, top } = cardRef.current.getBoundingClientRect(); + const clientX = e.clientX; + const clientY = e.clientY; + mouseX.set(clientX - left); + mouseY.set(clientY - top); + } + }, + [mouseX, mouseY], + ); + + const handleMouseOut = useCallback( + (e: MouseEvent) => { + if (!e.relatedTarget) { + document.removeEventListener("mousemove", handleMouseMove); + mouseX.set(-gradientSize); + mouseY.set(-gradientSize); + } + }, + [handleMouseMove, mouseX, gradientSize, mouseY], + ); + + const handleMouseEnter = useCallback(() => { + document.addEventListener("mousemove", handleMouseMove); + mouseX.set(-gradientSize); + mouseY.set(-gradientSize); + }, [handleMouseMove, mouseX, gradientSize, mouseY]); + + useEffect(() => { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseout", handleMouseOut); + document.addEventListener("mouseenter", handleMouseEnter); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseout", handleMouseOut); + document.removeEventListener("mouseenter", handleMouseEnter); + }; + }, [handleMouseEnter, handleMouseMove, handleMouseOut]); + + useEffect(() => { + mouseX.set(-gradientSize); + mouseY.set(-gradientSize); + }, [gradientSize, mouseX, mouseY]); + + return ( +
+ +
+ +
{children}
+
+ ); +} diff --git a/components/magicui/meteors.tsx b/components/magicui/meteors.tsx new file mode 100644 index 0000000..de2c61b --- /dev/null +++ b/components/magicui/meteors.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import React, { useEffect, useState } from "react"; + +interface MeteorsProps { + number?: number; + minDelay?: number; + maxDelay?: number; + minDuration?: number; + maxDuration?: number; + angle?: number; + className?: string; +} + +export const Meteors = ({ + number = 20, + minDelay = 0.2, + maxDelay = 1.2, + minDuration = 2, + maxDuration = 10, + angle = 215, + className, +}: MeteorsProps) => { + const [meteorStyles, setMeteorStyles] = useState>( + [], + ); + + useEffect(() => { + const styles = [...new Array(number)].map(() => ({ + "--angle": -angle + "deg", + top: "-5%", + left: `calc(0% + ${Math.floor(Math.random() * window.innerWidth)}px)`, + animationDelay: Math.random() * (maxDelay - minDelay) + minDelay + "s", + animationDuration: + Math.floor(Math.random() * (maxDuration - minDuration) + minDuration) + + "s", + })); + setMeteorStyles(styles); + }, [number, minDelay, maxDelay, minDuration, maxDuration, angle]); + + return ( + <> + {[...meteorStyles].map((style, idx) => ( + // Meteor Head + + {/* Meteor Tail */} +
+ + ))} + + ); +}; diff --git a/components/magicui/neon-gradient-card.tsx b/components/magicui/neon-gradient-card.tsx new file mode 100644 index 0000000..1a2a76b --- /dev/null +++ b/components/magicui/neon-gradient-card.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { + CSSProperties, + ReactElement, + ReactNode, + useEffect, + useRef, + useState, +} from "react"; + +import { cn } from "@/lib/utils"; + +interface NeonColorsProps { + firstColor: string; + secondColor: string; +} + +interface NeonGradientCardProps { + /** + * @default
+ * @type ReactElement + * @description + * The component to be rendered as the card + * */ + as?: ReactElement; + /** + * @default "" + * @type string + * @description + * The className of the card + */ + className?: string; + + /** + * @default "" + * @type ReactNode + * @description + * The children of the card + * */ + children?: ReactNode; + + /** + * @default 5 + * @type number + * @description + * The size of the border in pixels + * */ + borderSize?: number; + + /** + * @default 20 + * @type number + * @description + * The size of the radius in pixels + * */ + borderRadius?: number; + + /** + * @default "{ firstColor: '#ff00aa', secondColor: '#00FFF1' }" + * @type string + * @description + * The colors of the neon gradient + * */ + neonColors?: NeonColorsProps; + + [key: string]: any; +} + +export const NeonGradientCard: React.FC = ({ + className, + children, + borderSize = 2, + borderRadius = 20, + neonColors = { + firstColor: "#ff00aa", + secondColor: "#00FFF1", + }, + ...props +}) => { + const containerRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + + useEffect(() => { + const updateDimensions = () => { + if (containerRef.current) { + const { offsetWidth, offsetHeight } = containerRef.current; + setDimensions({ width: offsetWidth, height: offsetHeight }); + } + }; + + updateDimensions(); + window.addEventListener("resize", updateDimensions); + + return () => { + window.removeEventListener("resize", updateDimensions); + }; + }, []); + + useEffect(() => { + if (containerRef.current) { + const { offsetWidth, offsetHeight } = containerRef.current; + setDimensions({ width: offsetWidth, height: offsetHeight }); + } + }, [children]); + + return ( +
+
+ {children} +
+
+ ); +}; diff --git a/components/magicui/number-ticker.tsx b/components/magicui/number-ticker.tsx new file mode 100644 index 0000000..1b9bf39 --- /dev/null +++ b/components/magicui/number-ticker.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useInView, useMotionValue, useSpring } from "motion/react"; +import { ComponentPropsWithoutRef, useEffect, useRef } from "react"; + +import { cn } from "@/lib/utils"; + +interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> { + value: number; + startValue?: number; + direction?: "up" | "down"; + delay?: number; + decimalPlaces?: number; +} + +export function NumberTicker({ + value, + startValue = 0, + direction = "up", + delay = 0, + className, + decimalPlaces = 0, + ...props +}: NumberTickerProps) { + const ref = useRef(null); + const motionValue = useMotionValue(direction === "down" ? value : startValue); + const springValue = useSpring(motionValue, { + damping: 60, + stiffness: 100, + }); + const isInView = useInView(ref, { once: true, margin: "0px" }); + + useEffect(() => { + if (isInView) { + const timer = setTimeout(() => { + motionValue.set(direction === "down" ? startValue : value); + }, delay * 1000); + return () => clearTimeout(timer); + } + }, [motionValue, isInView, delay, value, direction, startValue]); + + useEffect( + () => + springValue.on("change", (latest) => { + if (ref.current) { + ref.current.textContent = Intl.NumberFormat("en-US", { + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + }).format(Number(latest.toFixed(decimalPlaces))); + } + }), + [springValue, decimalPlaces], + ); + + return ( + + {startValue} + + ); +} diff --git a/components/magicui/pointer.tsx b/components/magicui/pointer.tsx new file mode 100644 index 0000000..be75d4e --- /dev/null +++ b/components/magicui/pointer.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { + AnimatePresence, + HTMLMotionProps, + motion, + useMotionValue, +} from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +interface PointerProps extends Omit, "ref"> {} + +/** + * A custom pointer component that displays an animated cursor. + * Add this as a child to any component to enable a custom pointer when hovering. + * You can pass custom children to render as the pointer. + * + * @component + * @param {PointerProps} props - The component props + */ +export function Pointer({ + className, + style, + children, + ...props +}: PointerProps): JSX.Element { + const x = useMotionValue(0); + const y = useMotionValue(0); + const [isActive, setIsActive] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (typeof window !== "undefined" && containerRef.current) { + // Get the parent element directly from the ref + const parentElement = containerRef.current.parentElement; + + if (parentElement) { + // Add cursor-none to parent + parentElement.style.cursor = "none"; + + // Add event listeners to parent + const handleMouseMove = (e: MouseEvent) => { + x.set(e.clientX); + y.set(e.clientY); + }; + + const handleMouseEnter = () => { + setIsActive(true); + }; + + const handleMouseLeave = () => { + setIsActive(false); + }; + + parentElement.addEventListener("mousemove", handleMouseMove); + parentElement.addEventListener("mouseenter", handleMouseEnter); + parentElement.addEventListener("mouseleave", handleMouseLeave); + + return () => { + parentElement.style.cursor = ""; + parentElement.removeEventListener("mousemove", handleMouseMove); + parentElement.removeEventListener("mouseenter", handleMouseEnter); + parentElement.removeEventListener("mouseleave", handleMouseLeave); + }; + } + } + }, [x, y]); + + return ( + <> +
+ + {isActive && ( + + {children || ( + + + + )} + + )} + + + ); +} diff --git a/components/magicui/scroll-progress.tsx b/components/magicui/scroll-progress.tsx new file mode 100644 index 0000000..3edfe6f --- /dev/null +++ b/components/magicui/scroll-progress.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { motion, MotionProps, useScroll } from "motion/react"; +import React from "react"; +interface ScrollProgressProps + extends Omit, keyof MotionProps> {} + +export const ScrollProgress = React.forwardRef< + HTMLDivElement, + ScrollProgressProps +>(({ className, ...props }, ref) => { + const { scrollYProgress } = useScroll(); + + return ( + + ); +}); + +ScrollProgress.displayName = "ScrollProgress"; diff --git a/components/magicui/shine-border.tsx b/components/magicui/shine-border.tsx new file mode 100644 index 0000000..45b1c41 --- /dev/null +++ b/components/magicui/shine-border.tsx @@ -0,0 +1,63 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +interface ShineBorderProps extends React.HTMLAttributes { + /** + * Width of the border in pixels + * @default 1 + */ + borderWidth?: number; + /** + * Duration of the animation in seconds + * @default 14 + */ + duration?: number; + /** + * Color of the border, can be a single color or an array of colors + * @default "#000000" + */ + shineColor?: string | string[]; +} + +/** + * Shine Border + * + * An animated background border effect component with configurable properties. + */ +export function ShineBorder({ + borderWidth = 1, + duration = 14, + shineColor = "#000000", + className, + style, + ...props +}: ShineBorderProps) { + return ( +
+ ); +} diff --git a/components/magicui/text-animate.tsx b/components/magicui/text-animate.tsx new file mode 100644 index 0000000..dc988dd --- /dev/null +++ b/components/magicui/text-animate.tsx @@ -0,0 +1,410 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { AnimatePresence, motion, MotionProps, Variants } from "motion/react"; +import { ElementType, memo } from "react"; + +type AnimationType = "text" | "word" | "character" | "line"; +type AnimationVariant = + | "fadeIn" + | "blurIn" + | "blurInUp" + | "blurInDown" + | "slideUp" + | "slideDown" + | "slideLeft" + | "slideRight" + | "scaleUp" + | "scaleDown"; + +interface TextAnimateProps extends MotionProps { + /** + * The text content to animate + */ + children: string; + /** + * The class name to be applied to the component + */ + className?: string; + /** + * The class name to be applied to each segment + */ + segmentClassName?: string; + /** + * The delay before the animation starts + */ + delay?: number; + /** + * The duration of the animation + */ + duration?: number; + /** + * Custom motion variants for the animation + */ + variants?: Variants; + /** + * The element type to render + */ + as?: ElementType; + /** + * How to split the text ("text", "word", "character") + */ + by?: AnimationType; + /** + * Whether to start animation when component enters viewport + */ + startOnView?: boolean; + /** + * Whether to animate only once + */ + once?: boolean; + /** + * The animation preset to use + */ + animation?: AnimationVariant; +} + +const staggerTimings: Record = { + text: 0.06, + word: 0.05, + character: 0.03, + line: 0.06, +}; + +const defaultContainerVariants = { + hidden: { opacity: 1 }, + show: { + opacity: 1, + transition: { + delayChildren: 0, + staggerChildren: 0.05, + }, + }, + exit: { + opacity: 0, + transition: { + staggerChildren: 0.05, + staggerDirection: -1, + }, + }, +}; + +const defaultItemVariants: Variants = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + }, + exit: { + opacity: 0, + }, +}; + +const defaultItemAnimationVariants: Record< + AnimationVariant, + { container: Variants; item: Variants } +> = { + fadeIn: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, y: 20 }, + show: { + opacity: 1, + y: 0, + transition: { + duration: 0.3, + }, + }, + exit: { + opacity: 0, + y: 20, + transition: { duration: 0.3 }, + }, + }, + }, + blurIn: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, filter: "blur(10px)" }, + show: { + opacity: 1, + filter: "blur(0px)", + transition: { + duration: 0.3, + }, + }, + exit: { + opacity: 0, + filter: "blur(10px)", + transition: { duration: 0.3 }, + }, + }, + }, + blurInUp: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, filter: "blur(10px)", y: 20 }, + show: { + opacity: 1, + filter: "blur(0px)", + y: 0, + transition: { + y: { duration: 0.3 }, + opacity: { duration: 0.4 }, + filter: { duration: 0.3 }, + }, + }, + exit: { + opacity: 0, + filter: "blur(10px)", + y: 20, + transition: { + y: { duration: 0.3 }, + opacity: { duration: 0.4 }, + filter: { duration: 0.3 }, + }, + }, + }, + }, + blurInDown: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, filter: "blur(10px)", y: -20 }, + show: { + opacity: 1, + filter: "blur(0px)", + y: 0, + transition: { + y: { duration: 0.3 }, + opacity: { duration: 0.4 }, + filter: { duration: 0.3 }, + }, + }, + }, + }, + slideUp: { + container: defaultContainerVariants, + item: { + hidden: { y: 20, opacity: 0 }, + show: { + y: 0, + opacity: 1, + transition: { + duration: 0.3, + }, + }, + exit: { + y: -20, + opacity: 0, + transition: { + duration: 0.3, + }, + }, + }, + }, + slideDown: { + container: defaultContainerVariants, + item: { + hidden: { y: -20, opacity: 0 }, + show: { + y: 0, + opacity: 1, + transition: { duration: 0.3 }, + }, + exit: { + y: 20, + opacity: 0, + transition: { duration: 0.3 }, + }, + }, + }, + slideLeft: { + container: defaultContainerVariants, + item: { + hidden: { x: 20, opacity: 0 }, + show: { + x: 0, + opacity: 1, + transition: { duration: 0.3 }, + }, + exit: { + x: -20, + opacity: 0, + transition: { duration: 0.3 }, + }, + }, + }, + slideRight: { + container: defaultContainerVariants, + item: { + hidden: { x: -20, opacity: 0 }, + show: { + x: 0, + opacity: 1, + transition: { duration: 0.3 }, + }, + exit: { + x: 20, + opacity: 0, + transition: { duration: 0.3 }, + }, + }, + }, + scaleUp: { + container: defaultContainerVariants, + item: { + hidden: { scale: 0.5, opacity: 0 }, + show: { + scale: 1, + opacity: 1, + transition: { + duration: 0.3, + scale: { + type: "spring", + damping: 15, + stiffness: 300, + }, + }, + }, + exit: { + scale: 0.5, + opacity: 0, + transition: { duration: 0.3 }, + }, + }, + }, + scaleDown: { + container: defaultContainerVariants, + item: { + hidden: { scale: 1.5, opacity: 0 }, + show: { + scale: 1, + opacity: 1, + transition: { + duration: 0.3, + scale: { + type: "spring", + damping: 15, + stiffness: 300, + }, + }, + }, + exit: { + scale: 1.5, + opacity: 0, + transition: { duration: 0.3 }, + }, + }, + }, +}; + +const TextAnimateBase = ({ + children, + delay = 0, + duration = 0.3, + variants, + className, + segmentClassName, + as: Component = "p", + startOnView = true, + once = false, + by = "word", + animation = "fadeIn", + ...props +}: TextAnimateProps) => { + const MotionComponent = motion.create(Component); + + let segments: string[] = []; + switch (by) { + case "word": + segments = children.split(/(\s+)/); + break; + case "character": + segments = children.split(""); + break; + case "line": + segments = children.split("\n"); + break; + case "text": + default: + segments = [children]; + break; + } + + const finalVariants = variants + ? { + container: { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + opacity: { duration: 0.01, delay }, + delayChildren: delay, + staggerChildren: duration / segments.length, + }, + }, + exit: { + opacity: 0, + transition: { + staggerChildren: duration / segments.length, + staggerDirection: -1, + }, + }, + }, + item: variants, + } + : animation + ? { + container: { + ...defaultItemAnimationVariants[animation].container, + show: { + ...defaultItemAnimationVariants[animation].container.show, + transition: { + delayChildren: delay, + staggerChildren: duration / segments.length, + }, + }, + exit: { + ...defaultItemAnimationVariants[animation].container.exit, + transition: { + staggerChildren: duration / segments.length, + staggerDirection: -1, + }, + }, + }, + item: defaultItemAnimationVariants[animation].item, + } + : { container: defaultContainerVariants, item: defaultItemVariants }; + + return ( + + + {segments.map((segment, i) => ( + + {segment} + + ))} + + + ); +}; + +// Export the memoized version +export const TextAnimate = memo(TextAnimateBase); diff --git a/components/magicui/text-reveal.tsx b/components/magicui/text-reveal.tsx new file mode 100644 index 0000000..31127b6 --- /dev/null +++ b/components/magicui/text-reveal.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { motion, MotionValue, useScroll, useTransform } from "motion/react"; +import { ComponentPropsWithoutRef, FC, ReactNode, useRef } from "react"; + +import { cn } from "@/lib/utils"; + +export interface TextRevealProps extends ComponentPropsWithoutRef<"div"> { + children: string; +} + +export const TextReveal: FC = ({ children, className }) => { + const targetRef = useRef(null); + const { scrollYProgress } = useScroll({ + target: targetRef, + }); + + if (typeof children !== "string") { + throw new Error("TextReveal: children must be a string"); + } + + const words = children.split(" "); + + return ( +
+
+ + {words.map((word, i) => { + const start = i / words.length; + const end = start + 1 / words.length; + return ( + + {word} + + ); + })} + +
+
+ ); +}; + +interface WordProps { + children: ReactNode; + progress: MotionValue; + range: [number, number]; +} + +const Word: FC = ({ children, progress, range }) => { + const opacity = useTransform(progress, range, [0, 1]); + return ( + + {children} + + {children} + + + ); +}; diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000..7afe882 --- /dev/null +++ b/components/theme-provider.tsx @@ -0,0 +1,9 @@ +"use client"; + +import * as React from "react"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import { type ThemeProviderProps } from "next-themes/dist/types"; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} \ No newline at end of file diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..f002e4c --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; \ No newline at end of file diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..9548be5 --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export type InputProps = React.InputHTMLAttributes + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = "Input"; + +export { Input }; \ No newline at end of file diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..240a496 --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client"; + +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"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; \ No newline at end of file diff --git a/components/ui/metric-card.tsx b/components/ui/metric-card.tsx index 8cac8c9..693fd36 100644 --- a/components/ui/metric-card.tsx +++ b/components/ui/metric-card.tsx @@ -102,7 +102,7 @@ export default function MetricCard({ return ( - {icon} + {icon}
)}
@@ -137,7 +137,9 @@ export default function MetricCard({
-

{value ?? "—"}

+

+ {value ?? "—"} +

{trend && ( ; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; \ No newline at end of file diff --git a/components/ui/theme-toggle.tsx b/components/ui/theme-toggle.tsx new file mode 100644 index 0000000..8f77901 --- /dev/null +++ b/components/ui/theme-toggle.tsx @@ -0,0 +1,71 @@ +"use client"; + +import * as React from "react"; +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export function ThemeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ); +} + +export function SimpleThemeToggle() { + const { theme, setTheme } = useTheme(); + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( + + ); + } + + return ( + + ); +} \ No newline at end of file diff --git a/package.json b/package.json index a98be83..e42f992 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,12 @@ "@prisma/adapter-pg": "^6.10.1", "@prisma/client": "^6.10.1", "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.7", "@rapideditor/country-coder": "^5.4.0", + "@types/canvas-confetti": "^1.9.0", "@types/d3": "^7.4.3", "@types/d3-cloud": "^1.2.9", "@types/d3-selection": "^3.0.11", @@ -41,6 +43,7 @@ "@types/leaflet": "^1.9.19", "@types/node-fetch": "^2.6.12", "bcryptjs": "^3.0.2", + "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "csv-parse": "^5.6.0", @@ -51,8 +54,10 @@ "iso-639-1": "^3.1.5", "leaflet": "^1.9.4", "lucide-react": "^0.525.0", + "motion": "^12.19.2", "next": "^15.3.4", "next-auth": "^4.24.11", + "next-themes": "^0.4.6", "node-cron": "^4.1.1", "node-fetch": "^3.3.2", "react": "^19.1.0", @@ -61,6 +66,7 @@ "react-markdown": "^10.1.0", "recharts": "^3.0.2", "rehype-raw": "^7.0.0", + "sonner": "^2.0.5", "tailwind-merge": "^3.3.1", "zod": "^3.25.67" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 032cd89..3392c75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.15 version: 2.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-separator': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -29,6 +32,9 @@ importers: '@rapideditor/country-coder': specifier: ^5.4.0 version: 5.4.0 + '@types/canvas-confetti': + specifier: ^1.9.0 + version: 1.9.0 '@types/d3': specifier: ^7.4.3 version: 7.4.3 @@ -50,6 +56,9 @@ importers: bcryptjs: specifier: ^3.0.2 version: 3.0.2 + canvas-confetti: + specifier: ^1.9.3 + version: 1.9.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -80,12 +89,18 @@ importers: lucide-react: specifier: ^0.525.0 version: 0.525.0(react@19.1.0) + motion: + specifier: ^12.19.2 + version: 12.19.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: specifier: ^15.3.4 version: 15.3.4(@babel/core@7.27.7)(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-auth: specifier: ^4.24.11 version: 4.24.11(next@15.3.4(@babel/core@7.27.7)(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) node-cron: specifier: ^4.1.1 version: 4.1.1 @@ -110,6 +125,9 @@ importers: rehype-raw: specifier: ^7.0.0 version: 7.0.0 + sonner: + specifier: ^2.0.5 + version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -968,6 +986,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + 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 + '@radix-ui/react-menu@2.1.15': resolution: {integrity: sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==} peerDependencies: @@ -1444,6 +1475,9 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/canvas-confetti@1.9.0': + resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -1980,6 +2014,9 @@ packages: caniuse-lite@1.0.30001726: resolution: {integrity: sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==} + canvas-confetti@1.9.3: + resolution: {integrity: sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2613,6 +2650,20 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + framer-motion@12.19.2: + resolution: {integrity: sha512-0cWMLkYr+i0emeXC4hkLF+5aYpzo32nRdQ0D/5DI460B3O7biQ3l2BpDzIGsAHYuZ0fpBP0DC8XBkVf6RPAlZw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3335,6 +3386,26 @@ packages: engines: {node: '>=10'} hasBin: true + motion-dom@12.19.0: + resolution: {integrity: sha512-m96uqq8VbwxFLU0mtmlsIVe8NGGSdpBvBSHbnnOJQxniPaabvVdGgxSamhuDwBsRhwX7xPxdICgVJlOpzn/5bw==} + + motion-utils@12.19.0: + resolution: {integrity: sha512-BuFTHINYmV07pdWs6lj6aI63vr2N4dg0vR+td0rtrdpWOhBzIkEklZyLcvKBoEtwSqx8Jg06vUB5RS0xDiUybw==} + + motion@12.19.2: + resolution: {integrity: sha512-Yb69HXE4ryhVd1xwpgWMMQAQgqEGMSGWG+NOumans2fvSCtT8gsj8JK7jhcGnc410CLT3BFPgquP67zmjbA5Jw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3365,6 +3436,12 @@ packages: nodemailer: optional: true + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@15.3.4: resolution: {integrity: sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -3905,6 +3982,12 @@ packages: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} + sonner@2.0.5: + resolution: {integrity: sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5098,6 +5181,15 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-menu@2.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -5512,6 +5604,8 @@ snapshots: dependencies: '@babel/types': 7.27.7 + '@types/canvas-confetti@1.9.0': {} + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 @@ -6111,6 +6205,8 @@ snapshots: caniuse-lite@1.0.30001726: {} + canvas-confetti@1.9.3: {} + ccount@2.0.1: {} chai@5.2.0: @@ -6910,6 +7006,15 @@ snapshots: dependencies: fetch-blob: 3.2.0 + framer-motion@12.19.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + motion-dom: 12.19.0 + motion-utils: 12.19.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + fsevents@2.3.2: optional: true @@ -7852,6 +7957,20 @@ snapshots: mkdirp@3.0.1: {} + motion-dom@12.19.0: + dependencies: + motion-utils: 12.19.0 + + motion-utils@12.19.0: {} + + motion@12.19.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + framer-motion: 12.19.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + ms@2.1.3: {} nanoid@3.3.11: {} @@ -7875,6 +7994,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) uuid: 8.3.2 + next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + next@15.3.4(@babel/core@7.27.7)(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.3.4 @@ -8495,6 +8619,11 @@ snapshots: slash@5.1.0: {} + sonner@2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts index 8641cb5..3280d0a 100644 --- a/tests-examples/demo-todo-app.spec.ts +++ b/tests-examples/demo-todo-app.spec.ts @@ -56,7 +56,7 @@ test.describe('New Todo', () => { // create a todo count locator const todoCount = page.getByTestId('todo-count') - + // Check test using different methods. await expect(page.getByText('3 items left')).toBeVisible(); await expect(todoCount).toHaveText('3 items left'); @@ -260,7 +260,7 @@ test.describe('Counter', () => { test('should display the current number of todo items', async ({ page }) => { // create a new todo locator const newTodo = page.getByPlaceholder('What needs to be done?'); - + // create a todo count locator const todoCount = page.getByTestId('todo-count') @@ -350,7 +350,7 @@ test.describe('Routing', () => { }); test('should respect the back button', async ({ page }) => { - const todoItem = page.getByTestId('todo-item'); + const todoItem = page.getByTestId('todo-item'); await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); await checkNumberOfCompletedTodosInLocalStorage(page, 1); @@ -393,7 +393,7 @@ test.describe('Routing', () => { test('should highlight the currently applied filter', async ({ page }) => { await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); - + //create locators for active and completed links const activeLink = page.getByRole('link', { name: 'Active' }); const completedLink = page.getByRole('link', { name: 'Completed' });