From 0c5791cce322448238370c86f8a7cd34a2ef0058 Mon Sep 17 00:00:00 2001 From: Zen0space Date: Wed, 8 Apr 2026 02:54:04 +0800 Subject: [PATCH 1/3] feat(landing): add public monthly request stats to hero section - Add publicMonthlyStats public tRPC procedure to request-logs router - Query successful requests across all users for the current month - Cache result for 1 hour (CacheTTL.CATEGORIES) - Add trpcPublicMonthlyStats cache key - Display 'Requests This Month' stat card in Hero with TypewriterNumber - Remove 'Free API Access' and '<50ms Response Time' stat cards --- packages/kal-backend/src/lib/cache-keys.ts | 5 ++ .../kal-backend/src/routers/request-logs.ts | 25 ++++++++- .../src/components/landing/Hero.tsx | 53 ++++++++++--------- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/packages/kal-backend/src/lib/cache-keys.ts b/packages/kal-backend/src/lib/cache-keys.ts index 8afc88f..be16f38 100644 --- a/packages/kal-backend/src/lib/cache-keys.ts +++ b/packages/kal-backend/src/lib/cache-keys.ts @@ -73,6 +73,11 @@ export const CacheKeys = { trpcFoodStats: (): string => `${PREFIX}:trpc:food:stats`, + // ============================================ + // tRPC - Request Logs (public) + // ============================================ + trpcPublicMonthlyStats: (): string => `${PREFIX}:trpc:logs:public-monthly`, + trpcFoodAllPaginated: ( cursor: number, limit: number, diff --git a/packages/kal-backend/src/routers/request-logs.ts b/packages/kal-backend/src/routers/request-logs.ts index 6980b6a..0b1a457 100644 --- a/packages/kal-backend/src/routers/request-logs.ts +++ b/packages/kal-backend/src/routers/request-logs.ts @@ -9,7 +9,9 @@ import { z } from "zod"; import { RequestLogService } from "../lib/request-log-service.js"; -import { protectedProcedure, router } from "../lib/trpc.js"; +import { CacheKeys, CacheTTL } from "../lib/cache-keys.js"; +import { cache } from "../lib/cache.js"; +import { protectedProcedure, publicProcedure, router } from "../lib/trpc.js"; export const requestLogsRouter = router({ /** @@ -133,4 +135,25 @@ export const requestLogsRouter = router({ }, }; }), + + /** + * Get total successful requests this month across all users (public, cached 1hr) + */ + publicMonthlyStats: publicProcedure.query(async ({ ctx }) => { + const cacheKey = CacheKeys.trpcPublicMonthlyStats(); + + return cache.wrap(cacheKey, CacheTTL.CATEGORIES, async () => { + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + + const successfulRequests = await ctx.db + .collection("api_request_logs") + .countDocuments({ + success: true, + timestamp: { $gte: monthStart }, + }); + + return { successfulRequests }; + }); + }), }); diff --git a/packages/kal-frontend/src/components/landing/Hero.tsx b/packages/kal-frontend/src/components/landing/Hero.tsx index db9d253..83442ad 100644 --- a/packages/kal-frontend/src/components/landing/Hero.tsx +++ b/packages/kal-frontend/src/components/landing/Hero.tsx @@ -7,6 +7,7 @@ import { trpc } from "@/lib/trpc"; export function Hero() { const { data: stats } = trpc.food.stats.useQuery(); + const { data: logStats } = trpc.requestLogs.publicMonthlyStats.useQuery(); return (
@@ -27,17 +28,27 @@ export function Hero() { {/* Subheadline */}

- Access comprehensive nutritional data for Malaysian foods. - Build health apps, track calories, or integrate food data into your projects - with our free REST API. + Access comprehensive nutritional data for Malaysian foods. Build + health apps, track calories, or integrate food data into your + projects with our free REST API.

{/* CTA Buttons */}
{/* Stats */} -
+
{/* Foods Count */}
@@ -56,9 +67,7 @@ export function Hero() { ... )}
-
- Foods -
+
Foods
{/* Halal Foods Count */} @@ -70,28 +79,20 @@ export function Hero() { ... )}
-
- Halal Certified -
-
- - {/* Free */} -
-
- Free -
-
- API Access -
+
Halal Certified
- {/* Response Time */} + {/* Requests This Month */}
-
- <50ms +
+ {logStats?.successfulRequests != null ? ( + + ) : ( + ... + )}
- Response Time + Requests This Month
From 1314c0493dfb66c048444406aae48278b257a7f7 Mon Sep 17 00:00:00 2001 From: Zen0space Date: Wed, 8 Apr 2026 11:20:39 +0800 Subject: [PATCH 2/3] feat(landing): glass navbar on scroll + remove open source badge - Rebuild Navbar with jotai (scrolledAtom) instead of useEffect/window.scrollY - Add ScrollProvider wrapping the layout scroll container to detect actual scroll - Fix: window.scrollY was always 0 because layout uses overflow-y-auto div - Glass effect: bg-dark-elevated/90 backdrop-blur-xl on scroll - Remove 'Open Source API' badge from Hero section --- packages/kal-frontend/src/app/layout.tsx | 3 +- packages/kal-frontend/src/atoms/scroll.ts | 4 + .../src/components/ScrollProvider.tsx | 28 +++ .../src/components/landing/Hero.tsx | 192 ++++++++++++++---- .../src/components/landing/Navbar.tsx | 11 +- 5 files changed, 192 insertions(+), 46 deletions(-) create mode 100644 packages/kal-frontend/src/atoms/scroll.ts create mode 100644 packages/kal-frontend/src/components/ScrollProvider.tsx diff --git a/packages/kal-frontend/src/app/layout.tsx b/packages/kal-frontend/src/app/layout.tsx index f9e4e54..7cdc335 100644 --- a/packages/kal-frontend/src/app/layout.tsx +++ b/packages/kal-frontend/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Inter } from "next/font/google"; import "./globals.css"; import { ChatWidget } from "@/components/chat/ChatWidget"; +import { ScrollProvider } from "@/components/ScrollProvider"; import { ToastContainer } from "@/components/ui/Toast"; import { ToastProvider } from "@/contexts/ToastContext"; import { AuthProvider } from "@/lib/auth-context"; @@ -88,7 +89,7 @@ export default function RootLayout({
{/* Main content — scrollable, takes remaining space */} -
{children}
+ {children} {/* Right side: activity bar + chat panel (auth-gated internally) */}
diff --git a/packages/kal-frontend/src/atoms/scroll.ts b/packages/kal-frontend/src/atoms/scroll.ts new file mode 100644 index 0000000..4039610 --- /dev/null +++ b/packages/kal-frontend/src/atoms/scroll.ts @@ -0,0 +1,4 @@ +import { atom } from "jotai"; + +/** Whether the main scroll container has scrolled past the threshold */ +export const scrolledAtom = atom(false); diff --git a/packages/kal-frontend/src/components/ScrollProvider.tsx b/packages/kal-frontend/src/components/ScrollProvider.tsx new file mode 100644 index 0000000..602594c --- /dev/null +++ b/packages/kal-frontend/src/components/ScrollProvider.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useSetAtom } from "jotai"; +import { useCallback, useRef } from "react"; + +import { scrolledAtom } from "@/atoms/scroll"; + +export function ScrollProvider({ children }: { children: React.ReactNode }) { + const setScrolled = useSetAtom(scrolledAtom); + const lastScrolled = useRef(false); + + const handleScroll = useCallback( + (e: React.UIEvent) => { + const isScrolled = e.currentTarget.scrollTop > 20; + if (isScrolled !== lastScrolled.current) { + lastScrolled.current = isScrolled; + setScrolled(isScrolled); + } + }, + [setScrolled] + ); + + return ( +
+ {children} +
+ ); +} diff --git a/packages/kal-frontend/src/components/landing/Hero.tsx b/packages/kal-frontend/src/components/landing/Hero.tsx index 83442ad..5304473 100644 --- a/packages/kal-frontend/src/components/landing/Hero.tsx +++ b/packages/kal-frontend/src/components/landing/Hero.tsx @@ -1,40 +1,75 @@ "use client"; +import { motion } from "framer-motion"; + import { Button } from "@/components/ui/Button"; import { Container } from "@/components/ui/Container"; -import { TypewriterNumber } from "@/components/ui/TypewriterNumber"; +import { CountUp } from "@/components/ui/CountUp"; import { trpc } from "@/lib/trpc"; +const fadeUp = { + hidden: { opacity: 0, y: 20 }, + visible: (delay: number) => ({ + opacity: 1, + y: 0, + transition: { duration: 0.6, delay, ease: [0.25, 0.1, 0.25, 1] }, + }), +}; + export function Hero() { const { data: stats } = trpc.food.stats.useQuery(); const { data: logStats } = trpc.requestLogs.publicMonthlyStats.useQuery(); return ( -
+
-
+
{/* Logo */} -
+
Kal -
+
- {/* Headline */} -

+ {/* Headline — gradient text */} + Malaysian Food Nutrition API -

+ {/* Subheadline */} -

+ Access comprehensive nutritional data for Malaysian foods. Build health apps, track calories, or integrate food data into your projects with our free REST API. -

+ {/* CTA Buttons */} -
+ -
+ - {/* Stats */} -
- {/* Foods Count */} -
-
- {stats?.total ? ( - - ) : ( - ... - )} + {/* Code Preview */} + +
+ {/* Window chrome */} +
+
+
+
+ + Terminal + +
+ {/* Code content */} +
+
+ ${" "} + curl{" "} + + https://api.kal.my/api/v1/foods/search + {" "} + \ +
+
+ -H{" "} + + "x-api-key: your_key" + {" "} + \ +
+
+ -d{" "} + + '{`{"query": "nasi lemak"}`}' + +
+
+ {"// "} + 200 OK + + {" — "} + calories, protein, carbs, fat & more + +
-
Foods
+ - {/* Halal Foods Count */} -
-
- {stats?.halal ? ( - - ) : ( - ... - )} + {/* Stats */} + +
+ {/* Foods Count */} +
+
+ {stats?.total ? ( + + ) : ( + + ... + + )} +
+
Foods
-
Halal Certified
-
- {/* Requests This Month */} -
-
- {logStats?.successfulRequests != null ? ( - - ) : ( - ... - )} + {/* Halal Foods Count */} +
+
+ {stats?.halal ? ( + + ) : ( + + ... + + )} +
+
+ Halal Certified +
-
- Requests This Month + + {/* Requests This Month */} +
+
+ {logStats?.successfulRequests != null ? ( + + ) : ( + + ... + + )} +
+
+ Requests This Month +
-
+
diff --git a/packages/kal-frontend/src/components/landing/Navbar.tsx b/packages/kal-frontend/src/components/landing/Navbar.tsx index 7293706..0618667 100644 --- a/packages/kal-frontend/src/components/landing/Navbar.tsx +++ b/packages/kal-frontend/src/components/landing/Navbar.tsx @@ -1,9 +1,11 @@ "use client"; +import { useAtomValue } from "jotai"; import Link from "next/link"; import { useState } from "react"; import { Menu, X } from "react-feather"; +import { scrolledAtom } from "@/atoms/scroll"; import { Button } from "@/components/ui/Button"; import { Container } from "@/components/ui/Container"; @@ -21,9 +23,16 @@ interface NavbarProps { export function Navbar({ onSignIn }: NavbarProps) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const scrolled = useAtomValue(scrolledAtom); return ( -
); diff --git a/packages/kal-frontend/src/components/landing/Features.tsx b/packages/kal-frontend/src/components/landing/Features.tsx index d94f0f5..70f533a 100644 --- a/packages/kal-frontend/src/components/landing/Features.tsx +++ b/packages/kal-frontend/src/components/landing/Features.tsx @@ -2,6 +2,11 @@ import { Check, Code, Database, Globe, Lock, Zap } from "react-feather"; +import { + AnimateIn, + StaggerContainer, + StaggerChild, +} from "@/components/ui/AnimateIn"; import { Container } from "@/components/ui/Container"; import { SectionHeading } from "@/components/ui/SectionHeading"; @@ -9,32 +14,43 @@ const features = [ { icon: , title: "Fast REST API", - description: "Lightning-fast responses with optimized endpoints for search and filtering", + description: + "Lightning-fast responses with optimized endpoints for search and filtering", + span: "md:col-span-2", }, { icon: , title: "Rich Food Data", - description: "Access 100+ Malaysian foods with complete macro nutritional information", + description: + "Access 100+ Malaysian foods with complete macro nutritional information", + span: "", }, { icon: , title: "Halal Certified", - description: "JAKIM certified foods with verified brand and certification details", + description: + "JAKIM certified foods with verified brand and certification details", + span: "", }, { icon: , title: "Simple Integration", - description: "Clean JSON responses that work with any language or framework", + description: + "Clean JSON responses that work with any language or framework", + span: "md:col-span-2", }, { icon: , title: "Free API Keys", description: "Get your API key instantly with generous rate limits", + span: "", }, { icon: , title: "Production Ready", - description: "Reliable infrastructure built for your production applications", + description: + "Reliable infrastructure built for your production applications", + span: "", }, ]; @@ -42,32 +58,36 @@ export function Features() { return (
- + + + -
+ {features.map((feature, index) => ( -
- {/* Icon */} -
- {feature.icon} -
+ +
+ {/* Icon */} +
+ {feature.icon} +
- {/* Content */} -

- {feature.title} -

-

- {feature.description} -

-
+ {/* Content */} +

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} -
+
); diff --git a/packages/kal-frontend/src/components/landing/Footer.tsx b/packages/kal-frontend/src/components/landing/Footer.tsx index c370a59..3237486 100644 --- a/packages/kal-frontend/src/components/landing/Footer.tsx +++ b/packages/kal-frontend/src/components/landing/Footer.tsx @@ -1,39 +1,46 @@ +"use client"; + import Link from "next/link"; +import { AnimateIn } from "@/components/ui/AnimateIn"; import { Container } from "@/components/ui/Container"; export function Footer() { return (
-
- {/* Logo */} - -
- Kal - - - {/* Legal Links */} -
- - Privacy Policy - - - Terms of Service + +
+ {/* Logo */} + +
+ + Kal + -
- {/* Copyright */} -

- © {new Date().getFullYear()} Kal. All rights reserved. -

-
+ {/* Legal Links */} +
+ + Privacy Policy + + + Terms of Service + +
+ + {/* Copyright */} +

+ © {new Date().getFullYear()} Kal. All rights reserved. +

+
+
); diff --git a/packages/kal-frontend/src/components/landing/HowItWorks.tsx b/packages/kal-frontend/src/components/landing/HowItWorks.tsx index bf48b91..30f0a35 100644 --- a/packages/kal-frontend/src/components/landing/HowItWorks.tsx +++ b/packages/kal-frontend/src/components/landing/HowItWorks.tsx @@ -2,6 +2,11 @@ import { Code, Key, Zap } from "react-feather"; +import { + AnimateIn, + StaggerContainer, + StaggerChild, +} from "@/components/ui/AnimateIn"; import { Container } from "@/components/ui/Container"; import { SectionHeading } from "@/components/ui/SectionHeading"; @@ -10,7 +15,7 @@ const steps = [ number: "1", icon: , title: "Get Your API Key", - description: "Sign in to your dashboard and generate a free API key", + description: "Sign in to your dashboard and generate your API key", }, { number: "2", @@ -30,39 +35,44 @@ export function HowItWorks() { return (
- + + + -
+ {steps.map((step, index) => ( -
- {/* Step number */} -
-
- {step.icon} + +
+ {/* Step icon */} +
+
+ {step.icon} +
+ + {step.number} +
- - {step.number} - -
- {/* Content */} -

- {step.title} -

-

- {step.description} -

+ {/* Content */} +

+ {step.title} +

+

{step.description}

- {/* Connector line (not on last) */} - {index < steps.length - 1 && ( -
- )} -
+ {/* Connector line (not on last) */} + {index < steps.length - 1 && ( +
+ )} +
+
))} -
+
); diff --git a/packages/kal-frontend/src/components/landing/ProblemSolution.tsx b/packages/kal-frontend/src/components/landing/ProblemSolution.tsx index 769b14d..546df75 100644 --- a/packages/kal-frontend/src/components/landing/ProblemSolution.tsx +++ b/packages/kal-frontend/src/components/landing/ProblemSolution.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { AnimateIn } from "@/components/ui/AnimateIn"; import { Container } from "@/components/ui/Container"; import { SectionHeading } from "@/components/ui/SectionHeading"; @@ -19,45 +22,51 @@ export function ProblemSolution() { return (
- + + +
{/* Problems */} -
-

- The Problem -

-
    - {problems.map((problem, index) => ( -
  • - - ✕ - - {problem} -
  • - ))} -
-
+ +
+

+ The Problem +

+
    + {problems.map((problem, index) => ( +
  • + + ✕ + + {problem} +
  • + ))} +
+
+
{/* Solutions */} -
-

- The Solution -

-
    - {solutions.map((solution, index) => ( -
  • - - ✓ - - {solution} -
  • - ))} -
-
+ +
+

+ The Solution +

+
    + {solutions.map((solution, index) => ( +
  • + + ✓ + + {solution} +
  • + ))} +
+
+
diff --git a/packages/kal-frontend/src/components/landing/SampleFoods.tsx b/packages/kal-frontend/src/components/landing/SampleFoods.tsx index 5b0948c..ea83537 100644 --- a/packages/kal-frontend/src/components/landing/SampleFoods.tsx +++ b/packages/kal-frontend/src/components/landing/SampleFoods.tsx @@ -1,63 +1,128 @@ +"use client"; + +import { + AnimateIn, + StaggerContainer, + StaggerChild, +} from "@/components/ui/AnimateIn"; import { Button } from "@/components/ui/Button"; import { Container } from "@/components/ui/Container"; import { SectionHeading } from "@/components/ui/SectionHeading"; const sampleFoods = [ - { name: "Grilled Chicken", calories: 165, protein: 31, carbs: 0, fat: 3.6, serving: "100g" }, - { name: "White Rice", calories: 130, protein: 2.7, carbs: 28, fat: 0.3, serving: "100g" }, - { name: "Salmon Fillet", calories: 208, protein: 20, carbs: 0, fat: 13, serving: "100g" }, - { name: "Greek Yogurt", calories: 100, protein: 17, carbs: 6, fat: 0.7, serving: "1 cup" }, - { name: "Banana", calories: 89, protein: 1.1, carbs: 23, fat: 0.3, serving: "1 medium" }, - { name: "Avocado", calories: 160, protein: 2, carbs: 9, fat: 15, serving: "1/2 fruit" }, + { + name: "Grilled Chicken", + calories: 165, + protein: 31, + carbs: 0, + fat: 3.6, + serving: "100g", + }, + { + name: "White Rice", + calories: 130, + protein: 2.7, + carbs: 28, + fat: 0.3, + serving: "100g", + }, + { + name: "Salmon Fillet", + calories: 208, + protein: 20, + carbs: 0, + fat: 13, + serving: "100g", + }, + { + name: "Greek Yogurt", + calories: 100, + protein: 17, + carbs: 6, + fat: 0.7, + serving: "1 cup", + }, + { + name: "Banana", + calories: 89, + protein: 1.1, + carbs: 23, + fat: 0.3, + serving: "1 medium", + }, + { + name: "Avocado", + calories: 160, + protein: 2, + carbs: 9, + fat: 15, + serving: "1/2 fruit", + }, ]; export function SampleFoods() { return (
- + + + -
+ {sampleFoods.map((food, index) => ( -
-
-

{food.name}

- - {food.calories} cal - -
-

Per {food.serving}

- - {/* Macro breakdown */} -
-
-
- P: {food.protein}g + +
+
+

+ {food.name} +

+ + {food.calories} cal +
-
-
- C: {food.carbs}g -
-
-
- F: {food.fat}g +

+ Per {food.serving} +

+ + {/* Macro breakdown */} +
+
+
+ + P: {food.protein}g + +
+
+
+ + C: {food.carbs}g + +
+
+
+ + F: {food.fat}g + +
-
+ ))} -
+ -
- -
+ +
+ +
+
); diff --git a/packages/kal-frontend/src/components/landing/Testimonials.tsx b/packages/kal-frontend/src/components/landing/Testimonials.tsx index 4a7a661..77ea460 100644 --- a/packages/kal-frontend/src/components/landing/Testimonials.tsx +++ b/packages/kal-frontend/src/components/landing/Testimonials.tsx @@ -1,62 +1,110 @@ +"use client"; + +import { + AnimateIn, + StaggerContainer, + StaggerChild, +} from "@/components/ui/AnimateIn"; import { Container } from "@/components/ui/Container"; import { SectionHeading } from "@/components/ui/SectionHeading"; const testimonials = [ { - quote: "Finally, a calorie tracker that doesn't require a PhD to use. Just search and get the info you need.", + quote: + "Finally, a calorie tracker that doesn't require a PhD to use. Just search and get the info you need.", author: "Sarah M.", role: "Health enthusiast", + stars: 5, }, { - quote: "I use this every day to check my meals. Simple, fast, and accurate. Exactly what I needed.", + quote: + "I use this every day to check my meals. Simple, fast, and accurate. Exactly what I needed.", author: "James T.", role: "Fitness coach", + stars: 5, }, { - quote: "The fastest way to check what's in my food. No sign-ups, no hassle — just works.", + quote: + "The fastest way to check what's in my food. No sign-ups, no hassle — just works.", author: "Lisa K.", role: "Home cook", + stars: 5, }, ]; +function StarRating({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + + + ))} +
+ ); +} + export function Testimonials() { return (
- + + + -
+ {testimonials.map((testimonial, index) => ( -
- {/* Quote */} -
- - - -

- “{testimonial.quote}” -

-
+ +
+ {/* Top accent gradient line */} +
- {/* Author */} -
-
- {testimonial.author.charAt(0)} + {/* Stars */} + + + {/* Quote */} +
+ + + +

+ “{testimonial.quote}” +

-
-

{testimonial.author}

-

{testimonial.role}

+ + {/* Author */} +
+
+ {testimonial.author.charAt(0)} +
+
+

+ {testimonial.author} +

+

+ {testimonial.role} +

+
-
+ ))} -
+
); diff --git a/packages/kal-frontend/src/components/ui/AnimateIn.tsx b/packages/kal-frontend/src/components/ui/AnimateIn.tsx new file mode 100644 index 0000000..846d451 --- /dev/null +++ b/packages/kal-frontend/src/components/ui/AnimateIn.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { motion, type Variants } from "framer-motion"; +import { type ReactNode } from "react"; + +interface AnimateInProps { + children: ReactNode; + delay?: number; + direction?: "up" | "left" | "right" | "none"; + duration?: number; + className?: string; + once?: boolean; +} + +const directionOffsets = { + up: { x: 0, y: 30 }, + left: { x: -30, y: 0 }, + right: { x: 30, y: 0 }, + none: { x: 0, y: 0 }, +} as const; + +export function AnimateIn({ + children, + delay = 0, + direction = "up", + duration = 0.6, + className, + once = true, +}: AnimateInProps) { + const offset = directionOffsets[direction]; + + return ( + + {children} + + ); +} + +/** + * Stagger container — wrap children in this, then use AnimateInChild for each child + */ +const staggerContainerVariants: Variants = { + hidden: {}, + visible: { + transition: { + staggerChildren: 0.1, + }, + }, +}; + +const staggerChildVariants: Variants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }, + }, +}; + +interface StaggerContainerProps { + children: ReactNode; + className?: string; + staggerDelay?: number; + once?: boolean; +} + +export function StaggerContainer({ + children, + className, + staggerDelay = 0.1, + once = true, +}: StaggerContainerProps) { + return ( + + {children} + + ); +} + +export function StaggerChild({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/packages/kal-frontend/src/components/ui/CountUp.tsx b/packages/kal-frontend/src/components/ui/CountUp.tsx new file mode 100644 index 0000000..112d51e --- /dev/null +++ b/packages/kal-frontend/src/components/ui/CountUp.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + useMotionValue, + useSpring, + useInView, + type SpringOptions, +} from "framer-motion"; + +interface CountUpProps { + value: number; + duration?: number; + className?: string; + formatOptions?: Intl.NumberFormatOptions; +} + +const springConfig: SpringOptions = { + stiffness: 50, + damping: 30, + restDelta: 0.5, +}; + +export function CountUp({ + value, + className = "", + formatOptions, +}: CountUpProps) { + const ref = useRef(null); + const motionValue = useMotionValue(0); + const springValue = useSpring(motionValue, springConfig); + const isInView = useInView(ref, { once: true, amount: 0.5 }); + const [display, setDisplay] = useState("0"); + + useEffect(() => { + if (isInView && value > 0) { + motionValue.set(value); + } + }, [isInView, value, motionValue]); + + useEffect(() => { + const unsubscribe = springValue.on("change", (latest) => { + setDisplay(Math.round(latest).toLocaleString("en-US", formatOptions)); + }); + return unsubscribe; + }, [springValue, formatOptions]); + + return ( + + {display} + + ); +} diff --git a/packages/kal-frontend/tailwind.config.ts b/packages/kal-frontend/tailwind.config.ts index 10674d5..917fb45 100644 --- a/packages/kal-frontend/tailwind.config.ts +++ b/packages/kal-frontend/tailwind.config.ts @@ -31,6 +31,7 @@ const config: Config = { }, fontFamily: { sans: ["Inter", "system-ui", "sans-serif"], + mono: ["JetBrains Mono", "Fira Code", "monospace"], }, animation: { "fade-in": "fadeIn 0.5s ease-out", @@ -38,6 +39,9 @@ const config: Config = { "panel-slide-in": "panelSlideIn 0.25s ease-out", "neon-pulse": "neonPulse 2s ease-in-out infinite", "tooltip-in": "tooltipIn 0.15s ease-out", + "float-slow": "floatSlow 20s ease-in-out infinite", + "float-slower": "floatSlower 25s ease-in-out infinite", + "gradient-border": "gradientBorder 3s linear infinite", }, keyframes: { fadeIn: { @@ -66,6 +70,21 @@ const config: Config = { "0%": { opacity: "0", transform: "translateX(-4px)" }, "100%": { opacity: "1", transform: "translateX(0)" }, }, + floatSlow: { + "0%, 100%": { transform: "translate(0, 0) scale(1)" }, + "33%": { transform: "translate(30px, -20px) scale(1.05)" }, + "66%": { transform: "translate(-20px, 15px) scale(0.95)" }, + }, + floatSlower: { + "0%, 100%": { transform: "translate(0, 0) scale(1)" }, + "33%": { transform: "translate(-25px, 20px) scale(1.03)" }, + "66%": { transform: "translate(20px, -15px) scale(0.97)" }, + }, + gradientBorder: { + "0%": { backgroundPosition: "0% 50%" }, + "50%": { backgroundPosition: "100% 50%" }, + "100%": { backgroundPosition: "0% 50%" }, + }, }, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdb26eb..a8c9c63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,6 +249,9 @@ importers: '@trpc/react-query': specifier: ^11.0.0-rc.682 version: 11.8.1(@tanstack/react-query@5.90.12(react@19.2.3))(@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.8.1(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + framer-motion: + specifier: ^11.18.0 + version: 11.18.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) jotai: specifier: ^2.19.0 version: 2.19.0(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.7)(react@19.2.3) @@ -1904,6 +1907,20 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@11.18.2: + resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==} + 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 + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -2526,6 +2543,12 @@ packages: socks: optional: true + motion-dom@11.18.1: + resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} + + motion-utils@11.18.1: + resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -5094,6 +5117,15 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@11.18.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + motion-dom: 11.18.1 + motion-utils: 11.18.1 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + fresh@0.5.2: {} fsevents@2.3.3: @@ -5892,6 +5924,12 @@ snapshots: bson: 6.10.4 mongodb-connection-string-url: 3.0.2 + motion-dom@11.18.1: + dependencies: + motion-utils: 11.18.1 + + motion-utils@11.18.1: {} + ms@2.0.0: {} ms@2.1.3: {}