Axil_Accountants/src/components/ui/BeamButton.tsx
Vadym Samoilenko 7cd698d6d6 feat: UI polish — SolutionSection stacked cards, BeamButton CTA, Footer redesign
- 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>
2026-02-23 12:30:50 +00:00

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