first commit
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useInView } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AnimatedNumberProps {
|
||||
value: number;
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AnimatedNumber({
|
||||
value,
|
||||
suffix = "",
|
||||
prefix = "",
|
||||
duration = 1800,
|
||||
className,
|
||||
}: AnimatedNumberProps) {
|
||||
const [count, setCount] = useState(0);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return;
|
||||
|
||||
let rafId: number;
|
||||
let startTime: number | null = null;
|
||||
|
||||
const step = (ts: number) => {
|
||||
if (!startTime) startTime = ts;
|
||||
const progress = Math.min((ts - startTime) / duration, 1);
|
||||
// Ease-out cubic
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setCount(Math.round(eased * value));
|
||||
if (progress < 1) rafId = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [isInView, value, duration]);
|
||||
|
||||
return (
|
||||
<span ref={ref} className={cn(className)}>
|
||||
{prefix}{count}{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Variant = "gold" | "blue" | "green" | "dark" | "light";
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: Variant;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
gold: "bg-[var(--color-moy-gold-faint)] text-[var(--color-moy-gold-dark)] border border-[var(--color-moy-gold-subtle)]",
|
||||
blue: "bg-[var(--color-moy-blue)]/10 text-[var(--color-moy-blue)] border border-[var(--color-moy-blue)]/20",
|
||||
green: "bg-[var(--color-moy-green)]/10 text-[var(--color-moy-green)] border border-[var(--color-moy-green)]/20",
|
||||
dark: "bg-[var(--color-moy-dark)] text-white border border-[var(--color-moy-dark-border)]",
|
||||
light: "bg-white text-[var(--color-moy-dark)] border border-gray-200",
|
||||
};
|
||||
|
||||
export function Badge({ variant = "gold", children, className }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-3 py-1 text-[10px] font-medium uppercase tracking-[0.18em] rounded-full",
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { type ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "outline-light";
|
||||
type Size = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
shimmer?: boolean;
|
||||
as?: "button";
|
||||
}
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary:
|
||||
"bg-[var(--color-moy-gold)] text-[var(--color-moy-dark)] hover:bg-[var(--color-moy-gold-light)] font-bold shadow-[var(--shadow-gold)] hover:shadow-[var(--shadow-gold-lg)]",
|
||||
secondary:
|
||||
"border border-[var(--color-moy-gold)] text-[var(--color-moy-gold)] hover:bg-[var(--color-moy-gold)] hover:text-[var(--color-moy-dark)] font-medium",
|
||||
ghost:
|
||||
"text-[var(--color-moy-gold)] hover:bg-[var(--color-moy-gold-faint)] font-medium",
|
||||
"outline-light":
|
||||
"border border-white/50 text-white hover:border-white hover:bg-white/10 font-medium",
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: "px-5 py-2 text-[11px] tracking-[0.15em]",
|
||||
md: "px-8 py-3.5 text-[11px] tracking-[0.18em]",
|
||||
lg: "px-10 py-4 text-[11px] tracking-[0.2em]",
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "primary", size = "md", shimmer = false, children, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center uppercase overflow-hidden",
|
||||
"transition-all duration-300",
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{shimmer && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 moy-shimmer opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
/>
|
||||
)}
|
||||
<span className="relative">{children}</span>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/* ── Base Card ── */
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
hover?: boolean;
|
||||
glass?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ children, hover = true, glass = false, className }: CardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={hover ? { y: -6 } : undefined}
|
||||
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={cn(
|
||||
"rounded-2xl overflow-hidden transition-shadow duration-300",
|
||||
glass
|
||||
? "bg-white/10 backdrop-blur-md border border-white/20"
|
||||
: "bg-white border border-gray-100 shadow-[var(--shadow-sm)] hover:shadow-[var(--shadow-lg)]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Image Card ── */
|
||||
interface ImageCardProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ImageCard({ src, alt, title, subtitle, badge, className }: ImageCardProps) {
|
||||
return (
|
||||
<Card className={cn("group cursor-pointer", className)}>
|
||||
<div className="relative h-64 overflow-hidden">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-108"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-400" />
|
||||
{badge && (
|
||||
<span className="absolute top-4 left-4 text-[10px] uppercase tracking-[0.18em] font-semibold px-3 py-1 bg-[var(--color-moy-gold)] text-[var(--color-moy-dark)] rounded-full">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 border-t-[1.5px] border-transparent group-hover:border-[var(--color-moy-gold)] transition-[border-color] duration-300">
|
||||
<h3 className="text-xl font-serif text-[var(--color-moy-dark)] mb-2 leading-snug">{title}</h3>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-gray-500 leading-relaxed">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { Button } from "./button";
|
||||
export { Badge } from "./badge";
|
||||
export { SectionHeader } from "./section-header";
|
||||
export { Card, ImageCard } from "./card";
|
||||
export { Input, Textarea } from "./input";
|
||||
export { AnimatedNumber } from "./animated-number";
|
||||
export { ShimmerButton } from "./shimmer-button";
|
||||
export { Marquee } from "./marquee";
|
||||
export { TextReveal } from "./text-reveal";
|
||||
@@ -0,0 +1,68 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/* ── Input ── */
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, label, error, id, ...props }, ref) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label htmlFor={id} className="text-[10px] font-medium uppercase tracking-[0.16em] text-gray-500">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cn(
|
||||
"w-full px-4 py-3.5 bg-white border border-gray-200 text-[var(--color-moy-dark)] text-sm",
|
||||
"focus:outline-none focus:ring-2 focus:ring-[var(--color-moy-gold)]/40 focus:border-[var(--color-moy-gold)]",
|
||||
"transition-all duration-200 placeholder:text-gray-300",
|
||||
"rounded-none", // sharp corners = editorial feel
|
||||
error && "border-red-300 focus:ring-red-200/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span className="text-xs text-red-500">{error}</span>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
/* ── Textarea ── */
|
||||
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, label, error, id, ...props }, ref) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label htmlFor={id} className="text-[10px] font-medium uppercase tracking-[0.16em] text-gray-500">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cn(
|
||||
"w-full px-4 py-3.5 bg-white border border-gray-200 text-[var(--color-moy-dark)] text-sm resize-none",
|
||||
"focus:outline-none focus:ring-2 focus:ring-[var(--color-moy-gold)]/40 focus:border-[var(--color-moy-gold)]",
|
||||
"transition-all duration-200 placeholder:text-gray-300",
|
||||
"rounded-none",
|
||||
error && "border-red-300 focus:ring-red-200/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span className="text-xs text-red-500">{error}</span>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarqueeProps {
|
||||
items: string[];
|
||||
separator?: string;
|
||||
speed?: "slow" | "normal" | "fast";
|
||||
reverse?: boolean;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
separatorClassName?: string;
|
||||
}
|
||||
|
||||
const speeds = { slow: "42s", normal: "28s", fast: "16s" };
|
||||
|
||||
export function Marquee({
|
||||
items,
|
||||
separator = "✦",
|
||||
speed = "normal",
|
||||
reverse = false,
|
||||
className,
|
||||
itemClassName,
|
||||
separatorClassName,
|
||||
}: MarqueeProps) {
|
||||
// Double for seamless infinite loop
|
||||
const doubled = [...items, ...items];
|
||||
|
||||
return (
|
||||
<div className={cn("overflow-hidden whitespace-nowrap select-none", className)}>
|
||||
<div
|
||||
className="inline-flex"
|
||||
style={{
|
||||
animation: `marquee ${speeds[speed]} linear infinite${reverse ? " reverse" : ""}`,
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
{doubled.map((item, i) => (
|
||||
<span key={i} className="inline-flex items-center">
|
||||
<span className={cn("px-7 text-[11px] font-medium uppercase tracking-[0.18em]", itemClassName)}>
|
||||
{item}
|
||||
</span>
|
||||
<span className={cn("opacity-50 text-sm", separatorClassName)}>
|
||||
{separator}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
eyebrow?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
light?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
subtitle,
|
||||
eyebrow,
|
||||
align = "center",
|
||||
light = false,
|
||||
className,
|
||||
}: SectionHeaderProps) {
|
||||
const alignCls = {
|
||||
left: "items-start text-left",
|
||||
center: "items-center text-center",
|
||||
right: "items-end text-right",
|
||||
}[align];
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col mb-16", alignCls, className)}>
|
||||
{eyebrow && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[10px] font-medium uppercase tracking-[0.22em] mb-5",
|
||||
light ? "text-[var(--color-moy-gold-light)]" : "text-[var(--color-moy-gold-dark)]"
|
||||
)}
|
||||
>
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h2
|
||||
className={cn(
|
||||
"text-4xl md:text-[3.25rem] font-serif leading-tight mb-5",
|
||||
light ? "text-white" : "text-[var(--color-moy-dark)]"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"moy-gold-line",
|
||||
align === "center" && "mx-auto",
|
||||
align === "right" && "ml-auto"
|
||||
)}
|
||||
/>
|
||||
|
||||
{subtitle && (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-6 text-base md:text-lg leading-relaxed max-w-2xl",
|
||||
align === "center" && "mx-auto",
|
||||
light ? "text-gray-300/80" : "text-gray-500"
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ShimmerButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
shimmerColor?: string;
|
||||
background?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ShimmerButton = forwardRef<HTMLButtonElement, ShimmerButtonProps>(
|
||||
(
|
||||
{
|
||||
shimmerColor = "rgba(201,169,110,0.55)",
|
||||
background = "var(--color-moy-dark)",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group relative inline-flex items-center justify-center overflow-hidden",
|
||||
"px-10 py-4 text-[11px] font-medium uppercase tracking-[0.2em] text-white",
|
||||
"transition-all duration-300",
|
||||
className
|
||||
)}
|
||||
style={{ background }}
|
||||
{...props}
|
||||
>
|
||||
{/* Animated shimmer strip */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background: `linear-gradient(105deg, transparent 35%, ${shimmerColor} 50%, transparent 65%)`,
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer 2.8s linear infinite",
|
||||
}}
|
||||
/>
|
||||
{/* Inset bg to sit above shimmer */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-[1px]"
|
||||
style={{ background }}
|
||||
/>
|
||||
<span className="relative">{children}</span>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
ShimmerButton.displayName = "ShimmerButton";
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TextRevealProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
once?: boolean;
|
||||
stagger?: number;
|
||||
}
|
||||
|
||||
export function TextReveal({
|
||||
text,
|
||||
className,
|
||||
delay = 0,
|
||||
once = true,
|
||||
stagger = 0.055,
|
||||
}: TextRevealProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const isInView = useInView(ref, { once, margin: "-60px" });
|
||||
const words = text.split(" ");
|
||||
|
||||
return (
|
||||
<span ref={ref} className={cn("inline", className)}>
|
||||
{words.map((word, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="inline-block mr-[0.28em] last:mr-0"
|
||||
initial={{ opacity: 0, y: 22, filter: "blur(4px)" }}
|
||||
animate={
|
||||
isInView
|
||||
? { opacity: 1, y: 0, filter: "blur(0px)" }
|
||||
: { opacity: 0, y: 22, filter: "blur(4px)" }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.55,
|
||||
delay: delay + i * stagger,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user