chore: transfer repo
This commit is contained in:
33
components/product/BackButton.tsx
Normal file
33
components/product/BackButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
components/product/CollapsibleSection.tsx
Normal file
38
components/product/CollapsibleSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
components/product/ProductDescription.tsx
Normal file
47
components/product/ProductDescription.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
components/product/ProductDetailsSection.tsx
Normal file
36
components/product/ProductDetailsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
components/product/ProductGallery.tsx
Normal file
60
components/product/ProductGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
components/product/ProductQuantity.tsx
Normal file
71
components/product/ProductQuantity.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
components/product/VariantSelector.tsx
Normal file
93
components/product/VariantSelector.tsx
Normal 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>
|
||||
));
|
||||
}
|
||||
92
components/product/gallery.tsx
Normal file
92
components/product/gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
components/product/product-context.tsx
Normal file
81
components/product/product-context.tsx
Normal 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 });
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user