chore: transfer repo

This commit is contained in:
Danijel
2026-01-19 20:21:14 +01:00
commit 7d2fb0c737
213 changed files with 18085 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
'use client';
import { useTranslation } from "@/lib/hooks/useTranslation";
import { addItem } from "components/cart/actions";
import { useCart } from "components/cart/cart-context";
import { Product, ProductVariant } from "lib/shopify/types";
import { useState } from "react";
import { Button } from "./Button";
interface AddToCartButtonProps {
product: Product;
className?: string;
variantId?: string;
}
export function AddToCartButton({ product, className = "", variantId }: AddToCartButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const { addCartItem } = useCart();
const { t } = useTranslation();
const handleAddToCart = async () => {
if (isLoading) return;
setIsLoading(true);
// Get the selected variant or default to the first variant
let selectedVariant: ProductVariant | undefined;
if (variantId) {
selectedVariant = product.variants.find(v => v.id === variantId);
}
// If no specified variant or variant not found, use the first one
if (!selectedVariant) {
selectedVariant = product.variants[0];
}
if (!selectedVariant || !selectedVariant.id) {
setIsLoading(false);
return;
}
try {
// Add to backend cart
await addItem(null, selectedVariant.id, 1);
// Update local cart state
addCartItem(selectedVariant, product, 1);
// Set success message or notification here if needed
} catch (error) {
console.error("Failed to add item to cart:", error);
// Handle error (could show error message)
} finally {
setTimeout(() => {
setIsLoading(false);
}, 500);
}
};
return (
<Button
onClick={handleAddToCart}
disabled={isLoading || !product.availableForSale}
variant="primary"
size="lg"
className={`w-full ${className}`}
>
{isLoading
? t('product.adding')
: !product.availableForSale
? t('product.outOfStock')
: t('product.addToCart')
}
</Button>
);
}

74
components/ui/Button.tsx Normal file
View File

@@ -0,0 +1,74 @@
import Link from 'next/link';
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'outline' | 'primary' | 'secondary' | 'filled' | 'custom';
size?: 'sm' | 'md' | 'lg';
className?: string;
href?: string;
external?: boolean;
fullWidthMobile?: boolean;
fullWidth?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className = '', variant = 'default', size = 'md', children, href, external, fullWidthMobile, fullWidth, ...props }, ref) => {
const variantClasses = {
default: 'bg-white text-black border border-gray-200 hover:bg-gray-100',
outline: 'bg-transparent border border-gray-300 hover:bg-gray-100',
primary: 'bg-primary text-white hover:bg-primary-dark',
secondary: 'bg-blue-600 text-white hover:bg-blue-700',
filled: 'bg-primary text-white hover:bg-primary-dark',
custom: 'bg-[#f8dbe3] text-primary hover:bg-primary hover:text-white border-0',
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2',
lg: 'px-[24px] py-[12px] text-lg',
};
const widthClass = fullWidth
? 'w-full'
: fullWidthMobile
? 'w-full md:w-auto'
: '';
const buttonClasses = `
${variantClasses[variant]}
${sizeClasses[size]}
${widthClass}
${className}
rounded-button font-bold transition-colors focus:outline-none inline-flex justify-center items-center
`;
if (href) {
const linkProps = external ? {
target: "_blank",
rel: "noopener noreferrer"
} : {};
return (
<Link
href={href}
{...linkProps}
className={buttonClasses}
>
{children}
</Link>
);
}
return (
<button
ref={ref}
className={buttonClasses}
{...props}
>
{children}
</button>
);
}
);
Button.displayName = 'Button';

76
components/ui/Card.tsx Normal file
View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,83 @@
'use client';
import { ReactNode, useEffect, useState } from 'react';
export interface CarouselIndicatorsProps {
totalSlides: number;
activeIndex: number;
onSelect: (index: number) => void;
className?: string;
}
export function CarouselIndicators({ totalSlides, activeIndex, onSelect, className = '' }: CarouselIndicatorsProps) {
return (
<div className={`flex justify-center gap-2 z-20 ${className}`}>
{Array.from({ length: totalSlides }).map((_, index) => (
<button
key={index}
className={`w-2 h-2 rounded-full transition-colors ${
index === activeIndex ? "bg-black" : "bg-white"
}`}
onClick={() => onSelect(index)}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
);
}
export interface CarouselProps {
slides: ReactNode[];
autoPlay?: boolean;
interval?: number;
showIndicators?: boolean;
indicatorsClassName?: string;
className?: string;
}
export function Carousel({
slides,
autoPlay = true,
interval = 5000,
showIndicators = true,
indicatorsClassName = '',
className = ''
}: CarouselProps) {
const [activeSlide, setActiveSlide] = useState(0);
// Auto-advance carousel if autoPlay is enabled
useEffect(() => {
if (!autoPlay) return;
const timer = setInterval(() => {
setActiveSlide((prev) => (prev === slides.length - 1 ? 0 : prev + 1));
}, interval);
return () => clearInterval(timer);
}, [autoPlay, interval, slides.length]);
return (
<div className={`relative w-full h-full ${className}`}>
{/* All slides need to be absolutely positioned */}
{slides.map((slide, index) => (
<div
key={index}
className={`absolute inset-0 w-full h-full transition-opacity duration-500 ${
index === activeSlide ? "opacity-100 z-10" : "opacity-0 z-0"
}`}
>
{slide}
</div>
))}
{showIndicators && slides.length > 1 && (
<CarouselIndicators
totalSlides={slides.length}
activeIndex={activeSlide}
onSelect={setActiveSlide}
className={indicatorsClassName}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import React, { forwardRef, useState } from 'react';
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
className?: string;
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ label, className = '', onChange, ...props }, ref) => {
// Keep track of focus state for styling
const [isFocused, setIsFocused] = useState(false);
// Handle click on the custom checkbox
const handleClick = () => {
if (props.disabled) return;
// Create a synthetic event to pass to the onChange handler
const syntheticEvent = {
target: {
name: props.name,
checked: !props.checked,
type: 'checkbox'
}
} as React.ChangeEvent<HTMLInputElement>;
// Call the onChange handler with the synthetic event
if (onChange) {
onChange(syntheticEvent);
}
};
return (
<div className={`flex items-center ${className}`}>
<div
className="relative flex items-center cursor-pointer"
onClick={handleClick}
>
<input
type="checkbox"
ref={ref}
className="sr-only"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={onChange}
{...props}
/>
<div
className={`
h-5 w-5 rounded-sm flex items-center justify-center
${props.checked
? 'bg-primary border-primary'
: 'bg-white border-gray-300'
}
${isFocused ? 'ring-2 ring-primary/30' : ''}
${props.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
border transition-colors
`}
>
{props.checked && (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="w-3 h-3 text-white"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
</div>
{label && (
<label
htmlFor={props.id}
className={`ml-2 text-sm ${props.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
onClick={!props.disabled ? handleClick : undefined}
>
{label}
</label>
)}
</div>
);
}
);
Checkbox.displayName = 'Checkbox';

View File

@@ -0,0 +1,62 @@
'use client';
import { ColorVariant } from '@/components/products/utils/colorUtils';
interface ColorSelectorProps {
colors: string[] | ColorVariant[];
selectedColor: string | null;
onColorSelect?: (color: string) => void;
readonly?: boolean;
}
export function ColorSelector({
colors,
selectedColor,
onColorSelect,
readonly = false
}: ColorSelectorProps) {
// Check if we're dealing with ColorVariant[] or string[]
const isVariantArray = colors.length > 0 && typeof colors[0] !== 'string';
// Helper to get color from either a string or ColorVariant
const getColor = (item: string | ColorVariant): string => {
if (typeof item === 'string') return item;
return item.color;
};
// Helper to get color name or just use the hex code
const getColorName = (item: string | ColorVariant): string => {
if (typeof item === 'string') return item;
return item.colorName || item.color;
};
return (
<div className="flex items-center mt-2 mb-3">
{colors.map((colorItem, index) => {
const color = getColor(colorItem);
const colorName = getColorName(colorItem);
const isSelected = selectedColor === color;
const zIndex = isSelected ? 30 : 20 - index;
const marginLeft = index === 0 ? '0' : '-10px';
return (
<div
key={index}
className={`rounded-full transition-all duration-200 relative ${readonly ? '' : 'cursor-pointer'}`}
style={{
backgroundColor: color,
width: isSelected ? '42px' : '32px',
height: isSelected ? '42px' : '32px',
zIndex,
marginLeft,
borderWidth: isSelected ? '0px' : '0px'
}}
onClick={() => !readonly && onColorSelect && onColorSelect(color)}
aria-label={readonly ? `Color: ${colorName}` : `Select color ${colorName}`}
role={readonly ? 'presentation' : 'button'}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,202 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Checkbox } from '@/components/ui/Checkbox';
import { Heading, Label, Text } from '@/components/ui/Typography';
import { useState } from 'react';
interface ContactFormProps {
title?: string;
subtitle?: string;
}
export function ContactForm({ title = 'Kontaktiraj nas', subtitle = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' }: ContactFormProps) {
// Form state
const [formData, setFormData] = useState({
name: '',
email: '',
message: '',
acceptTerms: false
});
// Validation errors
const [errors, setErrors] = useState<Record<string, string>>({});
// Form submission status
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
// Handle input changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
// Handle checkbox
if (type === 'checkbox') {
const checked = (e.target as HTMLInputElement).checked;
setFormData(prev => ({ ...prev, [name]: checked }));
} else {
setFormData(prev => ({ ...prev, [name]: value }));
}
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
// Handle checkbox change specifically
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, checked } = e.target;
setFormData(prev => ({ ...prev, [name]: checked }));
// Clear error when user changes checkbox
if (errors[name]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
// Validate form
const validateForm = () => {
const newErrors: Record<string, string> = {};
// Name validation
if (!formData.name.trim()) {
newErrors.name = 'Molimo unesite vaše ime i prezime';
}
// Email validation
if (!formData.email.trim()) {
newErrors.email = 'Molimo unesite vašu email adresu';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Molimo unesite ispravnu email adresu';
}
// Message validation
if (!formData.message.trim()) {
newErrors.message = 'Molimo unesite vašu poruku';
} else if (formData.message.trim().length < 10) {
newErrors.message = 'Vaša poruka mora sadržavati barem 10 znakova';
}
// Terms validation
if (!formData.acceptTerms) {
newErrors.acceptTerms = 'Molimo prihvatite uvjete korištenja';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle form submission
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
setIsSubmitting(true);
// Simulate API call
setTimeout(() => {
setIsSubmitting(false);
setIsSubmitted(true);
// Reset form after submission
setFormData({
name: '',
email: '',
message: '',
acceptTerms: false
});
// Hide success message after 5 seconds
setTimeout(() => {
setIsSubmitted(false);
}, 5000);
}, 1000);
}
};
return (
<div>
<Heading level={2} className="mb-4">{title}</Heading>
<Text size="lg" className="mb-8">{subtitle}</Text>
{isSubmitted ? (
<div className="p-4 bg-green-50 border border-green-200 rounded-md mb-6">
<Text className="text-green-700 font-medium">Vaša poruka je uspješno poslana!</Text>
<Text className="text-green-600">Hvala vam na vašem upitu. Odgovorit ćemo vam u najkraćem mogućem roku.</Text>
</div>
) : null}
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<Label htmlFor="name" className="block mb-2 text-[16px] leading-[24px]">Ime i Prezime</Label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className={`w-full p-3 border ${errors.name ? 'border-red-500' : 'border-gray-300'} rounded`}
/>
{errors.name && <Text size="sm" className="mt-1 text-red-500">{errors.name}</Text>}
</div>
<div>
<Label htmlFor="email" className="block mb-2 text-[16px] leading-[24px]">Email</Label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className={`w-full p-3 border ${errors.email ? 'border-red-500' : 'border-gray-300'} rounded`}
/>
{errors.email && <Text size="sm" className="mt-1 text-red-500">{errors.email}</Text>}
</div>
<div>
<Label htmlFor="message" className="block mb-2 text-[16px] leading-[24px]">Poruka</Label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
rows={5}
className={`w-full p-3 border ${errors.message ? 'border-red-500' : 'border-gray-300'} rounded`}
placeholder="Upišite vašu poruku..."
/>
{errors.message && <Text size="sm" className="mt-1 text-red-500">{errors.message}</Text>}
</div>
<div>
<Checkbox
id="acceptTerms"
name="acceptTerms"
checked={formData.acceptTerms}
onChange={handleCheckboxChange}
label="Prihvaćam uvjete korištenja"
className={errors.acceptTerms ? "ring-2 ring-red-500 rounded-sm" : ""}
/>
{errors.acceptTerms && <Text size="sm" className="mt-1 text-red-500">{errors.acceptTerms}</Text>}
</div>
<Button
type="submit"
variant="primary"
className="px-8 py-3 bg-black text-white"
disabled={isSubmitting}
>
{isSubmitting ? 'Slanje...' : 'Pošalji'}
</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import {
Card,
CardContent,
CardHeader,
CardTitle
} from "@/components/ui/Card";
import Image from "next/image";
interface CustomCardProps {
title: string;
description: string;
imageSrc: string;
imageAlt: string;
}
export function CustomCard({ title, description, imageSrc, imageAlt }: CustomCardProps) {
return (
<Card className="flex flex-col h-[492px] rounded-none shadow-none border-none bg-[#FAFAFA] overflow-hidden group">
<div className="w-full h-[269px] relative bg-white overflow-hidden">
<Image
src={imageSrc}
alt={imageAlt}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 33vw, 405px"
className="object-cover transition-transform duration-300 group-hover:scale-105"
priority
/>
</div>
<div className="flex flex-col flex-grow p-6">
<CardHeader className="p-0 mb-4">
<CardTitle className="text-[28px] leading-[36px] font-allenoire transition-colors duration-300 group-hover:text-primary">{title}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<p className="text-[16px] leading-[26px] line-clamp-4 font-poppins">{description}</p>
</CardContent>
</div>
</Card>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Heading, Text } from '@/components/ui/Typography';
import { useState } from 'react';
interface NewsletterProps {
title?: string;
subtitle?: string;
buttonText?: string;
disclaimer?: string;
}
export function Newsletter({
title = 'Join our newsletter',
subtitle = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
buttonText = 'Sign Up',
disclaimer = 'By clicking Sign Up you\'re confirming that you agree with our Terms and Conditions.'
}: NewsletterProps) {
// Form state
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
// Handle input change
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
if (error) setError('');
};
// Validate email
const validateEmail = () => {
if (!email.trim()) {
setError('Molimo unesite vašu email adresu');
return false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError('Molimo unesite ispravnu email adresu');
return false;
}
return true;
};
// Handle form submission
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validateEmail()) {
setIsSubmitting(true);
// Simulate API call
setTimeout(() => {
setIsSubmitting(false);
setIsSubmitted(true);
setEmail('');
// Reset success message after 5 seconds
setTimeout(() => {
setIsSubmitted(false);
}, 5000);
}, 1000);
}
};
return (
<div className="w-full">
<div className="flex flex-col md:flex-row items-start justify-between gap-8">
<div className="md:w-1/2">
<Heading level={3} className="mb-4">{title}</Heading>
<Text size="lg">{subtitle}</Text>
</div>
<div className="md:w-1/2 w-full">
{isSubmitted ? (
<div className="p-4 bg-green-50 border border-green-200 rounded-md animate-fade-in">
<div className="flex items-center mb-2">
<div className="mr-3 bg-green-500 rounded-full p-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<Text className="text-green-700 font-medium">Uspješno ste se pretplatili!</Text>
</div>
<Text className="text-green-600">Hvala na prijavi. Uskoro ćete primiti našu prvu newsletter poruku.</Text>
</div>
) : (
<form onSubmit={handleSubmit} className="w-full">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<input
type="email"
placeholder="Enter your email"
value={email}
onChange={handleChange}
className={`w-full p-3 border ${error ? 'border-red-500' : 'border-gray-300'} rounded`}
/>
</div>
{error && (
<Text size="sm" className="mt-1 text-red-500">{error}</Text>
)}
</div>
<Button
type="submit"
variant="primary"
className="bg-black text-white px-6 w-[120px] h-[48px]"
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : buttonText}
</Button>
</div>
{!isSubmitted && (
<Text className="mt-4 text-gray-500 text-[12px] leading-[14.4px]">
{disclaimer}
</Text>
)}
</form>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { AddToBoxButton } from '@/components/products/AddToBoxButton';
import { ColorVariant, getProductColorVariants } from '@/components/products/utils/colorUtils';
import { Product } from 'lib/shopify/types';
import Image from "next/image";
import Link from "next/link";
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { AddToCartButton } from './AddToCartButton';
import { ColorSelector } from './ColorSelector';
interface ProductCardProps {
title: string;
variant: string;
price: number;
imageSrc: string;
slug: string;
product?: Product;
colors?: string[]; // Array of color hex codes
}
export function ProductCard({ title, variant, price: defaultPrice, imageSrc, slug, product, colors }: ProductCardProps) {
const pathname = usePathname();
const isBuildBoxPage = pathname.includes('/build-box');
const [colorVariants, setColorVariants] = useState<ColorVariant[] | undefined>(undefined);
const [selectedColorHex, setSelectedColorHex] = useState<string | null>(null);
const [selectedVariant, setSelectedVariant] = useState<ColorVariant | undefined>(undefined);
const [currentPrice, setCurrentPrice] = useState<number>(defaultPrice);
const [isHovered, setIsHovered] = useState(false);
// Initialize color variants and selected color
useEffect(() => {
if (product) {
const variants = getProductColorVariants(product);
setColorVariants(variants);
if (variants && variants.length > 0) {
// Only set these values if we have valid variants
if (variants[0]?.color) {
setSelectedColorHex(variants[0].color);
}
setSelectedVariant(variants[0]);
if (typeof variants[0]?.price === 'number') {
setCurrentPrice(variants[0].price);
}
}
} else if (colors && colors.length > 0) {
// If we only have color hex codes without variants
const firstColor = colors[0];
if (firstColor) {
setSelectedColorHex(firstColor);
}
}
}, [product, colors]);
// Handle color selection
const handleColorSelect = (color: string) => {
setSelectedColorHex(color);
if (colorVariants) {
const variant = colorVariants.find(v => v.color === color);
if (variant) {
setSelectedVariant(variant);
setCurrentPrice(variant.price);
}
}
};
return (
<div className="flex flex-col h-full">
{/* Image container with fixed height and hover effect */}
<div
className="relative w-full h-[480px] bg-gray-100 mb-5 overflow-hidden"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Link href={`/product/${slug}`}>
<Image
src={imageSrc}
alt={title}
fill
className="object-cover transition-opacity duration-300"
sizes="(max-width: 768px) 280px, 405px"
/>
</Link>
{/* Button - always visible on mobile, hover effect on desktop */}
{product && (
<>
{/* Mobile button - always visible */}
<div className="absolute bottom-6 left-5 right-5 md:hidden">
{isBuildBoxPage ? (
<AddToBoxButton
productId={product.id}
name={product.title}
price={currentPrice}
image={imageSrc}
variantId={selectedVariant?.variantId}
color={selectedColorHex || undefined}
/>
) : (
<AddToCartButton
product={product}
variantId={selectedVariant?.variantId}
/>
)}
</div>
{/* Desktop button - visible on hover */}
<div className={`absolute bottom-6 left-5 right-5 hidden md:block transition-opacity duration-300 ${isHovered ? 'opacity-100' : 'opacity-0'}`}>
{isBuildBoxPage ? (
<AddToBoxButton
productId={product.id}
name={product.title}
price={currentPrice}
image={imageSrc}
variantId={selectedVariant?.variantId}
color={selectedColorHex || undefined}
/>
) : (
<AddToCartButton
product={product}
variantId={selectedVariant?.variantId}
/>
)}
</div>
</>
)}
</div>
<div className="flex justify-between items-start">
<h3 className="font-bold text-[18px] leading-[28px]">{title}</h3>
<span className="font-bold text-[20px] leading-[28px] ml-2">{currentPrice} </span>
</div>
{variant && (
<p className="text-[16px] leading-[26px] text-gray-600 mb-auto">{variant}</p>
)}
{colorVariants && colorVariants.length > 0 ? (
<ColorSelector
colors={colorVariants}
selectedColor={selectedColorHex}
onColorSelect={handleColorSelect}
/>
) : colors && colors.length > 0 ? (
<ColorSelector
colors={colors}
selectedColor={selectedColorHex}
onColorSelect={setSelectedColorHex}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import React, { forwardRef, useState } from 'react';
export interface RadioButtonProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
className?: string;
}
export const RadioButton = forwardRef<HTMLInputElement, RadioButtonProps>(
({ label, className = '', onChange, ...props }, ref) => {
// Keep track of focus state for styling
const [isFocused, setIsFocused] = useState(false);
// Handle click on the custom radio button
const handleClick = () => {
if (props.disabled) return;
// Only trigger onChange if the radio button is not already checked
if (!props.checked) {
// Create a synthetic event to pass to the onChange handler
const syntheticEvent = {
target: {
name: props.name,
checked: true,
type: 'radio',
value: props.value
}
} as React.ChangeEvent<HTMLInputElement>;
// Call the onChange handler with the synthetic event
if (onChange) {
onChange(syntheticEvent);
}
}
};
return (
<div className={`flex items-center ${className}`}>
<div
className="relative flex items-center cursor-pointer"
onClick={handleClick}
>
<input
type="radio"
ref={ref}
className="sr-only"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={onChange}
{...props}
/>
<div
className={`
h-5 w-5 rounded-full flex items-center justify-center
${props.checked
? 'border-primary'
: 'border-gray-300'
}
${isFocused ? 'ring-2 ring-primary/30' : ''}
${props.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
border-2 bg-white transition-colors
`}
>
{props.checked && (
<div className="w-3 h-3 rounded-full bg-primary"></div>
)}
</div>
</div>
{label && (
<label
htmlFor={props.id}
className={`ml-2 text-sm ${props.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
onClick={!props.disabled ? handleClick : undefined}
>
{label}
</label>
)}
</div>
);
}
);
RadioButton.displayName = 'RadioButton';

45
components/ui/Section.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { container } from '@/lib/utils';
import React from 'react';
export interface SectionProps {
children: React.ReactNode;
background?: 'white' | 'light' | 'dark' | 'pattern' | 'gradient';
spacing?: 'xs' | 'small' | 'medium' | 'large' | 'none';
fullWidth?: boolean;
className?: string;
}
export function Section({
children,
background = 'white',
spacing = 'medium',
fullWidth = false,
className = '',
}: SectionProps) {
// Background styles
const backgroundClasses = {
white: 'bg-white',
light: 'bg-gray-50',
dark: 'bg-gray-900 text-white',
pattern: 'bg-white bg-[url("/assets/patterns/dots.svg")] bg-repeat',
gradient: 'bg-gradient-to-b from-white to-gray-100',
};
const spacingClasses = {
none: 'py-0',
xs: 'py-[64px] md:py-[80px]',
small: 'py-6 md:py-8',
medium: 'py-[80px] md:py-[112px]',
large: 'py-[80px] md:py-[112px]',
};
return (
<section
className={`w-full ${backgroundClasses[background]} ${spacingClasses[spacing]} ${className}`}
>
<div className={fullWidth ? 'w-full' : container}>
{children}
</div>
</section>
);
}

View File

@@ -0,0 +1,26 @@
import { Heading, Text } from "./Typography";
interface SectionHeaderProps {
title: string;
description: string;
titleClassName?: string;
descriptionClassName?: string;
className?: string;
}
export function SectionHeader({
title,
description,
titleClassName = "",
descriptionClassName = "",
className = ""
}: SectionHeaderProps) {
return (
<div className={`max-w-[800px] ${className}`}>
<Heading level={2} className={`mb-4 ${titleClassName}`}>{title}</Heading>
<Text size="lg" className={descriptionClassName}>
{description}
</Text>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { FaRegStar, FaStar, FaStarHalfAlt } from "react-icons/fa";
interface StarRatingProps {
rating: number;
maxRating?: number;
color?: string;
size?: number;
}
export function StarRating({
rating,
maxRating = 5,
color = "#FFD700", // Gold color for stars
size = 20
}: StarRatingProps) {
// Create an array of star elements
const stars = [];
// Fill in full and half stars
for (let i = 1; i <= maxRating; i++) {
if (i <= rating) {
// Full star
stars.push(
<FaStar key={i} color={color} size={size} className="inline" />
);
} else if (i - 0.5 <= rating) {
// Half star
stars.push(
<FaStarHalfAlt key={i} color={color} size={size} className="inline" />
);
} else {
// Empty star
stars.push(
<FaRegStar key={i} color={color} size={size} className="inline" />
);
}
}
return <div className="flex">{stars}</div>;
}

View File

@@ -0,0 +1,182 @@
import clsx from 'clsx';
import React from 'react';
// Typography scale common props
interface TypographyProps {
children: React.ReactNode;
className?: string;
as?: React.ElementType;
}
// Heading component props
interface HeadingProps extends TypographyProps {
level?: 1 | 2 | 3 | 4;
align?: 'left' | 'center' | 'right';
}
export function Heading({
children,
className = '',
level = 2,
align = 'left',
as,
}: HeadingProps) {
const Component = as || `h${level}` as React.ElementType;
// Responsive sizes based on provided specs
const sizeClasses = {
// H1
// Desktop: 60px/76px, Mobile: 44px/52px
1: 'text-[44px] leading-[52px] lg:text-[60px] lg:leading-[76px] font-allenoire',
// H2
// Desktop: 48px/56px, Mobile: 36px/44px
2: 'text-[36px] leading-[44px] lg:text-[48px] lg:leading-[56px] font-allenoire',
// H3
// Desktop: 28px/36px, Mobile: 24px/32px
3: 'text-[24px] leading-[32px] lg:text-[28px] lg:leading-[36px] font-allenoire',
// H4
// Desktop: 20px/28px
4: 'text-[20px] leading-[28px] font-allenoire',
};
const alignClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
};
return (
<Component
className={clsx(
sizeClasses[level],
alignClasses[align],
'tracking-[0px]',
className
)}
>
{children}
</Component>
);
}
// Text component props
interface TextProps extends TypographyProps {
size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl';
weight?: 'regular' | 'semibold';
color?: 'default' | 'muted' | 'accent';
}
export function Text({
children,
className = '',
size = 'base',
weight = 'regular',
color = 'default',
as = 'p',
}: TextProps) {
const Component = as;
// Base size classes without font family
const sizeClasses = {
xs: 'text-[12px] leading-[20px]',
sm: 'text-[14px] leading-[22px]',
base: 'text-[16px] leading-[24px]',
lg: 'text-[16px] leading-[28px]',
xl: 'text-[20px] leading-[28px]',
};
// Use Poppins for all text, either regular or semibold
const fontClass = weight === 'regular' ? 'font-poppins' : 'font-poppins-semibold';
const colorClasses = {
default: 'text-black',
muted: 'text-gray-600',
accent: 'text-blue-600',
};
return (
<Component
className={clsx(
sizeClasses[size],
fontClass,
colorClasses[color],
'tracking-[0px]',
className
)}
>
{children}
</Component>
);
}
// Caption component props
interface CaptionProps extends TypographyProps {
size?: 'sm' | 'base';
}
export function Caption({
children,
className = '',
size = 'sm',
as = 'span',
}: CaptionProps) {
const Component = as;
const sizeClasses = {
sm: 'text-[12px] leading-[20px]',
base: 'text-[14px] leading-[22px]',
};
return (
<Component
className={clsx(
sizeClasses[size],
'text-gray-500',
className
)}
>
{children}
</Component>
);
}
// Label component props
interface LabelProps extends TypographyProps {
size?: 'sm' | 'base' | 'lg';
required?: boolean;
htmlFor?: string;
}
export function Label({
children,
className = '',
size = 'base',
required = false,
htmlFor,
as = 'label',
}: LabelProps) {
const Component = as;
const sizeClasses = {
sm: 'text-[14px] leading-[22px]',
base: 'text-[16px] leading-[24px]',
lg: 'text-[18px] leading-[28px]',
};
return (
<Component
htmlFor={htmlFor}
className={clsx(
sizeClasses[size],
'font-allenoire tracking-[0px]',
className
)}
>
{children}
{required && <span className="ml-1 text-red-500">*</span>}
</Component>
);
}