Initial commit

This commit is contained in:
dk
2025-12-22 13:23:00 +02:00
commit 5b49d3c765
308 changed files with 64826 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
"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-full",
index !== 0 && avatarOverlapClassName,
`z-[${visibleAvatars.length - index}]`,
avatarClassName
)}
>
<div className={cls("relative z-1 h-12 w-auto aspect-square rounded-full overflow-hidden", avatarImageClassName)}>
<Image
src={avatar.src}
alt={avatar.alt}
fill
className="object-cover"
/>
</div>
</div>
))}
{remainingCount > 0 && (
<div
className={cls(
`card p-0.5 rounded-full ${avatarOverlapClassName} z-0`,
avatarClassName
)}
>
<div className={cls("relative z-1 h-12 w-auto aspect-square rounded-full 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,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-full 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,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,204 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { cls } from "@/lib/utils";
import createGlobe, { COBEOptions } from "cobe";
// Helper function to convert CSS color to RGB array
const getRGBFromCSSVar = (varName: string): [number, number, number] => {
if (typeof window === "undefined") return [0.5, 0.5, 0.5];
const value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
// Handle CSS named colors by creating a temporary element to get computed RGB
if (value && !value.startsWith("#") && !value.startsWith("rgb") && !value.includes("%") && !value.match(/^\d+\s+\d+\s+\d+$/)) {
const temp = document.createElement("div");
temp.style.color = value;
document.body.appendChild(temp);
const computed = getComputedStyle(temp).color;
document.body.removeChild(temp);
if (computed && computed.startsWith("rgb")) {
const match = computed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (match) {
const r = parseInt(match[1]) / 255;
const g = parseInt(match[2]) / 255;
const b = parseInt(match[3]) / 255;
return [r, g, b];
}
}
}
// Handle rgba/rgb format (e.g., "rgba(18, 0, 6, .9)" or "rgb(255, 255, 255)")
if (value.startsWith("rgb")) {
const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (match) {
const r = parseInt(match[1]) / 255;
const g = parseInt(match[2]) / 255;
const b = parseInt(match[3]) / 255;
return [r, g, b];
}
}
// Handle hex format (e.g., "#ffffff", "#ffffffaa", or shorthand "#fff", "#f0f")
if (value.startsWith("#")) {
let hex = value.replace("#", "");
// Expand shorthand hex (e.g., "93f" -> "9933ff")
if (hex.length === 3 || hex.length === 4) {
hex = hex.split("").map(c => c + c).join("").substring(0, 6);
}
// Take only first 6 characters (ignore alpha channel if present)
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
return [r, g, b];
}
// Handle HSL format (e.g., "0 0% 100%")
if (value.includes("%")) {
const [h, s, l] = value.split(/\s+/).map(v => parseFloat(v));
// Convert HSL to RGB
const sNorm = s / 100;
const lNorm = l / 100;
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = lNorm - c / 2;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return [(r + m), (g + m), (b + m)];
}
// Handle RGB format (e.g., "255 255 255")
const [r, g, b] = value.split(/\s+/).map(v => parseFloat(v) / 255);
return [r || 0.5, g || 0.5, b || 0.5];
};
const getGlobeConfig = (): COBEOptions => ({
width: 800,
height: 800,
onRender: () => {},
devicePixelRatio: 2,
phi: 0,
theta: 0.3,
dark: 0,
diffuse: 0.4,
mapSamples: 16000,
mapBrightness: 1.2,
baseColor: getRGBFromCSSVar("--card"),
markerColor: getRGBFromCSSVar("--primary-cta"),
glowColor: getRGBFromCSSVar("--card"),
markers: [
{ location: [14.5995, 120.9842], size: 0.03 },
{ location: [19.076, 72.8777], size: 0.1 },
{ location: [23.8103, 90.4125], size: 0.05 },
{ location: [30.0444, 31.2357], size: 0.07 },
{ location: [39.9042, 116.4074], size: 0.08 },
{ location: [-23.5505, -46.6333], size: 0.1 },
{ location: [19.4326, -99.1332], size: 0.1 },
{ location: [40.7128, -74.006], size: 0.1 },
{ location: [34.6937, 135.5022], size: 0.05 },
{ location: [41.0082, 28.9784], size: 0.06 },
],
});
interface GlobeProps {
className?: string;
config?: COBEOptions;
}
const GlobeComponent = ({
className = "",
config,
}: GlobeProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const globeRef = useRef<{ destroy: () => void } | null>(null);
const phiRef = useRef(0);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [globeConfig, setGlobeConfig] = useState<COBEOptions | null>(null);
const onRender = useCallback(
(state: Record<string, number>) => {
phiRef.current += 0.005;
state.phi = phiRef.current;
state.width = dimensions.width * 2;
state.height = dimensions.width * 2;
},
[dimensions]
);
const onResize = useCallback(() => {
if (canvasRef.current) {
const newWidth = canvasRef.current.offsetWidth;
setDimensions(prev => {
if (prev.width === newWidth) return prev;
return { width: newWidth, height: newWidth };
});
}
}, []);
useEffect(() => {
window.addEventListener("resize", onResize);
onResize();
return () => {
window.removeEventListener("resize", onResize);
};
}, [onResize]);
useEffect(() => {
// Initialize globe config with CSS variables
const defaultConfig = getGlobeConfig();
setGlobeConfig(config ? { ...defaultConfig, ...config } : defaultConfig);
}, [config]);
useEffect(() => {
if (!canvasRef.current || dimensions.width === 0 || !globeConfig) return;
if (globeRef.current) {
globeRef.current.destroy();
}
globeRef.current = createGlobe(canvasRef.current, {
...globeConfig,
width: dimensions.width * 2,
height: dimensions.width * 2,
onRender,
});
setTimeout(() => {
if (canvasRef.current) {
canvasRef.current.style.opacity = "1";
}
});
return () => {
if (globeRef.current) {
globeRef.current.destroy();
globeRef.current = null;
}
};
}, [dimensions, globeConfig, onRender]);
return (
<div
className={cls(
"absolute inset-0 mx-auto w-full aspect-square",
className
)}
>
<canvas
className="size-full opacity-0 transition-opacity duration-500 [contain:layout_paint_size]"
ref={canvasRef}
/>
</div>
);
};
GlobeComponent.displayName = "Globe";
export const Globe = React.memo(GlobeComponent);

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("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="card h-8 aspect-square rounded-full flex items-center justify-center cursor-pointer"
aria-label={ariaLabel}
type="button"
>
<Icon className="relative z-1 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" | "invertCard";
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" | "invertCard";
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 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;