Initial commit
This commit is contained in:
90
src/components/shared/AvatarGroup.tsx
Normal file
90
src/components/shared/AvatarGroup.tsx
Normal 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);
|
||||
35
src/components/shared/Badge.tsx
Normal file
35
src/components/shared/Badge.tsx
Normal 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;
|
||||
331
src/components/shared/Dashboard.tsx
Normal file
331
src/components/shared/Dashboard.tsx
Normal 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);
|
||||
60
src/components/shared/FavoriteButton.tsx
Normal file
60
src/components/shared/FavoriteButton.tsx
Normal 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;
|
||||
59
src/components/shared/FillWidthText/FillWidthText.tsx
Normal file
59
src/components/shared/FillWidthText/FillWidthText.tsx
Normal 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);
|
||||
109
src/components/shared/FillWidthText/useFillWidthText.ts
Normal file
109
src/components/shared/FillWidthText/useFillWidthText.ts
Normal 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
|
||||
};
|
||||
}
|
||||
62
src/components/shared/FooterColumns.tsx
Normal file
62
src/components/shared/FooterColumns.tsx
Normal 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;
|
||||
78
src/components/shared/LogoMarquee.tsx
Normal file
78
src/components/shared/LogoMarquee.tsx
Normal 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;
|
||||
53
src/components/shared/MediaContent.tsx
Normal file
53
src/components/shared/MediaContent.tsx
Normal 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);
|
||||
31
src/components/shared/OverlayArrowButton.tsx
Normal file
31
src/components/shared/OverlayArrowButton.tsx
Normal 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;
|
||||
28
src/components/shared/PricingBadge.tsx
Normal file
28
src/components/shared/PricingBadge.tsx
Normal 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;
|
||||
44
src/components/shared/PricingFeatureList.tsx
Normal file
44
src/components/shared/PricingFeatureList.tsx
Normal 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;
|
||||
68
src/components/shared/ProductImage.tsx
Normal file
68
src/components/shared/ProductImage.tsx
Normal 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;
|
||||
25
src/components/shared/QuantityButton.tsx
Normal file
25
src/components/shared/QuantityButton.tsx
Normal 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;
|
||||
50
src/components/shared/SocialLinks.tsx
Normal file
50
src/components/shared/SocialLinks.tsx
Normal 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;
|
||||
51
src/components/shared/SvgTextLogo/SvgTextLogo.tsx
Normal file
51
src/components/shared/SvgTextLogo/SvgTextLogo.tsx
Normal 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;
|
||||
34
src/components/shared/SvgTextLogo/useSvgTextLogo.ts
Normal file
34
src/components/shared/SvgTextLogo/useSvgTextLogo.ts
Normal 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
|
||||
};
|
||||
}
|
||||
39
src/components/shared/Tag.tsx
Normal file
39
src/components/shared/Tag.tsx
Normal 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;
|
||||
73
src/components/shared/TestimonialAuthor.tsx
Normal file
73
src/components/shared/TestimonialAuthor.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user