Initial commit
This commit is contained in:
89
src/components/shared/AvatarGroup.tsx
Normal file
89
src/components/shared/AvatarGroup.tsx
Normal 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);
|
||||
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;
|
||||
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-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;
|
||||
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;
|
||||
204
src/components/shared/Globe.tsx
Normal file
204
src/components/shared/Globe.tsx
Normal 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);
|
||||
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("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="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;
|
||||
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" | "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;
|
||||
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" | "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;
|
||||
Reference in New Issue
Block a user