- SolutionSection: replace flat cards with interactive stacked card layout (skew, grayscale, hover lift, click-to-expand with z-50 + skew reset) - BeamButton: new component with framer-motion SVG beam animation along pill border (emerald→blue gradient); variants dark/light - HeroSection + FinalCTASection: primary CTA switched to BeamButton - Footer: replace programmatic SVG logo with logo-axil.png via next/image; add AImpress LTD credit link; increase hero text height - TextHoverEffect: fix viewBox (920×100), use SVG-unit fontSize, split AXIL (blue #1B9AD6) / ACCOUNTANTS (emerald #3CC68A) via tspan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
144 lines
4.2 KiB
TypeScript
144 lines
4.2 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { ArrowRightIcon } from './icons';
|
|
import { Spinner } from './Spinner';
|
|
|
|
type Size = 'sm' | 'md' | 'lg';
|
|
type Variant = 'dark' | 'light';
|
|
|
|
interface BeamButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
size?: Size;
|
|
variant?: Variant;
|
|
loading?: boolean;
|
|
trailingArrow?: boolean;
|
|
leadingIcon?: React.ReactNode;
|
|
}
|
|
|
|
const HEIGHTS: Record<Size, number> = { sm: 36, md: 44, lg: 56 };
|
|
const PADDINGS: Record<Size, string> = {
|
|
sm: 'px-4 text-sm',
|
|
md: 'px-6 text-base',
|
|
lg: 'px-8 text-lg',
|
|
};
|
|
const GAPS: Record<Size, string> = { sm: 'gap-1.5', md: 'gap-2', lg: 'gap-2.5' };
|
|
|
|
const VARIANTS: Record<Variant, { bg: string; text: string; border: string }> = {
|
|
dark: {
|
|
bg: 'bg-charcoal hover:bg-charcoal/85',
|
|
text: 'text-white',
|
|
border: 'rgba(255,255,255,0.1)',
|
|
},
|
|
light: {
|
|
bg: 'bg-white hover:bg-emerald-mist',
|
|
text: 'text-charcoal',
|
|
border: 'rgba(22,37,32,0.1)',
|
|
},
|
|
};
|
|
|
|
export function BeamButton({
|
|
children,
|
|
size = 'md',
|
|
variant = 'dark',
|
|
loading = false,
|
|
trailingArrow = false,
|
|
leadingIcon,
|
|
className = '',
|
|
disabled,
|
|
...props
|
|
}: BeamButtonProps) {
|
|
const uid = useId().replace(/:/g, '');
|
|
const ref = useRef<HTMLButtonElement>(null);
|
|
const [w, setW] = useState(180);
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const ro = new ResizeObserver(([e]) => setW(Math.round(e.contentRect.width)));
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
const h = HEIGHTS[size];
|
|
// Perimeter of pill: two straight edges + one full circle (r = h/2)
|
|
const perimeter = 2 * (w - h) + Math.PI * h;
|
|
const beamLen = Math.min(perimeter * 0.28, 90);
|
|
const { bg, text, border } = VARIANTS[variant];
|
|
const arrowSize = size === 'sm' ? 14 : size === 'lg' ? 20 : 16;
|
|
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
disabled={disabled || loading}
|
|
style={{ height: h }}
|
|
className={[
|
|
'rounded-pill relative inline-flex cursor-pointer items-center justify-center font-medium',
|
|
'transition-all duration-200 select-none',
|
|
'active:scale-[0.98]',
|
|
'focus-visible:ring-emerald/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
|
'disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100',
|
|
bg,
|
|
text,
|
|
PADDINGS[size],
|
|
GAPS[size],
|
|
className,
|
|
].join(' ')}
|
|
{...props}
|
|
>
|
|
{/* Beam border */}
|
|
<svg aria-hidden className="pointer-events-none absolute inset-0" width={w} height={h}>
|
|
{/* Static base border */}
|
|
<rect
|
|
x="1"
|
|
y="1"
|
|
width={w - 2}
|
|
height={h - 2}
|
|
rx={9999}
|
|
fill="none"
|
|
stroke={border}
|
|
strokeWidth="1.5"
|
|
/>
|
|
{/* Traveling beam */}
|
|
<motion.rect
|
|
x="1"
|
|
y="1"
|
|
width={w - 2}
|
|
height={h - 2}
|
|
rx={9999}
|
|
fill="none"
|
|
stroke={`url(#${uid})`}
|
|
strokeWidth="2.5"
|
|
strokeLinecap="round"
|
|
strokeDasharray={`${beamLen} ${perimeter}`}
|
|
initial={{ strokeDashoffset: 0 }}
|
|
animate={{ strokeDashoffset: -(perimeter + beamLen) }}
|
|
transition={{
|
|
duration: 2.8,
|
|
repeat: Infinity,
|
|
ease: 'linear',
|
|
repeatDelay: 0.6,
|
|
}}
|
|
/>
|
|
<defs>
|
|
<linearGradient id={uid} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2={w} y2="0">
|
|
<stop offset="0%" stopColor="#3CC68A" stopOpacity="0" />
|
|
<stop offset="30%" stopColor="#3CC68A" stopOpacity="1" />
|
|
<stop offset="70%" stopColor="#1B9AD6" stopOpacity="1" />
|
|
<stop offset="100%" stopColor="#1B9AD6" stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
</svg>
|
|
|
|
{/* Content */}
|
|
{loading ? <Spinner size={size === 'lg' ? 'md' : 'sm'} /> : (leadingIcon ?? null)}
|
|
{children}
|
|
{!loading && trailingArrow && (
|
|
<ArrowRightIcon
|
|
size={arrowSize}
|
|
className="shrink-0 transition-transform duration-200 group-hover:translate-x-0.5"
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|