diff --git a/public/images/features/live-occupancy-dark.webp b/public/images/features/live-occupancy-dark.webp deleted file mode 100644 index 60581e3..0000000 Binary files a/public/images/features/live-occupancy-dark.webp and /dev/null differ diff --git a/public/images/features/live-occupancy.webp b/public/images/features/live-occupancy.webp deleted file mode 100644 index 8734931..0000000 Binary files a/public/images/features/live-occupancy.webp and /dev/null differ diff --git a/src/components/BentoGrid.tsx b/src/components/BentoGrid.tsx index 05daf74..443db28 100644 --- a/src/components/BentoGrid.tsx +++ b/src/components/BentoGrid.tsx @@ -1,11 +1,13 @@ +import { type ReactNode } from "react"; import { motion } from "framer-motion"; import styles from "./BentoGrid.module.css"; export interface BentoItem { title: string; description: string; - image: string; + image?: string; imageDark?: string; + component?: ReactNode; size: "small" | "medium" | "large" | "wide" | "full"; theme?: "gold" | "night" | "ios26" | "spectrum"; } @@ -61,8 +63,14 @@ export default function BentoGrid({ items }: BentoGridProps) {

{item.description}

- {item.title} - {item.imageDark && {item.title}} + {item.component ? ( + item.component + ) : item.image ? ( + <> + {item.title} + {item.imageDark && {item.title}} + + ) : null}
); diff --git a/src/components/OccupancyCard.module.css b/src/components/OccupancyCard.module.css new file mode 100644 index 0000000..11eabc7 --- /dev/null +++ b/src/components/OccupancyCard.module.css @@ -0,0 +1,92 @@ +.wrapper { + display: flex; + align-items: flex-end; + justify-content: center; + width: 100%; + height: 100%; + padding: var(--space-md); + padding-top: 0; + overflow: hidden; +} + +.card { + background: #faf9f7; + border-radius: var(--radius-xl); + padding: var(--space-xl); + padding-bottom: var(--space-2xl); + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.08); + transform: translateY(20%); +} + +@media (prefers-color-scheme: dark) { + .card { + background: #2a2a2a; + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3); + } +} + +.gaugeContainer { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.gauge { + display: block; +} + +.track { + color: #d4ccc4; +} + +.fill { + color: #a89080; +} + +@media (prefers-color-scheme: dark) { + .track { + color: #4a4540; + } + + .fill { + color: #d4bfa8; + } +} + +.valueContainer { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} + +.percentage { + font-size: 2rem; + font-weight: 700; + line-height: 1; + color: #2a2a2a; + font-variant-numeric: tabular-nums; +} + +@media (prefers-color-scheme: dark) { + .percentage { + color: #f5f5f5; + } +} + +.label { + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.08em; + color: #b0a090; + margin-top: 0.25em; +} + +@media (prefers-color-scheme: dark) { + .label { + color: #908880; + } +} diff --git a/src/components/OccupancyCard.tsx b/src/components/OccupancyCard.tsx new file mode 100644 index 0000000..5d7c177 --- /dev/null +++ b/src/components/OccupancyCard.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import { motion, useSpring, useTransform, useMotionValueEvent } from "framer-motion"; +import styles from "./OccupancyCard.module.css"; + +interface OccupancyCardProps { + className?: string; +} + +const occupancyStates = [ + { value: 23, label: "QUIET" }, + { value: 45, label: "CALM" }, + { value: 67, label: "BUSY" }, + { value: 82, label: "CROWDED" }, + { value: 34, label: "CALM" }, + { value: 91, label: "FULL" }, + { value: 56, label: "BUSY" }, + { value: 12, label: "QUIET" }, +]; + +export default function OccupancyCard({ className }: OccupancyCardProps) { + const [stateIndex, setStateIndex] = useState(0); + const [displayNumber, setDisplayNumber] = useState(occupancyStates[0].value); + const currentState = occupancyStates[stateIndex]; + + // Spring animation for smooth value transitions + const springValue = useSpring(currentState.value, { + stiffness: 50, + damping: 20, + }); + + // Update display number when spring value changes + useMotionValueEvent(springValue, "change", (latest) => { + setDisplayNumber(Math.round(latest)); + }); + + // Cycle through states + useEffect(() => { + const interval = setInterval(() => { + setStateIndex((prev) => (prev + 1) % occupancyStates.length); + }, 3000); + + return () => clearInterval(interval); + }, []); + + // Update spring when state changes + useEffect(() => { + springValue.set(currentState.value); + }, [currentState.value, springValue]); + + // SVG circle calculations + const size = 180; + const strokeWidth = 18; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + + // Transform spring value to stroke offset + const strokeDashoffset = useTransform(springValue, (v) => circumference - (v / 100) * circumference); + + return ( +
+
+
+ + {/* Background track */} + + {/* Animated fill */} + + +
+ {displayNumber}% + + {currentState.label} + +
+
+
+
+ ); +} diff --git a/src/pages/home/sections/Features.tsx b/src/pages/home/sections/Features.tsx index 4c07446..6ddc545 100644 --- a/src/pages/home/sections/Features.tsx +++ b/src/pages/home/sections/Features.tsx @@ -1,13 +1,13 @@ import { motion } from "framer-motion"; import BentoGrid, { type BentoItem } from "../../../components/BentoGrid"; +import OccupancyCard from "../../../components/OccupancyCard"; import styles from "./Features.module.css"; const featureItems: BentoItem[] = [ { title: "Live Occupancy", description: "Real-time occupancy, no reload required", - image: "/images/features/live-occupancy.webp", - imageDark: "/images/features/live-occupancy-dark.webp", + component: , size: "medium", theme: "gold", },