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
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;
|