chore: transfer repo
This commit is contained in:
77
components/ui/AddToCartButton.tsx
Normal file
77
components/ui/AddToCartButton.tsx
Normal 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
74
components/ui/Button.tsx
Normal 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
76
components/ui/Card.tsx
Normal 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 }
|
||||
83
components/ui/Carousel.tsx
Normal file
83
components/ui/Carousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
components/ui/Checkbox.tsx
Normal file
93
components/ui/Checkbox.tsx
Normal 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';
|
||||
62
components/ui/ColorSelector.tsx
Normal file
62
components/ui/ColorSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
components/ui/ContactForm.tsx
Normal file
202
components/ui/ContactForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
components/ui/CustomCard.tsx
Normal file
39
components/ui/CustomCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
components/ui/Newsletter.tsx
Normal file
124
components/ui/Newsletter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
components/ui/ProductCard.tsx
Normal file
157
components/ui/ProductCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
components/ui/RadioButton.tsx
Normal file
86
components/ui/RadioButton.tsx
Normal 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
45
components/ui/Section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
components/ui/SectionHeader.tsx
Normal file
26
components/ui/SectionHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
components/ui/StarRating.tsx
Normal file
40
components/ui/StarRating.tsx
Normal 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>;
|
||||
}
|
||||
182
components/ui/Typography.tsx
Normal file
182
components/ui/Typography.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user