You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

245 lines
6.3 KiB

"use client";
import Link from "next/link";
import {
type ButtonHTMLAttributes,
type ReactNode,
useEffect,
useId,
useState,
} from "react";
import { GoArrowRight } from "react-icons/go";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { LoadingThreeDot } from "./loading-three-dot";
type ButtonVariant =
| "default"
| "secondary"
| "countdown"
| "outlined"
| "dark";
type ArrowDirection = "left" | "right";
export type ButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"children"
> & {
children: ReactNode;
description?: string;
variant?: ButtonVariant;
arrowDirection?: ArrowDirection;
countdownSeconds?: number;
href?: string;
isLoading?: boolean;
};
const FILLED_STROKE = "#FFFFFF";
const EMPTY_STROKE = "rgba(255, 255, 255, 0.5)";
const RADIUS = 18;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
export function Button({
children,
description,
variant = "default",
arrowDirection,
countdownSeconds = 0,
disabled,
isLoading = false,
href,
className,
type = "button",
...props
}: ButtonProps) {
const { locale } = useI18n();
const countdownId = useId();
const isCountdown = variant === "countdown";
const isOutlined = variant === "outlined";
const initialCountdown = Math.max(0, Math.ceil(countdownSeconds));
const [remainingSeconds, setRemainingSeconds] = useState(initialCountdown);
const [animatedProgress, setAnimatedProgress] = useState(
isCountdown && initialCountdown > 0 ? 0 : 1,
);
useEffect(() => {
setRemainingSeconds(initialCountdown);
}, [initialCountdown]);
useEffect(() => {
if (!isCountdown || remainingSeconds <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
setRemainingSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearTimeout(timeoutId);
}, [isCountdown, remainingSeconds]);
useEffect(() => {
if (!isCountdown || initialCountdown <= 0) {
setAnimatedProgress(1);
return;
}
let animationFrameId = 0;
const startedAt = window.performance.now();
const duration = initialCountdown * 1000;
setAnimatedProgress(0);
const updateProgress = (currentTime: number) => {
const elapsed = currentTime - startedAt;
const nextProgress = Math.min(1, elapsed / duration);
setAnimatedProgress(nextProgress);
if (nextProgress < 1) {
animationFrameId = window.requestAnimationFrame(updateProgress);
}
};
animationFrameId = window.requestAnimationFrame(updateProgress);
return () => window.cancelAnimationFrame(animationFrameId);
}, [initialCountdown, isCountdown]);
const countdownLocked = isCountdown && remainingSeconds > 0;
const isDisabled = disabled || countdownLocked || isLoading;
const progress = isCountdown ? animatedProgress : 1;
const widthClass = variant === "secondary" ? "w-1/2" : "w-full";
const baseClassName = [
"inline-flex",
widthClass,
"items-center",
"justify-center",
"h-[52px]",
variant === "dark" ? "rounded-[9px]" : "rounded-[11px]",
"px-4",
"text-center",
"transition-opacity",
variant === "outlined"
? "border border-[#8B8B8B] bg-transparent text-[#8B8B8B]"
: variant === "dark"
? "border-none bg-[#2B2C31] text-white shadow-none hover:opacity-90"
: "bg-linear-to-tl from-[#FE6F82] to-[#E03950] text-white",
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
className,
]
.filter(Boolean)
.join(" ");
const renderArrow = (direction: ArrowDirection) => {
if (arrowDirection !== direction) {
return null;
}
return (
<GoArrowRight
aria-hidden="true"
className={[
"size-5",
direction === "left"
? "rotate-180 rtl:rotate-0"
: "rotate-0 rtl:rotate-180",
isOutlined ? "text-[#8B8B8B]" : "text-white",
]
.filter(Boolean)
.join(" ")}
/>
);
};
const button = (
<button
{...props}
type={type}
disabled={isDisabled}
aria-describedby={description && !isOutlined ? countdownId : undefined}
className={baseClassName}
>
{isLoading ? (
<LoadingThreeDot />
) : (
<span className="flex w-full items-center justify-center gap-2">
{renderArrow("left")}
<span className="flex min-w-0 flex-col items-center justify-center">
<span className="flex items-center justify-center gap-2 text-center group-16 font-semibold leading-none">
{children}
</span>
{description && !isOutlined ? (
<span
id={countdownId}
className="mt-1 text-center group-10 font-semibold leading-none text-white"
>
{description}
</span>
) : null}
</span>
{countdownLocked ? (
<span className="shrink-0">
<CountdownProgress value={remainingSeconds} progress={progress} />
</span>
) : (
renderArrow("right")
)}
</span>
)}
</button>
);
return href ? (
<Link href={localizePath(href, locale)}>{button}</Link>
) : (
button
);
}
type CountdownProgressProps = {
value: number;
progress: number;
};
function CountdownProgress({ value, progress }: CountdownProgressProps) {
const dashOffset = CIRCUMFERENCE * (1 - progress);
return (
<span className="relative flex h-[27px] w-[27px] items-center justify-center">
<svg
aria-hidden="true"
className="-rotate-90"
viewBox="0 0 44 44"
width="27"
height="27"
>
<circle
cx="22"
cy="22"
r={RADIUS}
fill="none"
stroke={EMPTY_STROKE}
strokeWidth="4"
/>
<circle
cx="22"
cy="22"
r={RADIUS}
fill="none"
stroke={FILLED_STROKE}
strokeWidth="4"
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashOffset}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center group-12 font-semibold leading-none text-white">
{value}
</span>
</span>
);
}
export default Button;