Initial commit

This commit is contained in:
DK
2026-02-09 16:59:57 +00:00
commit 4d32657949
656 changed files with 77327 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
import Image from "next/image";
export interface Avatar {
src: string;
alt: string;
}
interface AvatarGroupProps {
avatars: Avatar[];
text?: string;
maxVisible?: number;
className?: string;
avatarClassName?: string;
avatarImageClassName?: string;
avatarOverlapClassName?: string;
textClassName?: string;
ariaLabel?: string;
}
const AvatarGroup = ({
avatars,
text,
maxVisible = 5,
className = "",
avatarClassName = "",
avatarImageClassName = "",
avatarOverlapClassName = "-ml-3",
textClassName = "",
ariaLabel = "User avatars",
}: AvatarGroupProps) => {
const visibleAvatars = avatars.slice(0, maxVisible);
const remainingCount = Math.max(0, avatars.length - maxVisible);
return (
<div
role="group"
aria-label={ariaLabel}
className={cls("relative z-1 flex items-center gap-3", className)}
>
<div className="flex items-center">
{visibleAvatars.map((avatar, index) => (
<div
key={index}
className={cls(
"relative card p-0.5 rounded-theme",
index !== 0 && avatarOverlapClassName,
`z-[${visibleAvatars.length - index}]`,
avatarClassName
)}
>
<div className={cls("relative z-1 h-12 w-auto aspect-square rounded-theme overflow-hidden", avatarImageClassName)}>
<Image
src={avatar.src}
alt={avatar.alt}
fill
className="w-full h-full object-cover"
unoptimized={avatar.src.startsWith('http') || avatar.src.startsWith('//')}
/>
</div>
</div>
))}
{remainingCount > 0 && (
<div
className={cls(
`card p-0.5 rounded-theme ${avatarOverlapClassName} z-0`,
avatarClassName
)}
>
<div className={cls("relative z-1 h-12 w-auto aspect-square rounded-theme flex items-center justify-center text-xs text-foreground", avatarImageClassName)}>
+{remainingCount}
</div>
</div>
)}
</div>
{text && (
<p className={cls("relative z-1 text-sm text-foreground text-balance", textClassName)}>
{text}
</p>
)}
</div>
);
};
AvatarGroup.displayName = "AvatarGroup";
export default memo(AvatarGroup);

View File

@@ -0,0 +1,35 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
interface BadgeProps {
text: string;
variant?: "primary" | "card";
className?: string;
}
const Badge = memo(({
text,
variant = "primary",
className = "",
}: BadgeProps) => {
return (
<div className={cls(
"px-3 py-1 rounded-theme w-fit",
variant === "primary" ? "primary-button" : "card",
className
)}>
<p className={cls(
"relative z-1 text-xs",
variant === "primary" ? "text-background" : "text-foreground"
)}>
{text}
</p>
</div>
);
});
Badge.displayName = "Badge";
export default Badge;

View File

@@ -0,0 +1,331 @@
"use client";
import React, { useState, useEffect } from "react";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import {
ArrowUpRight,
Bell,
ChevronLeft,
ChevronRight,
Plus,
Search,
} from "lucide-react";
import AnimationContainer from "@/components/sections/AnimationContainer";
import Button from "@/components/button/Button";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import MediaContent from "@/components/shared/MediaContent";
import BentoLineChart from "@/components/bento/BentoLineChart/BentoLineChart";
import type { ChartDataItem } from "@/components/bento/BentoLineChart/utils";
import type { ButtonConfig } from "@/types/button";
import { useCardAnimation } from "@/components/cardStack/hooks/useCardAnimation";
import TextNumberCount from "@/components/text/TextNumberCount";
export interface DashboardSidebarItem {
icon: LucideIcon;
active?: boolean;
}
export interface DashboardStat {
title: string;
titleMobile?: string;
values: [number, number, number];
valuePrefix?: string;
valueSuffix?: string;
valueFormat?: Omit<Intl.NumberFormatOptions, "notation"> & {
notation?: Exclude<Intl.NumberFormatOptions["notation"], "scientific" | "engineering">;
};
description: string;
}
export interface DashboardListItem {
icon: LucideIcon;
title: string;
status: string;
}
interface DashboardProps {
title: string;
stats: [DashboardStat, DashboardStat, DashboardStat];
logoIcon: LucideIcon;
sidebarItems: DashboardSidebarItem[];
searchPlaceholder?: string;
buttons: ButtonConfig[];
chartTitle?: string;
chartData?: ChartDataItem[];
listItems: DashboardListItem[];
listTitle?: string;
imageSrc: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
className?: string;
containerClassName?: string;
sidebarClassName?: string;
statClassName?: string;
chartClassName?: string;
listClassName?: string;
}
const Dashboard = ({
title,
stats,
logoIcon: LogoIcon,
sidebarItems,
searchPlaceholder = "Search",
buttons,
chartTitle = "Revenue Overview",
chartData,
listItems,
listTitle = "Recent Transfers",
imageSrc,
videoSrc,
imageAlt = "",
videoAriaLabel = "Avatar video",
className = "",
containerClassName = "",
sidebarClassName = "",
statClassName = "",
chartClassName = "",
listClassName = "",
}: DashboardProps) => {
const theme = useTheme();
const [activeStatIndex, setActiveStatIndex] = useState(0);
const [statValueIndex, setStatValueIndex] = useState(0);
const { itemRefs: statRefs } = useCardAnimation({
animationType: "slide-up",
itemCount: 3,
});
useEffect(() => {
const interval = setInterval(() => {
setStatValueIndex((prev) => (prev + 1) % 3);
}, 3000);
return () => clearInterval(interval);
}, []);
const statCard = (stat: DashboardStat, index: number, withRef = false) => (
<div
key={index}
ref={withRef ? (el) => { statRefs.current[index] = el; } : undefined}
className={cls(
"group rounded-theme-capped p-5 flex flex-col justify-between h-40 md:h-50 card shadow",
statClassName
)}
>
<div className="flex items-center justify-between">
<p className="text-base font-medium text-foreground">
{stat.title}
</p>
<div className="h-6 w-auto aspect-square rounded-theme secondary-button flex items-center justify-center transition-transform duration-300 hover:-translate-y-[3px]">
<ArrowUpRight className="h-1/2 w-1/2 text-foreground transition-transform duration-300 group-hover:rotate-45" />
</div>
</div>
<div className="flex flex-col">
<TextNumberCount
value={stat.values[statValueIndex]}
prefix={stat.valuePrefix}
suffix={stat.valueSuffix}
format={stat.valueFormat}
className="text-xl md:text-3xl font-medium text-foreground truncate"
/>
<p className="text-sm text-foreground/75 truncate">
{stat.description}
</p>
</div>
</div>
);
return (
<div
className={cls(
"w-content-width flex gap-5 p-5 rounded-theme-capped card shadow",
className
)}
>
<div
className={cls(
"hidden md:flex gap-5 shrink-0",
sidebarClassName
)}
>
<div className="flex flex-col items-center gap-10" >
<div className="relative secondary-button h-9 w-auto aspect-square rounded-theme flex items-center justify-center transition-transform duration-300 hover:-translate-y-[3px]">
<LogoIcon className="h-4/10 w-4/10 text-foreground" />
</div>
<nav className="flex flex-col gap-3">
{sidebarItems.map((item, index) => (
<div
key={index}
className={cls(
"h-9 w-auto aspect-square rounded-theme flex items-center justify-center transition-transform duration-300 hover:-translate-y-[3px]",
item.active
? "primary-button"
: "secondary-button"
)}
>
<item.icon
className={cls(
"h-4/10 w-4/10 text-background",
item.active
? "text-background"
: "text-foreground"
)}
strokeWidth={1.5}
/>
</div>
))}
</nav>
</div>
<div className="h-full w-px bg-background-accent" />
</div>
<div
className={cls(
"flex-1 flex flex-col gap-5 min-w-0",
containerClassName
)}
>
<div className="flex items-center justify-between h-9">
<div className="h-9 px-6 rounded-theme card shadow flex items-center gap-3 transition-all duration-300 hover:px-8">
<Search className="h-(--text-sm) w-auto text-foreground" />
<p className="text-sm text-foreground">
{searchPlaceholder}
</p>
</div>
<div className="flex items-center gap-5">
<div className="h-9 w-auto aspect-square secondary-button rounded-theme flex items-center justify-center transition-transform duration-300 hover:-translate-y-[3px]">
<Bell className="h-4/10 w-4/10 text-foreground" />
</div>
<div className="h-9 w-auto aspect-square rounded-theme overflow-hidden transition-transform duration-300 hover:-translate-y-[3px]">
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName="w-full h-full object-cover"
/>
</div>
</div>
</div>
<div className="w-full h-px bg-background-accent" />
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
<h2 className="text-xl md:text-3xl font-medium text-foreground">
{title}
</h2>
<div className="flex items-center gap-5">
{buttons.slice(0, 2).map((button, index) => (
<Button
key={`${button.text}-${index}`}
{...getButtonProps(
button,
index,
theme.defaultButtonVariant
)}
/>
))}
</div>
</div>
<div className="hidden md:grid grid-cols-3 gap-5">
{stats.map((stat, index) => statCard(stat, index, true))}
</div>
<div className="flex flex-col gap-3 md:hidden">
<AnimationContainer
key={activeStatIndex}
className="w-full"
animationType="fade"
>
{statCard(stats[activeStatIndex], activeStatIndex)}
</AnimationContainer>
<div className="w-full flex justify-end gap-3">
<button
onClick={() => setActiveStatIndex((prev) => (prev - 1 + 3) % 3)}
className="secondary-button h-8 aspect-square flex items-center justify-center rounded-theme cursor-pointer transition-transform duration-300 hover:-translate-y-[3px]"
type="button"
aria-label="Previous stat"
>
<ChevronLeft className="h-[40%] w-auto aspect-square text-foreground" />
</button>
<button
onClick={() => setActiveStatIndex((prev) => (prev + 1) % 3)}
className="secondary-button h-8 aspect-square flex items-center justify-center rounded-theme cursor-pointer transition-transform duration-300 hover:-translate-y-[3px]"
type="button"
aria-label="Next stat"
>
<ChevronRight className="h-[40%] w-auto aspect-square text-foreground" />
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div
className={cls(
"group/chart rounded-theme-capped p-3 md:p-4 flex flex-col h-80 card shadow",
chartClassName
)}
>
<div className="flex items-center justify-between mb-2">
<p className="text-base font-medium text-foreground">
{chartTitle}
</p>
<div className="h-6 w-auto aspect-square rounded-theme secondary-button flex items-center justify-center transition-transform duration-300 hover:-translate-y-[3px]">
<ArrowUpRight className="h-1/2 w-1/2 text-foreground transition-transform duration-300 group-hover/chart:rotate-45" />
</div>
</div>
<div className="flex-1 min-h-0">
<BentoLineChart
data={chartData}
metricLabel={chartTitle}
useInvertedBackground="noInvert"
/>
</div>
</div>
<div
className={cls(
"group/list rounded-theme-capped p-5 flex flex-col h-80 card shadow",
listClassName
)}
>
<div className="flex items-center justify-between">
<p className="text-base font-medium text-foreground">
{listTitle}
</p>
<div className="h-6 w-auto aspect-square rounded-theme secondary-button flex items-center justify-center transition-transform duration-300 hover:-translate-y-[3px]">
<Plus className="h-1/2 w-1/2 text-foreground transition-transform duration-300 group-hover/list:rotate-90" />
</div>
</div>
<div className="overflow-hidden mask-fade-y flex-1 min-h-0 mt-3">
<div className="flex flex-col animate-marquee-vertical px-px">
{[...listItems, ...listItems].map((item, index) => {
const ItemIcon = item.icon;
return (
<div
key={index}
className="flex items-center gap-2.5 p-2 rounded-theme bg-foreground/3 border border-foreground/5 flex-shrink-0 mb-2"
>
<div className="h-8 w-auto aspect-square rounded-theme shrink-0 flex items-center justify-center secondary-button">
<ItemIcon className="h-4/10 w-4/10 text-foreground" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<p className="text-xs truncate text-foreground">
{item.title}
</p>
<p className="text-xs text-foreground/75">
{item.status}
</p>
</div>
<ChevronRight className="h-(--text-xs) w-auto shrink-0 text-foreground/75" />
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
Dashboard.displayName = "Dashboard";
export default React.memo(Dashboard);

View File

@@ -0,0 +1,60 @@
"use client";
import { memo, useState, useCallback } from "react";
import { Heart } from "lucide-react";
import { cls } from "@/lib/utils";
interface FavoriteButtonProps {
initialFavorited?: boolean;
onToggle?: () => void;
className?: string;
}
const FavoriteButton = memo(({
initialFavorited = false,
onToggle,
className = "",
}: FavoriteButtonProps) => {
const [isFavorited, setIsFavorited] = useState(initialFavorited);
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setIsFavorited(prev => !prev);
onToggle?.();
}, [onToggle]);
return (
<button
onClick={handleClick}
className={cls(
"absolute cursor-pointer top-4 right-4 card h-8 w-auto aspect-square rounded-theme flex items-center justify-center z-10",
className
)}
aria-label={isFavorited ? "Remove from favorites" : "Add to favorites"}
type="button"
>
<div className="relative z-1 w-full h-full flex items-center justify-center">
<Heart
className={cls(
"h-4/10 text-foreground transition-all duration-300",
isFavorited ? "opacity-0 blur-xs" : "opacity-100 blur-0"
)}
fill="none"
strokeWidth={1.5}
/>
<Heart
className={cls(
"h-4/10 text-accent absolute transition-all duration-300",
isFavorited ? "opacity-100 blur-0" : "opacity-0 blur-xs"
)}
fill="currentColor"
strokeWidth={0}
/>
</div>
</button>
);
});
FavoriteButton.displayName = "FavoriteButton";
export default FavoriteButton;

View File

@@ -0,0 +1,59 @@
"use client";
import React, { useMemo } from "react";
import useFillWidthText from "./useFillWidthText";
import { cls } from "@/lib/utils";
type TextElement = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span";
// Lowercase characters that have descenders (extend below the baseline)
// Uppercase versions (G, J, P, Q, Y) don't have descenders
const DESCENDER_CHARS = /[gjpqy]/;
// Utility function to check if text has descender characters
export const hasDescenders = (text: string): boolean => DESCENDER_CHARS.test(text);
interface FillWidthTextProps {
children: string;
as?: TextElement;
className?: string;
}
const FillWidthText = ({
children,
as: Component = "h1",
className = "",
}: FillWidthTextProps) => {
const { containerRef, textRef, fontSize, isReady } = useFillWidthText(children);
// Use tighter line height if text has no descender characters
const lineHeight = useMemo(() => {
return DESCENDER_CHARS.test(children) ? 1.2 : 0.8;
}, [children]);
return (
<div
ref={containerRef}
className="w-full min-w-0 flex-1"
>
<Component
ref={textRef as React.RefObject<HTMLHeadingElement & HTMLParagraphElement & HTMLSpanElement>}
className={cls(
"whitespace-nowrap transition-opacity duration-150",
isReady ? "opacity-100" : "opacity-0",
className
)}
style={{
fontSize: `${fontSize}px`,
lineHeight,
}}
>
{children}
</Component>
</div>
);
};
FillWidthText.displayName = "FillWidthText";
export default React.memo(FillWidthText);

View File

@@ -0,0 +1,109 @@
'use client';
import { useRef, useEffect, useState, useCallback } from 'react';
interface UseFillWidthTextReturn {
containerRef: React.RefObject<HTMLDivElement | null>;
textRef: React.RefObject<HTMLElement | null>;
fontSize: number;
isReady: boolean;
}
const BASE_FONT_SIZE = 100;
// Shared canvas for text measurement (no DOM manipulation needed)
let measureCanvas: HTMLCanvasElement | null = null;
function getMeasureCanvas(): CanvasRenderingContext2D | null {
if (typeof document === 'undefined') return null;
if (!measureCanvas) {
measureCanvas = document.createElement('canvas');
}
return measureCanvas.getContext('2d');
}
export default function useFillWidthText(text: string): UseFillWidthTextReturn {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLElement>(null);
const [fontSize, setFontSize] = useState(BASE_FONT_SIZE);
const [isReady, setIsReady] = useState(false);
// Cache for computed styles
const stylesRef = useRef<{ fontFamily: string; fontWeight: string } | null>(null);
const calculateFontSize = useCallback(() => {
if (!containerRef.current || !textRef.current) return;
const container = containerRef.current;
const textElement = textRef.current;
const containerWidth = container.offsetWidth;
if (containerWidth === 0) return;
// Cache styles on first calculation
if (!stylesRef.current) {
const computed = getComputedStyle(textElement);
stylesRef.current = {
fontFamily: computed.fontFamily,
fontWeight: computed.fontWeight,
};
}
const ctx = getMeasureCanvas();
if (!ctx) return;
// Measure text using canvas (fast, no reflow)
const { fontFamily, fontWeight } = stylesRef.current;
ctx.font = `${fontWeight} ${BASE_FONT_SIZE}px ${fontFamily}`;
const textWidth = ctx.measureText(text).width;
if (textWidth === 0) return;
const newFontSize = (containerWidth / textWidth) * BASE_FONT_SIZE;
setFontSize(newFontSize);
setIsReady(true);
}, [text]);
// Initial calculation
useEffect(() => {
const rafId = requestAnimationFrame(() => {
calculateFontSize();
});
return () => cancelAnimationFrame(rafId);
}, [text, calculateFontSize]);
// Debounced resize observer
useEffect(() => {
if (!containerRef.current) return;
let rafId: number | null = null;
const resizeObserver = new ResizeObserver(() => {
// Debounce using RAF - only calculate once per frame
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
calculateFontSize();
});
});
resizeObserver.observe(containerRef.current);
return () => {
if (rafId) cancelAnimationFrame(rafId);
resizeObserver.disconnect();
};
}, [calculateFontSize]);
// Reset style cache if text changes (might have different element)
useEffect(() => {
stylesRef.current = null;
}, [text]);
return {
containerRef,
textRef,
fontSize,
isReady
};
}

View File

@@ -0,0 +1,62 @@
"use client";
import { memo } from "react";
import ButtonTextUnderline from "@/components/button/ButtonTextUnderline";
import { cls } from "@/lib/utils";
export interface FooterColumn {
title: string;
items: Array<{
label: string;
href?: string;
onClick?: () => void;
}>;
}
interface FooterColumnsProps {
columns: FooterColumn[];
className?: string;
columnClassName?: string;
columnTitleClassName?: string;
columnItemClassName?: string;
}
const FooterColumns = memo<FooterColumnsProps>(function FooterColumns({
columns,
className = "",
columnClassName = "",
columnTitleClassName = "",
columnItemClassName = "",
}) {
return (
<div
className={cls("w-full md:w-fit flex flex-wrap gap-y-[var(--width-10)] md:gap-[calc(var(--width-10)/1.5)]", className)}
>
{columns.map((column) => (
<div
key={column.title}
className={cls("relative z-1 w-1/2 md:w-auto flex items-start flex-col gap-4", columnClassName)}
>
<h3
className={cls("text-sm text-accent/75", columnTitleClassName)}
>
{column.title}
</h3>
{column.items.map((item) => (
<ButtonTextUnderline
key={item.label}
text={item.label}
href={item.href}
onClick={item.onClick}
className={cls("text-base text-foreground", columnItemClassName)}
/>
))}
</div>
))}
</div>
);
});
FooterColumns.displayName = "FooterColumns";
export default FooterColumns;

View File

@@ -0,0 +1,78 @@
"use client";
import Image from "next/image";
import Marquee from "react-fast-marquee";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
export type MarqueeItem =
| { type: "image"; src: string; alt?: string }
| { type: "text"; text: string }
| { type: "text-icon"; text: string; icon: LucideIcon };
interface LogoMarqueeProps {
items: MarqueeItem[];
speed?: number;
showCard?: boolean;
className?: string;
itemClassName?: string;
cardClassName?: string;
imageClassName?: string;
textClassName?: string;
iconClassName?: string;
}
const LogoMarquee = ({
items,
speed = 30,
showCard = true,
className = "",
itemClassName = "",
cardClassName = "",
imageClassName = "",
textClassName = "",
iconClassName = "",
}: LogoMarqueeProps) => {
const repeatedItems = [...items, ...items, ...items];
return (
<div className={cls("mask-padding-x", className)}>
<Marquee gradient={false} speed={speed}>
{repeatedItems.map((item, i) => {
const hasCard = item.type !== "image" && showCard;
return (
<div className={cls(hasCard ? "mx-2" : "mx-6", itemClassName)} key={i}>
<div className={cls(hasCard ? "card px-4 py-3 mb-1 rounded-theme" : "", cardClassName)}>
{item.type === "image" && (
<Image
width={500}
height={500}
src={item.src}
alt={item.alt || `Logo ${i + 1}`}
className={cls("relative z-1 h-6 w-auto", imageClassName)}
unoptimized={item.src.startsWith("http") || item.src.startsWith("//")}
/>
)}
{item.type === "text" && (
<p className={cls("relative z-1 text-foreground text-sm", textClassName)}>
{item.text}
</p>
)}
{item.type === "text-icon" && (
<span className={cls("relative z-1 flex items-center gap-2 text-foreground text-sm", textClassName)}>
<item.icon className={cls("h-[1em] w-auto", iconClassName)} strokeWidth={1.5} />
{item.text}
</span>
)}
</div>
</div>
);
})}
</Marquee>
</div>
);
};
LogoMarquee.displayName = "LogoMarquee";
export default LogoMarquee;

View File

@@ -0,0 +1,53 @@
"use client";
import { memo } from "react";
import Image from "next/image";
import { cls } from "@/lib/utils";
interface MediaContentProps {
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
imageClassName?: string;
}
const MediaContent = ({
imageSrc,
videoSrc,
imageAlt = "",
videoAriaLabel = "Video content",
imageClassName = "",
}: MediaContentProps) => {
return (
<>
{videoSrc ? (
<video
src={videoSrc}
aria-label={videoAriaLabel}
className={cls("w-full h-auto object-cover rounded-theme-capped", imageClassName)}
autoPlay
loop
muted
playsInline
/>
) : (
imageSrc && (
<Image
src={imageSrc}
alt={imageAlt}
width={1920}
height={1080}
className={cls("w-full h-auto object-cover rounded-theme-capped", imageClassName)}
unoptimized={imageSrc.startsWith('http') || imageSrc.startsWith('//')}
aria-hidden={imageAlt === ""}
/>
)
)}
</>
);
};
MediaContent.displayName = "MediaContent";
export default memo(MediaContent);

View File

@@ -0,0 +1,31 @@
"use client";
import { memo } from "react";
import { ArrowUpRight } from "lucide-react";
import { cls } from "@/lib/utils";
interface OverlayArrowButtonProps {
ariaLabel?: string;
className?: string;
}
const OverlayArrowButton = memo(({
ariaLabel = "View details",
className = "",
}: OverlayArrowButtonProps) => {
return (
<div
className={cls(
"!absolute z-1 top-4 right-4 cursor-pointer card h-8 w-auto aspect-square rounded-theme flex items-center justify-center flex-shrink-0",
className
)}
aria-label={ariaLabel}
>
<ArrowUpRight className="relative z-1 h-4/10 text-foreground transition-transform duration-300 group-hover:rotate-45" strokeWidth={1.5} />
</div>
);
});
OverlayArrowButton.displayName = "OverlayArrowButton";
export default OverlayArrowButton;

View File

@@ -0,0 +1,28 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
interface PricingBadgeProps {
badge: string;
badgeIcon?: LucideIcon;
className?: string;
}
const PricingBadge = memo(({
badge,
badgeIcon: BadgeIcon,
className = "",
}: PricingBadgeProps) => {
return (
<div className={cls("relative z-1 inline-flex items-center gap-2 px-4 py-2 w-fit card rounded-theme text-sm", className)}>
{BadgeIcon && <BadgeIcon className="relative z-1 h-[1em] w-auto" />}
<span>{badge}</span>
</div>
);
});
PricingBadge.displayName = "PricingBadge";
export default PricingBadge;

View File

@@ -0,0 +1,44 @@
"use client";
import { memo } from "react";
import { Check, LucideIcon } from "lucide-react";
import { cls } from "@/lib/utils";
interface PricingFeatureListProps {
features: string[];
icon?: LucideIcon;
shouldUseLightText?: boolean;
className?: string;
featureItemClassName?: string;
featureIconWrapperClassName?: string;
featureIconClassName?: string;
featureTextClassName?: string;
}
const PricingFeatureList = memo(({
features,
icon: Icon = Check,
shouldUseLightText = false,
className = "",
featureItemClassName = "",
featureIconWrapperClassName = "",
featureIconClassName = "",
featureTextClassName = "",
}: PricingFeatureListProps) => {
return (
<div className={cls("relative z-1 flex flex-col gap-3", className)}>
{features.map((feature, featureIndex) => (
<div key={featureIndex} className={cls("flex items-start gap-3", featureItemClassName)}>
<div className={cls("h-6 aspect-square primary-button rounded-theme flex items-center justify-center", featureIconWrapperClassName)}>
<Icon className={cls("h-4/10 text-background", featureIconClassName)} strokeWidth={1.5} />
</div>
<span className={cls("text-base", shouldUseLightText ? "text-background" : "text-foreground", featureTextClassName)}>{feature}</span>
</div>
))}
</div>
);
});
PricingFeatureList.displayName = "PricingFeatureList";
export default PricingFeatureList;

View File

@@ -0,0 +1,68 @@
"use client";
import { memo } from "react";
import Image from "next/image";
import { ArrowUpRight } from "lucide-react";
import FavoriteButton from "@/components/shared/FavoriteButton";
import { cls } from "@/lib/utils";
interface ProductImageProps {
imageSrc: string;
imageAlt: string;
isFavorited?: boolean;
onFavoriteToggle?: () => void;
showActionButton?: boolean;
actionButtonAriaLabel?: string;
onActionClick?: () => void;
className?: string;
imageClassName?: string;
actionButtonClassName?: string;
}
const ProductImage = memo(({
imageSrc,
imageAlt,
isFavorited = false,
onFavoriteToggle,
showActionButton = false,
actionButtonAriaLabel = "View details",
onActionClick,
className = "",
imageClassName = "",
actionButtonClassName = "",
}: ProductImageProps) => {
return (
<div className={cls("relative w-full h-full rounded-theme-capped overflow-hidden card", className)}>
<div className="relative z-1 w-full h-full overflow-hidden rounded-theme-capped">
<Image
src={imageSrc}
alt={imageAlt}
fill
className={cls("w-full h-full object-cover transition-transform duration-500 ease-in-out group-hover:scale-105", imageClassName)}
unoptimized={imageSrc.startsWith('http') || imageSrc.startsWith('//')}
aria-hidden={imageAlt === ""}
/>
</div>
<FavoriteButton
initialFavorited={isFavorited}
onToggle={onFavoriteToggle}
/>
{showActionButton && (
<button
className={cls("absolute! z-2 top-3 right-3 cursor-pointer card backdrop-blur-lg h-8 w-auto aspect-square rounded-theme flex items-center justify-center", actionButtonClassName)}
aria-label={actionButtonAriaLabel}
type="button"
onClick={onActionClick}
>
<ArrowUpRight className="h-4/10 text-foreground transition-transform duration-300 group-hover:rotate-45" strokeWidth={1.5} />
</button>
)}
</div>
);
});
ProductImage.displayName = "ProductImage";
export default ProductImage;

View File

@@ -0,0 +1,25 @@
"use client";
import { memo } from "react";
import type { LucideIcon } from "lucide-react";
interface QuantityButtonProps {
onClick: (e: React.MouseEvent) => void;
ariaLabel: string;
Icon: LucideIcon;
}
const QuantityButton = memo(({ onClick, ariaLabel, Icon }: QuantityButtonProps) => (
<button
onClick={onClick}
className="secondary-button h-8 aspect-square rounded-theme flex items-center justify-center cursor-pointer"
aria-label={ariaLabel}
type="button"
>
<Icon className="relative h-4/10 text-foreground" strokeWidth={1.5} />
</button>
));
QuantityButton.displayName = "QuantityButton";
export default QuantityButton;

View File

@@ -0,0 +1,50 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
export interface SocialLink {
icon: LucideIcon;
href: string;
ariaLabel: string;
}
interface SocialLinksProps {
socialLinks: SocialLink[];
className?: string;
iconClassName?: string;
}
const SocialLinks = memo<SocialLinksProps>(function SocialLinks({
socialLinks,
className = "",
iconClassName = "",
}) {
return (
<div className={cls("relative z-1 flex items-center gap-4", className)}>
{socialLinks.map((social, index) => {
const SocialIcon = social.icon;
return (
<a
key={index}
href={social.href}
target="_blank"
rel="noopener noreferrer"
aria-label={social.ariaLabel}
className={cls(
"card h-10 w-auto aspect-square rounded-theme flex items-center justify-center",
iconClassName
)}
>
<SocialIcon className="relative z-1 h-45/100 w-45/100 text-foreground" strokeWidth={1.5} />
</a>
);
})}
</div>
);
});
SocialLinks.displayName = "SocialLinks";
export default SocialLinks;

View File

@@ -0,0 +1,51 @@
"use client";
import { memo } from "react";
import useSvgTextLogo from "./useSvgTextLogo";
import { cls } from "@/lib/utils";
interface SvgTextLogoProps {
logoText: string;
adjustHeightFactor?: number;
verticalAlign?: "top" | "center";
className?: string;
}
const SvgTextLogo = memo<SvgTextLogoProps>(function SvgTextLogo({
logoText,
adjustHeightFactor,
verticalAlign = "top",
className = "",
}) {
const { svgRef, textRef, viewBox, aspectRatio } = useSvgTextLogo(logoText, false, adjustHeightFactor);
return (
<svg
ref={svgRef}
viewBox={viewBox}
className={cls("w-full", className)}
style={{ aspectRatio: aspectRatio }}
preserveAspectRatio="none"
role="img"
aria-label={`${logoText} logo`}
>
<text
ref={textRef}
x="0"
y={verticalAlign === "center" ? "50%" : "0"}
className="font-bold fill-current"
style={{
fontSize: "20px",
letterSpacing: "-0.02em",
dominantBaseline: verticalAlign === "center" ? "middle" : "text-before-edge"
}}
>
{logoText}
</text>
</svg>
);
});
SvgTextLogo.displayName = "SvgTextLogo";
export default SvgTextLogo;

View File

@@ -0,0 +1,34 @@
'use client';
import { useRef, useEffect, useState } from 'react';
interface UseSvgTextLogoReturn {
svgRef: React.RefObject<SVGSVGElement | null>;
textRef: React.RefObject<SVGTextElement | null>;
viewBox: string;
aspectRatio: number;
}
export default function useSvgTextLogo(logoText: string, hasLogoSrc: boolean, adjustHeightFactor?: number): UseSvgTextLogoReturn {
const svgRef = useRef<SVGSVGElement>(null);
const textRef = useRef<SVGTextElement>(null);
const [viewBox, setViewBox] = useState('0 0 100 20');
const [aspectRatio, setAspectRatio] = useState(5);
useEffect(() => {
if (!hasLogoSrc && textRef.current && svgRef.current) {
const bbox = textRef.current.getBBox();
const height = adjustHeightFactor ? bbox.height * adjustHeightFactor : bbox.height;
const yOffset = adjustHeightFactor ? bbox.y + (bbox.height - height) / 2 : bbox.y;
setViewBox(`${bbox.x} ${yOffset} ${bbox.width} ${height}`);
setAspectRatio(bbox.width / height);
}
}, [hasLogoSrc, logoText, adjustHeightFactor]);
return {
svgRef,
textRef,
viewBox,
aspectRatio
};
}

View File

@@ -0,0 +1,39 @@
"use client";
import { memo } from "react";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import { LucideIcon } from "lucide-react";
interface TagProps {
text: string;
icon?: LucideIcon;
useInvertedBackground?: "noInvert" | "invertDefault";
className?: string;
textClassName?: string;
}
const Tag = memo(({
text,
icon: Icon,
useInvertedBackground,
className = "",
textClassName = "",
}: TagProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
return (
<div className={cls(
"relative z-1 px-3 py-1 text-sm rounded-theme card inline-flex items-center gap-2 w-fit",
className
)}>
{Icon && <Icon className={cls("relative z-1 h-[1em] w-auto", shouldUseLightText ? "text-background" : "text-foreground")} />}
<span className={cls(shouldUseLightText ? "text-background" : "text-foreground", textClassName)}>{text}</span>
</div>
);
});
Tag.displayName = "Tag";
export default Tag;

View File

@@ -0,0 +1,73 @@
"use client";
import { memo } from "react";
import Image from "next/image";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
interface TestimonialAuthorProps {
name: string;
subtitle: string;
imageSrc?: string;
imageAlt?: string;
icon?: LucideIcon;
useInvertedBackground?: "noInvert" | "invertDefault";
className?: string;
imageWrapperClassName?: string;
imageClassName?: string;
iconClassName?: string;
nameClassName?: string;
subtitleClassName?: string;
}
const TestimonialAuthor = memo(({
name,
subtitle,
imageSrc,
imageAlt,
icon: Icon,
useInvertedBackground,
className = "",
imageWrapperClassName = "",
imageClassName = "",
iconClassName = "",
nameClassName = "",
subtitleClassName = "",
}: TestimonialAuthorProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
return (
<div className={cls("relative z-1 flex items-center gap-4", className)}>
<div className={cls("relative shrink-0 h-11 w-fit aspect-square rounded-theme flex items-center justify-center primary-button overflow-hidden", imageWrapperClassName)}>
{imageSrc ? (
<Image
src={imageSrc}
alt={imageAlt || name}
width={800}
height={800}
className={cls("absolute inset-0 w-full h-full object-cover", imageClassName)}
unoptimized={imageSrc.startsWith('http') || imageSrc.startsWith('//')}
aria-hidden={imageAlt === ""}
/>
) : (
<Icon className={cls("h-1/2 w-1/2 text-background", iconClassName)} strokeWidth={1} />
)}
</div>
<div className="w-full min-w-0 flex flex-col gap-0">
<h3 className={cls("text-2xl font-medium leading-[1.15] truncate", shouldUseLightText ? "text-background" : "text-foreground", nameClassName)}>
{name}
</h3>
<p className={cls("text-sm leading-[1.15]", shouldUseLightText ? "text-background" : "text-foreground", subtitleClassName)}>
{subtitle}
</p>
</div>
</div>
);
});
TestimonialAuthor.displayName = "TestimonialAuthor";
export default TestimonialAuthor;