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,33 @@
'use client';
import { useTranslation } from "@/lib/hooks/useTranslation";
import { useRouter } from "next/navigation";
export function BackButton() {
const { t } = useTranslation();
const router = useRouter();
const handleGoBack = () => {
// Check if we can go back in history
if (window.history.length > 1) {
// Go back to previous page
router.back();
} else {
// Fallback to home page if there's no history
router.push('/');
}
};
return (
<button
onClick={handleGoBack}
className="flex items-center text-sm text-gray-600 hover:text-black"
aria-label={t('product.back')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('product.back')}
</button>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import { Heading } from '@/components/ui/Typography';
import { ReactNode } from 'react';
interface CollapsibleSectionProps {
title: string;
children: ReactNode;
isOpen?: boolean;
className?: string;
}
export function CollapsibleSection({
title,
children,
isOpen = false,
className = ''
}: CollapsibleSectionProps) {
return (
<details className={`group mb-4 ${className}`} open={isOpen}>
<summary className="flex cursor-pointer items-center justify-between py-2">
<Heading level={3} className="font-medium">{title}</Heading>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 transition-transform duration-200 group-open:rotate-180"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="pt-2 pb-6">
{children}
</div>
</details>
);
}

View File

@@ -0,0 +1,47 @@
"use client"
import { Heading, Label } from '@/components/ui/Typography';
import { AddToCart } from 'components/cart/add-to-cart';
import Price from 'components/price';
import Prose from 'components/prose';
import { Product } from 'lib/shopify/types';
import { useState } from 'react';
import { ProductQuantity } from './ProductQuantity';
import { VariantSelector } from './VariantSelector';
export function ProductDescription({ product }: { product: Product }) {
const [quantity, setQuantity] = useState(1);
return (
<div className="pt-5 overflow-hidden">
<div className="mb-6 flex flex-col pb-4 border-b">
<Heading level={1} className="mb-3">{product.title}</Heading>
<div className="mr-auto w-auto">
<Price
amount={product.priceRange.maxVariantPrice.amount}
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
className="text-xl"
/>
</div>
</div>
{product.descriptionHtml ? (
<Prose
className="mb-6 text-gray-600"
html={product.descriptionHtml}
/>
) : null}
<VariantSelector options={product.options} variants={product.variants} />
<div className="mt-8 mb-6">
<Label className="mb-2">Quantity</Label>
<ProductQuantity
onChange={setQuantity}
initialQuantity={1}
/>
</div>
<AddToCart product={product} quantity={quantity} />
</div>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import { CollapsibleSection } from './CollapsibleSection';
export function ProductDetailsSection() {
return (
<div className="mt-8 border-t pt-8">
<CollapsibleSection title="Details" isOpen={true}>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in
eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum
nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id
rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.
</p>
</CollapsibleSection>
<CollapsibleSection title="Shipping" className="border-t">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in
eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum
nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id
rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.
</p>
</CollapsibleSection>
<CollapsibleSection title="Returns" className="border-t">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in
eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum
nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id
rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.
</p>
</CollapsibleSection>
</div>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import { Image as ImageType } from 'lib/shopify/types';
import Image from 'next/image';
import { useState } from 'react';
interface ProductGalleryProps {
images: ImageType[];
}
export function ProductGallery({ images = [] }: ProductGalleryProps) {
const [selectedImage, setSelectedImage] = useState(0);
if (!images.length) {
return (
<div className="aspect-square w-full bg-gray-100 flex items-center justify-center">
<p className="text-gray-500">No image available</p>
</div>
);
}
return (
<div className="flex flex-col md:flex-row gap-3 md:items-start pt-5 w-full overflow-hidden">
{/* Thumbnails */}
<div className="order-2 md:order-1 flex md:flex-col gap-3 overflow-x-auto md:overflow-y-auto max-h-[600px] md:w-16 flex-shrink-0">
{images.map((image, index) => (
<button
key={`${image.url}-${index}`}
className={`relative h-14 w-14 flex-shrink-0 overflow-hidden border ${
selectedImage === index ? 'border-black' : 'border-gray-200'
}`}
onClick={() => setSelectedImage(index)}
aria-label={`View image ${index + 1} of ${images.length}`}
>
<Image
src={image.url}
alt={image.altText || ''}
fill
className="object-cover"
sizes="56px"
/>
</button>
))}
</div>
{/* Main image */}
<div className="order-1 md:order-2 relative w-full h-[500px] md:h-[550px] flex-grow overflow-hidden">
<div className="w-full h-full relative">
{images[selectedImage] && (
<img
src={images[selectedImage].url}
alt={images[selectedImage].altText || ''}
className="w-full h-full object-cover object-top"
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import React, { useState } from 'react';
interface ProductQuantityProps {
onChange: (quantity: number) => void;
initialQuantity?: number;
min?: number;
max?: number;
}
export function ProductQuantity({
onChange,
initialQuantity = 1,
min = 1,
max = 100
}: ProductQuantityProps) {
const [quantity, setQuantity] = useState(initialQuantity);
const handleIncrement = () => {
if (quantity < max) {
const newQuantity = quantity + 1;
setQuantity(newQuantity);
onChange(newQuantity);
}
};
const handleDecrement = () => {
if (quantity > min) {
const newQuantity = quantity - 1;
setQuantity(newQuantity);
onChange(newQuantity);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value >= min && value <= max) {
setQuantity(value);
onChange(value);
}
};
return (
<div className="flex items-center">
<button
onClick={handleDecrement}
type="button"
className="w-10 h-10 border border-gray-300 flex items-center justify-center text-lg font-medium text-gray-600 hover:bg-gray-50"
disabled={quantity <= min}
>
-
</button>
<input
type="text"
value={quantity}
onChange={handleInputChange}
className="w-16 h-10 border-t border-b border-gray-300 text-center focus:outline-none"
aria-label="Količina"
/>
<button
onClick={handleIncrement}
type="button"
className="w-10 h-10 border border-gray-300 flex items-center justify-center text-lg font-medium text-gray-600 hover:bg-gray-50"
disabled={quantity >= max}
>
+
</button>
</div>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import clsx from 'clsx';
import { useProduct, useUpdateURL } from 'components/product/product-context';
import { ProductOption, ProductVariant } from 'lib/shopify/types';
type Combination = {
id: string;
availableForSale: boolean;
[key: string]: string | boolean;
};
export function VariantSelector({
options,
variants
}: {
options: ProductOption[];
variants: ProductVariant[];
}) {
const { state, updateOption } = useProduct();
const updateURL = useUpdateURL();
const hasNoOptionsOrJustOneOption =
!options.length || (options.length === 1 && options[0]?.values.length === 1);
if (hasNoOptionsOrJustOneOption) {
return null;
}
const combinations: Combination[] = variants.map((variant) => ({
id: variant.id,
availableForSale: variant.availableForSale,
...variant.selectedOptions.reduce(
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
{}
)
}));
return options.map((option) => (
<form key={option.id}>
<dl className="mb-8">
<dt className="mb-4">{option.name}</dt>
<dd className="flex flex-wrap gap-3">
{option.values.map((value) => {
const optionNameLowerCase = option.name.toLowerCase();
// Base option params on current selectedOptions so we can preserve any other param state.
const optionParams = { ...state, [optionNameLowerCase]: value };
// Filter out invalid options and check if the option combination is available for sale.
const filtered = Object.entries(optionParams).filter(([key, value]) =>
options.find(
(option) => option.name.toLowerCase() === key && option.values.includes(value)
)
);
const isAvailableForSale = combinations.find((combination) =>
filtered.every(
([key, value]) => combination[key] === value && combination.availableForSale
)
);
// The option is active if it's in the selected options.
const isActive = state[optionNameLowerCase] === value;
return (
<button
formAction={() => {
const newState = updateOption(optionNameLowerCase, value);
updateURL(newState);
}}
key={value}
aria-disabled={!isAvailableForSale}
disabled={!isAvailableForSale}
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
className={clsx(
'flex min-w-[48px] items-center justify-center px-2 py-1',
{
'cursor-default ring-2': isActive,
'ring-1 ring-transparent':
!isActive && isAvailableForSale,
'relative z-10 cursor-not-allowed overflow-hidden':
!isAvailableForSale
}
)}
>
{value}
</button>
);
})}
</dd>
</dl>
</form>
));
}

View File

@@ -0,0 +1,92 @@
'use client';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import { GridTileImage } from 'components/grid/tile';
import { useProduct, useUpdateURL } from 'components/product/product-context';
import Image from 'next/image';
export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
const { state, updateImage } = useProduct();
const updateURL = useUpdateURL();
const imageIndex = state.image ? parseInt(state.image) : 0;
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
const buttonClassName =
'h-full px-6 flex items-center justify-center';
return (
<form>
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
{images[imageIndex] && (
<Image
className="h-full w-full object-contain"
fill
sizes="(min-width: 1024px) 66vw, 100vw"
alt={images[imageIndex]?.altText as string}
src={images[imageIndex]?.src as string}
priority={true}
/>
)}
{images.length > 1 ? (
<div className="absolute bottom-[15%] flex w-full justify-center">
<div className="mx-auto flex h-11 items-center">
<button
formAction={() => {
const newState = updateImage(previousImageIndex.toString());
updateURL(newState);
}}
aria-label="Previous product image"
className={buttonClassName}
>
<ArrowLeftIcon className="h-5" />
</button>
<div className="mx-1 h-6 w-px "></div>
<button
formAction={() => {
const newState = updateImage(nextImageIndex.toString());
updateURL(newState);
}}
aria-label="Next product image"
className={buttonClassName}
>
<ArrowRightIcon className="h-5" />
</button>
</div>
</div>
) : null}
</div>
{images.length > 1 ? (
<ul className="my-12 flex items-center flex-wrap justify-center gap-2 overflow-auto py-1 lg:mb-0">
{images.map((image, index) => {
const isActive = index === imageIndex;
return (
<li key={image.src} className="h-20 w-20">
<button
formAction={() => {
const newState = updateImage(index.toString());
updateURL(newState);
}}
aria-label="Select product image"
className="h-full w-full"
>
<GridTileImage
alt={image.altText}
src={image.src}
width={80}
height={80}
active={isActive}
/>
</button>
</li>
);
})}
</ul>
) : null}
</form>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { createContext, useContext, useMemo, useOptimistic } from 'react';
type ProductState = {
[key: string]: string;
} & {
image?: string;
};
type ProductContextType = {
state: ProductState;
updateOption: (name: string, value: string) => ProductState;
updateImage: (index: string) => ProductState;
};
const ProductContext = createContext<ProductContextType | undefined>(undefined);
export function ProductProvider({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams();
const getInitialState = () => {
const params: ProductState = {};
for (const [key, value] of searchParams.entries()) {
params[key] = value;
}
return params;
};
const [state, setOptimisticState] = useOptimistic(
getInitialState(),
(prevState: ProductState, update: ProductState) => ({
...prevState,
...update
})
);
const updateOption = (name: string, value: string) => {
const newState = { [name]: value };
setOptimisticState(newState);
return { ...state, ...newState };
};
const updateImage = (index: string) => {
const newState = { image: index };
setOptimisticState(newState);
return { ...state, ...newState };
};
const value = useMemo(
() => ({
state,
updateOption,
updateImage
}),
[state]
);
return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>;
}
export function useProduct() {
const context = useContext(ProductContext);
if (context === undefined) {
throw new Error('useProduct must be used within a ProductProvider');
}
return context;
}
export function useUpdateURL() {
const router = useRouter();
return (state: ProductState) => {
const newParams = new URLSearchParams(window.location.search);
Object.entries(state).forEach(([key, value]) => {
newParams.set(key, value);
});
router.push(`?${newParams.toString()}`, { scroll: false });
};
}