Initial commit

This commit is contained in:
dk
2026-02-05 05:20:29 +02:00
commit e7b9ff01f5
301 changed files with 58111 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,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,49 @@
"use client";
import React from "react";
import useFillWidthText from "./useFillWidthText";
import { cls } from "@/lib/utils";
type TextElement = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span";
interface FillWidthTextProps {
children: string;
as?: TextElement;
lineHeight?: number;
className?: string;
}
const FillWidthText = ({
children,
as: Component = "h1",
lineHeight = 1.1,
className = "",
}: FillWidthTextProps) => {
const { containerRef, textRef, fontSize, isReady } = useFillWidthText(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,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="card h-8 aspect-square rounded-theme 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";
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;