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

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
COMPANY_NAME="Vercel Inc."
TWITTER_CREATOR="@vercel"
TWITTER_SITE="https://nextjs.org/commerce"
SITE_NAME="Next.js Commerce"
SHOPIFY_REVALIDATION_SECRET=""
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"

View File

@@ -0,0 +1,34 @@
name: Build and Deploy
on: workflow_dispatch
inputs:
user_version:
description: 'Custom version (e.g., 1.2.3)'
required: true
default: '1.0.0'
jobs:
deploy:
runs-on: sent-shop-vps
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Calculate version
id: version
run: |
SHORT_SHA=$(git rev-parse --short=7 HEAD)
APP_VERSION=${{ inputs.user_version }}-${SHORT_SHA}
echo "Using version: ${APP_VERSION}"
- name: Build and deploy
env:
COMPOSE_PROJECT_NAME: sent-shop # Unique identifier, must be kept not to overwrite other composes.
APP_VERSION: ${{ env.APP_VERSION }}
SHOPIFY_REVALIDATION_SECRET: ${{ secrets.SHOPIFY_REVALIDATION_SECRET }}
SHOPIFY_STOREFRONT_ACCESS_TOKEN: ${{ secrets.SHOPIFY_STOREFRONT_ACCESS_TOKEN }}
run: |
docker-compose build
docker-compose down
docker-compose up -d
echo "New version is up and running

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
.playwright
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

28
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

9
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

61
Dockerfile Normal file
View File

@@ -0,0 +1,61 @@
# syntax = docker/dockerfile:1
FROM node:22-slim AS base
ARG PORT=3005
ARG COMPANY_NAME
ARG TWITTER_CREATOR
ARG TWITTER_SITE
ARG SITE_NAME
ARG SHOPIFY_REVALIDATION_SECRET
ARG SHOPIFY_STOREFRONT_ACCESS_TOKEN
ARG SHOPIFY_STORE_DOMAIN
ENV PORT=${PORT}
ENV COMPANY_NAME=${COMPANY_NAME}
ENV TWITTER_CREATOR=${TWITTER_CREATOR}
ENV TWITTER_SITE=${TWITTER_SITE}
ENV SITE_NAME=${SITE_NAME}
ENV SHOPIFY_REVALIDATION_SECRET=${SHOPIFY_REVALIDATION_SECRET}
ENV SHOPIFY_STOREFRONT_ACCESS_TOKEN=${SHOPIFY_STOREFRONT_ACCESS_TOKEN}
ENV SHOPIFY_STORE_DOMAIN=${SHOPIFY_STORE_DOMAIN}
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
# Dependencies
FROM base AS dependencies
COPY package.json package-lock.json ./
RUN npm ci
# Build
FROM base AS build
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
#Should be set to npm run build.
RUN npm run build
# Run
FROM base AS run
ENV NODE_ENV=production
ENV PORT=$PORT
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=build /app/public ./public
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE $PORT
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME,TWITTER_CREATOR,TWITTER_SITE)
# Next.js Commerce
A high-performance, server-rendered Next.js App Router ecommerce application.
This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more.
<h3 id="v1-note"></h3>
> Note: Looking for Next.js Commerce v1? View the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), and [release notes](https://github.com/vercel/commerce/releases/tag/v1).
## Providers
Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966).
Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
- Shopify (this repository)
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
- [Ecwid by Lightspeed](https://github.com/Ecwid/ecwid-nextjs-commerce/) ([Demo](https://ecwid-nextjs-commerce.vercel.app/))
- [Geins](https://github.com/geins-io/vercel-nextjs-commerce) ([Demo](https://geins-nextjs-commerce-starter.vercel.app/))
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
- [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/))
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
- [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/))
- [Wix](https://github.com/wix/nextjs-commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
- [Fourthwall](https://github.com/FourthwallHQ/vercel-commerce) ([Demo](https://vercel-storefront.fourthwall.app/))
> Note: Providers, if you are looking to use similar products for your demo, you can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing).
## Integrations
Integrations enable upgraded or additional functionality for Next.js Commerce
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
- [React Bricks](https://github.com/ReactBricks/nextjs-commerce-rb) ([Demo](https://nextjs-commerce.reactbricks.com/))
- Edit pages, product details, and footer content visually using [React Bricks](https://www.reactbricks.com) visual headless CMS.
## Running locally
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
1. Install Vercel CLI: `npm i -g vercel`
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
3. Download your environment variables: `vercel env pull`
```bash
pnpm install
pnpm dev
```
Your app should now be running on [localhost:3000](http://localhost:3000/).
<details>
<summary>Expand if you work at Vercel and want to run locally and / or contribute</summary>
1. Run `vc link`.
1. Select the `Vercel Solutions` scope.
1. Connect to the existing `commerce-shopify` project.
1. Run `vc env pull` to get environment variables.
1. Run `pnpm dev` to ensure everything is working correctly.
</details>
## Vercel, Next.js Commerce, and Shopify Integration Guide
You can use this comprehensive [integration guide](https://vercel.com/docs/integrations/ecommerce/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel.

12
app/[page]/layout.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { Footer } from 'components/layout/footer';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="w-full">
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">{children}</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,11 @@
import { getPage } from 'lib/shopify';
import OpengraphImage from '../../components/opengraph-image';
export const runtime = 'edge';
export default async function Image({ params }: { params: { page: string } }) {
const page = await getPage(params.page);
const title = page.seo?.title || page.title;
return await OpengraphImage({ title });
}

45
app/[page]/page.tsx Normal file
View File

@@ -0,0 +1,45 @@
import type { Metadata } from 'next';
import Prose from 'components/prose';
import { getPage } from 'lib/shopify';
import { notFound } from 'next/navigation';
export async function generateMetadata(props: {
params: Promise<{ page: string }>;
}): Promise<Metadata> {
const params = await props.params;
const page = await getPage(params.page);
if (!page) return notFound();
return {
title: page.seo?.title || page.title,
description: page.seo?.description || page.bodySummary,
openGraph: {
publishedTime: page.createdAt,
modifiedTime: page.updatedAt,
type: 'article'
}
};
}
export default async function Page(props: { params: Promise<{ page: string }> }) {
const params = await props.params;
const page = await getPage(params.page);
if (!page) return notFound();
return (
<>
<h1 className="mb-8">{page.title}</h1>
<Prose className="mb-8" html={page.body} />
<p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(page.updatedAt))}.`}
</p>
</>
);
}

11
app/about/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { AboutPageContent } from '@/components/about/AboutPageContent';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About Us | Sent',
description: 'Learn more about Sent and our mission to create personalized gift boxes for every occasion.'
};
export default function AboutPage() {
return <AboutPageContent />;
}

21
app/api/products/route.ts Normal file
View File

@@ -0,0 +1,21 @@
import { getBoxProducts, getProducts } from 'lib/shopify';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
try {
// Get query parameters
const { searchParams } = new URL(request.url);
const type = searchParams.get('type');
if (type === 'boxes') {
const boxProducts = await getBoxProducts({});
return NextResponse.json({ products: boxProducts });
} else {
const allProducts = await getProducts({});
return NextResponse.json({ products: allProducts });
}
} catch (error) {
console.error('Error fetching products:', error);
return NextResponse.json({ error: 'Failed to fetch products' }, { status: 500 });
}
}

View File

@@ -0,0 +1,6 @@
import { revalidate } from 'lib/shopify';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest): Promise<NextResponse> {
return revalidate(req);
}

View File

@@ -0,0 +1,10 @@
import { BuildBoxCustomizePage } from "@/components/build-box/BuildBoxCustomizePage";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Choose Your Box | Sent",
description: "Select the perfect box design to complete your custom gift box."
};
export default function BuildBoxCustomizeRoute() {
return <BuildBoxCustomizePage />;
}

11
app/build-box/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { BuildBoxPage } from "@/components/build-box/BuildBoxPage";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Build Your Box | Sent",
description: "Create your custom gift box by selecting products you'd like to include."
};
export default function BuildBoxRoute() {
return <BuildBoxPage />;
}

10
app/cart/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
import CartPage from 'components/cart/CartPage';
export const metadata = {
title: 'Cart',
description: 'View your shopping cart and proceed to checkout.',
};
export default function Cart() {
return <CartPage />;
}

19
app/error.tsx Normal file
View File

@@ -0,0 +1,19 @@
'use client';
export default function Error({ reset }: { reset: () => void }) {
return (
<div className="mx-auto my-4 flex max-w-xl flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 dark:border-neutral-800 dark:bg-black">
<h2 className="text-xl font-bold">Oh no!</h2>
<p className="my-2">
There was an issue with our storefront. This could be a temporary issue, please try your
action again.
</p>
<button
className="mx-auto mt-4 flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90"
onClick={() => reset()}
>
Try Again
</button>
</div>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

28
app/fonts.css Normal file
View File

@@ -0,0 +1,28 @@
/* AlleNoire fonts */
/* Regular */
@font-face {
font-family: 'AlleNoire';
src: url('/fonts/allenoire-allenoire-regular-400.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Poppins fonts */
/* Regular */
@font-face {
font-family: 'Poppins Regular';
src: url('/fonts/Poppins-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* SemiBold */
@font-face {
font-family: 'Poppins SemiBold';
src: url('/fonts/Poppins-SemiBold.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}

153
app/globals.css Normal file
View File

@@ -0,0 +1,153 @@
@import './fonts.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom animation for fade-in effect */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
/* Add custom styles below */
/* Hide scrollbar for Chrome, Safari and Opera */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Hide scrollbar but keep functionality */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
html {
font-family: 'AlleNoire', system-ui, sans-serif;
}
body {
@apply bg-background text-foreground;
font-family: 'AlleNoire', system-ui, sans-serif;
}
/* Font weight utility classes */
.font-extrabold {
font-family: 'AlleNoire', system-ui, sans-serif;
font-weight: 800;
}
.font-bold {
font-family: 'AlleNoire', system-ui, sans-serif;
font-weight: 700;
}
.font-semibold {
font-family: 'AlleNoire', system-ui, sans-serif;
font-weight: 600;
}
/* Set letter-spacing to 0 for all text elements */
h1, h2, h3, h4, h5, h6, p, span, div {
letter-spacing: 0;
}
}

92
app/layout.tsx Normal file
View File

@@ -0,0 +1,92 @@
import { CookieConsent } from '@/components/cookies';
import { Footer } from '@/components/layout/footer';
import Navbar from '@/components/layout/navbar';
import { ReduxProvider } from '@/lib/redux/provider';
import { cn } from '@/lib/utils';
import { CartProvider } from 'components/cart/cart-context';
import { getCart } from 'lib/shopify';
import { ensureStartsWith } from 'lib/utils';
import type { Metadata } from 'next';
import { cookies } from 'next/headers';
import { ReactNode } from 'react';
import { Toaster } from 'sonner';
import './globals.css';
// Add font loading check
if (typeof window !== 'undefined') {
document.fonts.ready.then(() => {
console.log('Fonts have loaded.');
document.fonts.forEach(font => {
console.log(`Font family: ${font.family}, Status: ${font.status}`);
});
});
}
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
const twitterCreator = TWITTER_CREATOR ? ensureStartsWith(TWITTER_CREATOR, '@') : undefined;
const twitterSite = TWITTER_SITE ? ensureStartsWith(TWITTER_SITE, 'https://') : undefined;
export const metadata: Metadata = {
metadataBase: new URL(baseUrl),
title: {
default: SITE_NAME!,
template: `%s | ${SITE_NAME}`
},
robots: {
follow: true,
index: true
},
...(twitterCreator &&
twitterSite && {
twitter: {
card: 'summary_large_image',
creator: twitterCreator,
site: twitterSite
}
})
};
export default async function RootLayout({ children }: { children: ReactNode }) {
const allCookies = await cookies();
const cartId = allCookies.get('cartId')?.value;
// Don't await the fetch, pass the Promise to the context provider
const cart = getCart(cartId);
// Get locale from cookie
const localeCookie = allCookies.get('NEXT_LOCALE')?.value;
const locale = localeCookie || 'hr';
return (
<html lang={locale}>
<head>
<meta name="theme-color" content="#ffffff" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css"
integrity="sha512-KfkfwYDsLkIlwQp6LFnl8zNdLGxu9YAA1QvwINks4PhcElQSvqcyVLLD9aMhXd13uQjoXtEKNosOWaZqXgel0g=="
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
</head>
<body className={cn("min-h-screen bg-white antialiased")}>
<ReduxProvider>
<CartProvider cartPromise={cart}>
<CookieConsent>
<div className="flex flex-col min-h-screen">
<Navbar />
<main className="flex-grow">
{children}
<Toaster closeButton />
</main>
<Footer />
</div>
</CookieConsent>
</CartProvider>
</ReduxProvider>
</body>
</html>
);
}

8
app/opengraph-image.tsx Normal file
View File

@@ -0,0 +1,8 @@
import OpengraphImage from '../components/opengraph-image';
export const runtime = 'edge';
export default async function Image() {
// Default implementation for root page
return await OpengraphImage({ title: 'Sent - Personalizirani poklon paketi' });
}

20
app/page.tsx Normal file
View File

@@ -0,0 +1,20 @@
import NewHomePage from "@/components/home/NewHomePage";
// Statički metadata
export const metadata = {
title: 'Sent | Poklon paketi za svaku priliku',
description: 'Otkrij čar darivanja uz našu opciju "Složi kutiju". Biraj između pažljivo odabranih proizvoda i složi poklon koji će osvojiti na prvu.',
openGraph: {
type: 'website',
title: 'Sent | Poklon paketi za svaku priliku',
description: 'Otkrij čar darivanja uz našu opciju "Složi kutiju". Biraj između pažljivo odabranih proizvoda i složi poklon koji će osvojiti na prvu.'
}
};
export default function HomePage() {
return (
<>
<NewHomePage />
</>
);
}

View File

@@ -0,0 +1,64 @@
import { Heading, Text } from "@/components/ui/Typography";
import { container } from "@/lib/utils";
export default function PrivacyPolicyPage() {
return (
<div className={`${container} py-12`}>
<Heading level={1} className="mb-8 text-center">Privacy Policy</Heading>
<div className="max-w-3xl mx-auto">
<Text className="mb-6">
Last updated: {new Date().toLocaleDateString()}
</Text>
<Heading level={2} className="mb-4 mt-8">1. Introduction</Heading>
<Text className="mb-4">
Welcome to SENT. We respect your privacy and are committed to protecting your personal data.
This privacy policy will inform you about how we look after your personal data when you visit our website
and tell you about your privacy rights and how the law protects you.
</Text>
<Heading level={2} className="mb-4 mt-8">2. The Data We Collect About You</Heading>
<Text className="mb-4">
Personal data, or personal information, means any information about an individual from which that person can be identified.
It does not include data where the identity has been removed (anonymous data).
</Text>
<Text className="mb-4">
We may collect, use, store and transfer different kinds of personal data about you which we have grouped together as follows:
</Text>
<ul className="list-disc pl-6 mb-6 space-y-2">
<li>Identity Data includes first name, last name, username or similar identifier.</li>
<li>Contact Data includes billing address, delivery address, email address and telephone numbers.</li>
<li>Financial Data includes payment card details.</li>
<li>Transaction Data includes details about payments to and from you and other details of products you have purchased from us.</li>
<li>Technical Data includes internet protocol (IP) address, browser type and version, time zone setting and location, browser plug-in types and versions, operating system and platform, and other technology on the devices you use to access this website.</li>
</ul>
<Heading level={2} className="mb-4 mt-8">3. How We Use Your Personal Data</Heading>
<Text className="mb-4">
We will only use your personal data when the law allows us to. Most commonly, we will use your personal data in the following circumstances:
</Text>
<ul className="list-disc pl-6 mb-6 space-y-2">
<li>Where we need to perform the contract we are about to enter into or have entered into with you.</li>
<li>Where it is necessary for our legitimate interests (or those of a third party) and your interests and fundamental rights do not override those interests.</li>
<li>Where we need to comply with a legal obligation.</li>
</ul>
<Heading level={2} className="mb-4 mt-8">4. Data Security</Heading>
<Text className="mb-4">
We have put in place appropriate security measures to prevent your personal data from being accidentally lost, used or accessed in an unauthorized way, altered or disclosed.
In addition, we limit access to your personal data to those employees, agents, contractors and other third parties who have a business need to know.
They will only process your personal data on our instructions and they are subject to a duty of confidentiality.
</Text>
<Heading level={2} className="mb-4 mt-8">5. Contact Us</Heading>
<Text className="mb-6">
If you have any questions about this privacy policy or our privacy practices, please contact us at:
</Text>
<Text className="font-medium mb-1">Email: info@sentshop.com</Text>
<Text className="font-medium mb-1">Phone: +385 1 234 5678</Text>
<Text className="font-medium mb-6">Address: Ilica 123, 10000 Zagreb, Croatia</Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { BackButton } from '@/components/product/BackButton';
import { ProductDescription } from '@/components/product/ProductDescription';
import { container } from '@/lib/utils';
import { ProductProvider } from 'components/product/product-context';
import { ProductDetailsSection } from 'components/product/ProductDetailsSection';
import { ProductGallery } from 'components/product/ProductGallery';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct } from 'lib/shopify';
import { Suspense } from 'react';
export async function generateMetadata(props: {
params: Promise<{ handle: string }>;
}): Promise<Metadata> {
const params = await props.params;
const product = await getProduct(params.handle);
if (!product) return notFound();
const { url, width, height, altText: alt } = product.featuredImage || {};
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
return {
title: product.seo.title || product.title,
description: product.seo.description || product.description,
robots: {
index: indexable,
follow: indexable,
googleBot: {
index: indexable,
follow: indexable
}
},
openGraph: url
? {
images: [
{
url,
width,
height,
alt
}
]
}
: null
};
}
export default async function ProductPage(props: { params: Promise<{ handle: string }> }) {
const params = await props.params;
const product = await getProduct(params.handle);
if (!product) return notFound();
const productJsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.title,
description: product.description,
image: product.featuredImage.url,
offers: {
'@type': 'AggregateOffer',
availability: product.availableForSale
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
highPrice: product.priceRange.maxVariantPrice.amount,
lowPrice: product.priceRange.minVariantPrice.amount
}
};
// If product has only one image, duplicate it to create the gallery effect
const galleryImages = product.images.length === 1
? Array(5).fill(product.images[0]).map(image => ({
url: image.url,
altText: image.altText,
width: image.width,
height: image.height
}))
: product.images;
return (
<ProductProvider>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(productJsonLd)
}}
/>
<div className="py-4">
<div className={container}>
<BackButton />
</div>
</div>
<div className={container}>
<div className="flex flex-col md:flex-row lg:gap-8">
<div className="w-full lg:w-3/5">
<Suspense
fallback={
<div className="relative aspect-square h-full max-h-[600px] w-full overflow-hidden" />
}
>
<ProductGallery
images={galleryImages.slice(0, 5)}
/>
</Suspense>
</div>
<div className="w-full lg:w-2/5 mt-8 lg:mt-0">
<Suspense fallback={null}>
<ProductDescription product={product} />
<ProductDetailsSection />
</Suspense>
</div>
</div>
</div>
</ProductProvider>
);
}

11
app/products/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { ProductsPage } from "@/components/products/ProductsPage";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Products | Sent",
description: "Explore our collection of ready-made gift boxes and baskets."
};
export default function ProductsRoute() {
return <ProductsPage />;
}

15
app/robots.ts Normal file
View File

@@ -0,0 +1,15 @@
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
export default function robots() {
return {
rules: [
{
userAgent: '*'
}
],
sitemap: `${baseUrl}/sitemap.xml`,
host: baseUrl
};
}

View File

@@ -0,0 +1,11 @@
import { getCollection } from 'lib/shopify';
import OpengraphImage from '../../../components/opengraph-image';
export const runtime = 'edge';
export default async function Image({ params }: { params: { collection: string } }) {
const collection = await getCollection(params.collection);
const title = collection?.seo?.title || collection?.title;
return await OpengraphImage({ title });
}

View File

@@ -0,0 +1,45 @@
import { getCollection, getCollectionProducts } from 'lib/shopify';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import ProductGridItems from '@/components/layout/ProductGridItems';
import Grid from 'components/grid';
import { defaultSort, sorting } from 'lib/constants';
export async function generateMetadata(props: {
params: Promise<{ collection: string }>;
}): Promise<Metadata> {
const params = await props.params;
const collection = await getCollection(params.collection);
if (!collection) return notFound();
return {
title: collection.seo?.title || collection.title,
description:
collection.seo?.description || collection.description || `${collection.title} products`
};
}
export default async function CategoryPage(props: {
params: Promise<{ collection: string }>;
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParams = await props.searchParams;
const params = await props.params;
const { sort } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
return (
<section>
{products.length === 0 ? (
<p className="py-3 text-lg">{`No products found in this collection`}</p>
) : (
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} />
</Grid>
)}
</section>
);
}

View File

@@ -0,0 +1,10 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { Fragment } from 'react';
// Ensure children are re-rendered when the search query changes
export default function ChildrenWrapper({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams();
return <Fragment key={searchParams.get('q')}>{children}</Fragment>;
}

24
app/search/layout.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { Footer } from 'components/layout/footer';
import Collections from 'components/layout/search/collections';
import FilterList from 'components/layout/search/filter';
import { sorting } from 'lib/constants';
import ChildrenWrapper from './children-wrapper';
export default function SearchLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 md:flex-row">
<div className="order-first w-full flex-none md:max-w-[125px]">
<Collections />
</div>
<div className="order-last min-h-screen w-full md:order-none">
<ChildrenWrapper>{children}</ChildrenWrapper>
</div>
<div className="order-none flex-none md:order-last md:w-[125px]">
<FilterList list={sorting} title="Sort by" />
</div>
</div>
<Footer />
</>
);
}

18
app/search/loading.tsx Normal file
View File

@@ -0,0 +1,18 @@
import Grid from 'components/grid';
export default function Loading() {
return (
<>
<div className="mb-4 h-6" />
<Grid className="grid-cols-2 lg:grid-cols-3">
{Array(12)
.fill(0)
.map((_, index) => {
return (
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-800" />
);
})}
</Grid>
</>
);
}

38
app/search/page.tsx Normal file
View File

@@ -0,0 +1,38 @@
import ProductGridItems from '@/components/layout/ProductGridItems';
import Grid from 'components/grid';
import { defaultSort, sorting } from 'lib/constants';
import { getProducts } from 'lib/shopify';
export const metadata = {
title: 'Search',
description: 'Search for products in the store.'
};
export default async function SearchPage(props: {
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParams = await props.searchParams;
const { sort, q: searchValue } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getProducts({ sortKey, reverse, query: searchValue });
const resultsText = products.length > 1 ? 'results' : 'result';
return (
<>
{searchValue ? (
<p className="mb-4">
{products.length === 0
? 'There are no products that match '
: `Showing ${products.length} ${resultsText} for `}
<span className="font-bold">&quot;{searchValue}&quot;</span>
</p>
) : null}
{products.length > 0 ? (
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} />
</Grid>
) : null}
</>
);
}

54
app/sitemap.ts Normal file
View File

@@ -0,0 +1,54 @@
import { getCollections, getPages, getProducts } from 'lib/shopify';
import { validateEnvironmentVariables } from 'lib/utils';
import { MetadataRoute } from 'next';
type Route = {
url: string;
lastModified: string;
};
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
export const dynamic = 'force-dynamic';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
validateEnvironmentVariables();
const routesMap = [''].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date().toISOString()
}));
const collectionsPromise = getCollections().then((collections) =>
collections.map((collection) => ({
url: `${baseUrl}${collection.path}`,
lastModified: collection.updatedAt
}))
);
const productsPromise = getProducts({}).then((products) =>
products.map((product) => ({
url: `${baseUrl}/product/${product.handle}`,
lastModified: product.updatedAt
}))
);
const pagesPromise = getPages().then((pages) =>
pages.map((page) => ({
url: `${baseUrl}/${page.handle}`,
lastModified: page.updatedAt
}))
);
let fetchedRoutes: Route[] = [];
try {
fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat();
} catch (error) {
throw JSON.stringify(error, null, 2);
}
return [...routesMap, ...fetchedRoutes];
}

View File

@@ -0,0 +1,78 @@
import { Heading, Text } from "@/components/ui/Typography";
import { container } from "@/lib/utils";
export default function TermsOfServicePage() {
return (
<div className={`${container} py-12`}>
<Heading level={1} className="mb-8 text-center">Terms of Service</Heading>
<div className="max-w-3xl mx-auto">
<Text className="mb-6">
Last updated: {new Date().toLocaleDateString()}
</Text>
<Heading level={2} className="mb-4 mt-8">1. Agreement to Terms</Heading>
<Text className="mb-4">
By accessing our website at <span className="font-medium">sentshop.com</span>, you are agreeing to be bound by these terms of service,
all applicable laws and regulations, and agree that you are responsible for compliance with any
applicable local laws. If you do not agree with any of these terms, you are prohibited from
using or accessing this site.
</Text>
<Heading level={2} className="mb-4 mt-8">2. Use License</Heading>
<Text className="mb-4">
Permission is granted to temporarily download one copy of the materials (information or software)
on SENT's website for personal, non-commercial transitory viewing only. This is the grant of a license, not a transfer of title,
and under this license you may not:
</Text>
<ul className="list-disc pl-6 mb-6 space-y-2">
<li>Modify or copy the materials;</li>
<li>Use the materials for any commercial purpose, or for any public display (commercial or non-commercial);</li>
<li>Attempt to decompile or reverse engineer any software contained on SENT's website;</li>
<li>Remove any copyright or other proprietary notations from the materials; or</li>
<li>Transfer the materials to another person or "mirror" the materials on any other server.</li>
</ul>
<Text className="mb-4">
This license shall automatically terminate if you violate any of these restrictions and may be terminated by SENT at any time.
Upon terminating your viewing of these materials or upon the termination of this license,
you must destroy any downloaded materials in your possession whether in electronic or printed format.
</Text>
<Heading level={2} className="mb-4 mt-8">3. Disclaimer</Heading>
<Text className="mb-4">
The materials on SENT's website are provided on an 'as is' basis. SENT makes no warranties,
expressed or implied, and hereby disclaims and negates all other warranties including, without limitation,
implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property
or other violation of rights.
</Text>
<Text className="mb-4">
Further, SENT does not warrant or make any representations concerning the accuracy, likely results,
or reliability of the use of the materials on its website or otherwise relating to such materials or
on any sites linked to this site.
</Text>
<Heading level={2} className="mb-4 mt-8">4. Limitations</Heading>
<Text className="mb-4">
In no event shall SENT or its suppliers be liable for any damages (including, without limitation,
damages for loss of data or profit, or due to business interruption) arising out of the use or inability to use
the materials on SENT's website, even if SENT or a SENT authorized representative has been notified orally or in writing
of the possibility of such damage.
</Text>
<Heading level={2} className="mb-4 mt-8">5. Governing Law</Heading>
<Text className="mb-4">
These terms and conditions are governed by and construed in accordance with the laws of Croatia
and you irrevocably submit to the exclusive jurisdiction of the courts in that location.
</Text>
<Heading level={2} className="mb-4 mt-8">6. Contact Us</Heading>
<Text className="mb-6">
If you have any questions about these Terms, please contact us at:
</Text>
<Text className="font-medium mb-1">Email: info@sentshop.com</Text>
<Text className="font-medium mb-1">Phone: +385 1 234 5678</Text>
<Text className="font-medium mb-6">Address: Ilica 123, 10000 Zagreb, Croatia</Text>
</div>
</div>
);
}

29
app/test-boxes/page.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { ProductGrid } from "@/components/products/ProductGrid";
import { Section } from "@/components/ui/Section";
import { Heading, Text } from "@/components/ui/Typography";
import { getBoxProducts } from "lib/shopify";
export default async function TestBoxesPage() {
const boxes = await getBoxProducts({
sortKey: 'CREATED_AT',
reverse: true
});
return (
<Section>
<div className="mb-6">
<Heading level={1}>Test Box Products</Heading>
<Text>Found {boxes.length} box products. This is a temporary test page.</Text>
</div>
{boxes.length === 0 ? (
<div className="p-12 text-center bg-gray-100 rounded-lg">
<Text size="lg" className="font-semibold">No box products found</Text>
<Text className="mt-2">Try adding products with 'box' in the title or with the 'box' tag.</Text>
</div>
) : (
<ProductGrid products={boxes} title="Box Products" />
)}
</Section>
);
}

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

34
components/Label.tsx Normal file
View File

@@ -0,0 +1,34 @@
import clsx from 'clsx';
import Price from './price';
const Label = ({
title,
amount,
currencyCode,
position = 'bottom'
}: {
title: string;
amount: string;
currencyCode: string;
position?: 'bottom' | 'center';
}) => {
return (
<div
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', {
'lg:px-20 lg:pb-[35%]': position === 'center'
})}
>
<div className="flex items-center bg-white">
<h3 className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight">{title}</h3>
<Price
className=""
amount={amount}
currencyCode={currencyCode}
currencyCodeClassName="hidden @[275px]/label:inline"
/>
</div>
</div>
);
};
export default Label;

View File

@@ -0,0 +1,15 @@
import clsx from 'clsx';
const dots = 'mx-[1px] inline-block h-1 w-1 animate-blink rounded-md';
const LoadingDots = ({ className }: { className: string }) => {
return (
<span className="mx-2 inline-flex items-center">
<span className={clsx(dots, className)} />
<span className={clsx(dots, 'animation-delay-[200ms]', className)} />
<span className={clsx(dots, 'animation-delay-[400ms]', className)} />
</span>
);
};
export default LoadingDots;

View File

@@ -0,0 +1,104 @@
'use client';
import { ContactForm } from '@/components/ui/ContactForm';
import { Newsletter } from '@/components/ui/Newsletter';
import { Section } from '@/components/ui/Section';
import { Heading, Text } from '@/components/ui/Typography';
import { useTranslation } from '@/lib/hooks/useTranslation';
import { container } from '@/lib/utils';
import Image from 'next/image';
export function AboutPageContent() {
const { t } = useTranslation();
return (
<main>
{/* About Us Header Section */}
<Section spacing="large">
<div className="flex flex-col items-center text-center">
<Heading level={1} className="mb-6 tracking-[0]">{t('about.title')}</Heading>
<Text className="max-w-3xl mx-auto mb-12 tracking-[0] mb-[80px]">
{t('about.description')}
</Text>
<div className="w-full aspect-[16/9] relative bg-gray-100">
<Image
src="/assets/images/image2.webp"
alt={t('about.title')}
fill
className="object-cover"
/>
</div>
</div>
</Section>
{/* Contact Form Section */}
<div className="relative">
{/* Top wave */}
<div className="w-full">
<Image
src="/assets/images/Frame6.png"
alt=""
width={1440}
height={140}
className="w-full object-cover"
/>
</div>
{/* Main content with yellow background */}
<div className="bg-[#FFC877] pb-[224px]">
<div className={container}>
<div className="py-[112px]">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
<div>
<ContactForm
title={t('about.contactForm.title')}
subtitle={t('about.contactForm.subtitle')}
/>
</div>
<div className="w-full h-full relative bg-gray-100 min-h-[600px]">
<Image
src="/assets/images/image22.png"
alt={t('about.contactForm.title')}
fill
className="object-cover"
priority
/>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Newsletter Section */}
<div className="relative -mt-[112px]">
{/* Top wave that overlaps with yellow section */}
<div className="w-full absolute -top-32 z-10">
<Image
src="/assets/images/Frame4.png"
alt=""
width={1440}
height={140}
className="w-full object-cover"
/>
</div>
{/* Main content with pink background */}
<div className="bg-[#F58EA7]">
<div className={container}>
<div className="py-[112px]">
<Newsletter
title={t('about.newsletter.title')}
subtitle={t('about.newsletter.subtitle')}
buttonText={t('about.newsletter.buttonText')}
disclaimer={t('about.newsletter.disclaimer')}
/>
</div>
</div>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { Button } from "@/components/ui/Button";
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
import { clearBox, selectBoxItems, selectEditingBoxGroupId } from "@/lib/redux/slices/boxSlice";
import { addItem, removeItem } from "components/cart/actions";
import { useCart } from "components/cart/cart-context";
import { Product, ProductVariant } from "lib/shopify/types";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
export function AddBoxToCartClient() {
const [isLoading, setIsLoading] = useState(false);
const [isLoadingProducts, setIsLoadingProducts] = useState(true);
const [allProducts, setAllProducts] = useState<Product[]>([]);
const [boxProducts, setBoxProducts] = useState<Product[]>([]);
const items = useAppSelector(selectBoxItems);
const editingBoxGroupId = useAppSelector(selectEditingBoxGroupId);
const { addCartItem, cart } = useCart();
const dispatch = useAppDispatch();
const router = useRouter();
// Fetch products on mount
useEffect(() => {
async function fetchProducts() {
setIsLoadingProducts(true);
try {
// Fetch regular products
const regularResponse = await fetch('/api/products');
const regularData = await regularResponse.json();
// Fetch box products
const boxesResponse = await fetch('/api/products?type=boxes');
const boxesData = await boxesResponse.json();
if (regularData.products) setAllProducts(regularData.products);
if (boxesData.products) setBoxProducts(boxesData.products);
} catch (error) {
console.error("Failed to fetch products:", error);
} finally {
setIsLoadingProducts(false);
}
}
fetchProducts();
}, []);
// Check if we have a box
const boxItems = items.filter(item => item.variantId === 'box-container');
const productItems = items.filter(item => item.variantId !== 'box-container');
const isEnabled = boxItems.length > 0 && !isLoadingProducts;
const handleAddToCart = async () => {
if (isLoading || !isEnabled) return;
setIsLoading(true);
try {
// Save the current box state to localStorage for potential editing later
try {
const boxState = {
boxItems: boxItems,
productItems: productItems,
boxGroupId: `box-${Date.now()}`
};
localStorage.setItem('lastBoxState', JSON.stringify(boxState));
} catch (error) {
console.error("Failed to save box state:", error);
}
// Generate a unique box ID to group all items together
const boxGroupId = `box-${Date.now()}`;
// Ako je ovo uređivanje postojećeg boxa, prvo ukloni stari box i sve njegove proizvode
if (editingBoxGroupId && cart) {
// Pronađi sve proizvode i kontejner koji pripadaju ovom boxu
const itemsToRemove = cart.lines.filter(line => {
const attrs = line.attributes || [];
const itemBoxGroupId = attrs.find(attr => attr.key === 'box_group_id')?.value;
return itemBoxGroupId === editingBoxGroupId;
});
// Ukloni sve pronađene proizvode
for (const item of itemsToRemove) {
if (item.id && item.merchandise.id) {
await removeItem(null, item.merchandise.id, item.id);
}
}
}
// Add box items first
for (const boxItem of boxItems) {
// Find the actual box product from our pre-loaded products
const boxProduct = boxProducts.find(p => p.id === boxItem.id);
if (boxProduct) {
// Get the variant to use
const boxVariant = boxProduct.variants[0];
if (boxVariant) {
// Add box with attribute marking it as a box container and the box group ID
await addItem(
null,
boxVariant.id,
boxItem.quantity,
[
{ key: "_box_type", value: "container" },
{ key: "_box_group_id", value: boxGroupId }
]
);
// Update local cart state
addCartItem(boxVariant, boxProduct, boxItem.quantity);
}
}
}
// Add product items
for (const productItem of productItems) {
// Find the actual product from our pre-loaded products
const product = allProducts.find(p => p.id === productItem.id);
if (product) {
// Find the variant
let selectedVariant: ProductVariant | undefined;
if (productItem.variantId && productItem.variantId !== 'undefined') {
selectedVariant = product.variants.find(v => v.id === productItem.variantId);
}
// If no variant found, use the first one (exactly like AddToCartButton.tsx)
if (!selectedVariant) {
selectedVariant = product.variants[0];
}
if (selectedVariant && selectedVariant.id) {
// Add product with attribute marking it as a box item and the box group ID
await addItem(
null,
selectedVariant.id,
productItem.quantity,
[
{ key: "_box_type", value: "item" },
{ key: "_box_group_id", value: boxGroupId }
]
);
// Update local cart state
addCartItem(selectedVariant, product, productItem.quantity);
}
}
}
dispatch(clearBox());
} catch (error) {
console.error("Failed to add box to cart:", error);
} finally {
setIsLoading(false);
}
};
return (
<Button
onClick={handleAddToCart}
disabled={isLoading || !isEnabled}
variant="primary"
className="w-full py-3"
>
{isLoading ? "Dodaje se..." : isLoadingProducts ? "Učitavanje..." : "Dodaj u košaricu"}
</Button>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import { Text } from "@/components/ui/Typography";
import { useAppSelector } from "@/lib/redux/hooks";
import { selectBoxTotal } from "@/lib/redux/slices/boxSlice";
import { container } from "@/lib/utils";
import { Product } from "lib/shopify/types";
import Link from "next/link";
import { ProductGrid } from "../products/ProductGrid";
import { BuildBoxSidebar } from "./BuildBoxSidebar";
interface BuildBoxClientPageProps {
products: Product[];
}
export function BuildBoxClientPage({ products }: BuildBoxClientPageProps) {
const boxTotal = useAppSelector(selectBoxTotal);
return (
<div className="relative">
{/* Main content */}
<div className={`${container} lg:pr-[240px]`}>
<ProductGrid products={products} title="" />
{/* Add bottom padding on mobile to make room for mobile sidebar */}
<div className="h-24 lg:hidden"></div>
</div>
{/* Desktop sidebar - right side, full height with scrolling */}
<div className="hidden lg:block fixed top-0 right-0 w-[230px] h-full bg-gray-100 border-l border-gray-200 shadow-sm">
<div className="pt-[88px] h-full overflow-y-auto">
<BuildBoxSidebar />
</div>
</div>
{/* Mobile sidebar - fixed at bottom of screen */}
<div className="lg:hidden fixed bottom-0 left-0 right-0 h-20 bg-white border-t border-gray-200 flex items-center justify-between px-4 z-50">
<div>
<Text weight="semibold">Cijena boxa: ${boxTotal.toFixed(2)}</Text>
</div>
<Link
href="/build-box/customize"
className="bg-primary text-white px-6 py-2 rounded disabled:bg-gray-400"
aria-disabled={boxTotal === 0}
tabIndex={boxTotal === 0 ? -1 : undefined}
>
Idući korak
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { Button } from "@/components/ui/Button";
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
import { addToBox, removeFromBox, selectBoxItems } from "@/lib/redux/slices/boxSlice";
import Image from "next/image";
import { useEffect, useState } from "react";
// Box option interface
export interface BoxOption {
id: string;
title: string;
price: number;
image: string;
}
interface BoxCustomizeProps {
boxes: BoxOption[];
}
export function BuildBoxCustomizeClient({ boxes }: BoxCustomizeProps) {
const [selectedBoxId, setSelectedBoxId] = useState<string | null>(null);
const dispatch = useAppDispatch();
const items = useAppSelector(selectBoxItems);
// Check if there's already a box in the items and set it as selected on load
useEffect(() => {
const existingBox = items.find(item => item.variantId === 'box-container');
if (existingBox) {
setSelectedBoxId(existingBox.id);
}
}, [items]);
// When a box is selected, replace any existing box with the new one
const handleSelectBox = (box: BoxOption) => {
// If the same box is already selected, do nothing
if (selectedBoxId === box.id) return;
// Set this box as selected
setSelectedBoxId(box.id);
// Remove any existing box containers first
const existingBoxes = items.filter(item => item.variantId === 'box-container');
existingBoxes.forEach(existingBox => {
dispatch(removeFromBox({ id: existingBox.id, color: existingBox.color }));
});
// Add the new box
dispatch(addToBox({
id: box.id,
name: box.title,
price: box.price,
image: box.image,
quantity: 1,
variantId: 'box-container', // Mark this as a box container, not a product
}));
};
return (
<div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{boxes.map((box) => (
<BoxOption
key={box.id}
box={box}
isSelected={selectedBoxId === box.id}
onSelect={() => handleSelectBox(box)}
/>
))}
</div>
</div>
);
}
// Box option component
function BoxOption({
box,
isSelected,
onSelect
}: {
box: BoxOption;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<div className={`border rounded-md overflow-hidden ${isSelected ? 'ring-2 ring-primary' : ''}`}>
<div className="relative h-60 overflow-hidden">
<Image
src={box.image}
alt={box.title}
fill
className="object-cover transition-transform duration-300"
/>
{/* Overlay with "Dodaj u box" button - always visible for selected, only on hover for others */}
<div className={`absolute inset-0 flex items-center justify-center ${isSelected ? 'bg-black/40' : 'bg-black/40 opacity-0 hover:opacity-100 transition-opacity duration-300'}`}>
<Button
variant="custom"
onClick={onSelect}
>
Dodaj u box
</Button>
</div>
{isSelected && (
<div className="absolute top-2 right-2 bg-primary text-white rounded-full p-1 w-6 h-6 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
)}
</div>
<div className="p-4">
<div className="flex justify-between items-start">
<h3 className="font-medium">{box.title}</h3>
<span className="font-medium">${box.price.toFixed(2)}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { getBoxProducts } from "lib/shopify";
import { BoxOption } from "./BuildBoxCustomizeClient";
import { BuildBoxCustomizePageContent } from "./client/BuildBoxCustomizePageContent";
export async function BuildBoxCustomizePage() {
// Fetch boxes from the API
const boxes = await getBoxProducts({
sortKey: 'CREATED_AT',
reverse: true
});
// Convert products to box options for display
const boxOptions: BoxOption[] = boxes.map(box => ({
id: box.id,
title: box.title,
price: parseFloat(box.priceRange.minVariantPrice.amount),
image: box.featuredImage?.url || '/placeholder-box.jpg'
}));
return <BuildBoxCustomizePageContent boxes={boxOptions} />;
}

View File

@@ -0,0 +1,24 @@
'use client';
import { Text } from "@/components/ui/Typography";
import { useAppSelector } from "@/lib/redux/hooks";
import { selectBoxTotal } from "@/lib/redux/slices/boxSlice";
import Link from "next/link";
export function BuildBoxMobileSummary() {
const boxTotal = useAppSelector(selectBoxTotal);
return (
<div className="lg:hidden fixed bottom-0 left-0 right-0 h-20 bg-white border-t border-gray-200 flex items-center justify-between px-4 z-50">
<div>
<Text weight="semibold">Cijena boxa: ${boxTotal.toFixed(2)}</Text>
</div>
<Link
href="/build-box/customize"
className={`px-6 py-2 rounded ${boxTotal > 0 ? 'bg-black text-white' : 'bg-gray-300 text-gray-500 pointer-events-none'}`}
>
Idući korak
</Link>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { getRegularProducts } from "lib/shopify";
import { BuildBoxPageContent } from "./client/BuildBoxPageContent";
// Main server component
export async function BuildBoxPage() {
const products = await getRegularProducts({
sortKey: 'CREATED_AT',
reverse: true
});
return <BuildBoxPageContent products={products} />;
}

View File

@@ -0,0 +1,203 @@
'use client';
import { Button } from "@/components/ui/Button";
import { Heading, Text } from "@/components/ui/Typography";
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
import { BoxItem, removeFromBox, selectBoxItems, selectBoxTotal, updateQuantity } from "@/lib/redux/slices/boxSlice";
import { Minus, Plus, Trash2 } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { AddBoxToCartClient } from "./AddBoxToCartClient";
export function BuildBoxSidebar() {
const dispatch = useAppDispatch();
const items = useAppSelector(selectBoxItems);
const boxTotal = useAppSelector(selectBoxTotal);
const isEmpty = items.length === 0;
const pathname = usePathname();
// Separate box containers from products
const boxContainers = items.filter(item => item.variantId === 'box-container');
const products = items.filter(item => item.variantId !== 'box-container');
// Check if we have products for the first step
const hasProducts = products.length > 0;
// Check if we have a selected box for the second step
const hasBoxSelected = boxContainers.length > 0;
// Determine which page we're on to show appropriate button text
const isCustomizePage = pathname.includes('/customize');
const nextStepUrl = "/build-box/customize";
const nextStepText = "Idući korak";
// Determine if the next step button should be disabled
const isNextStepDisabled = isEmpty || (!isCustomizePage && !hasProducts);
const handleUpdateQuantity = (id: string, color: string | undefined, newQuantity: number) => {
if (newQuantity < 1) return;
dispatch(updateQuantity({ id, color, quantity: newQuantity }));
};
const handleRemoveItem = (id: string, color: string | undefined) => {
dispatch(removeFromBox({ id, color }));
};
return (
<div className="h-full flex flex-col">
{/* Fixed header - adjusted padding to align with main page title */}
<div className="p-4 pt-8 pb-4 border-b">
<Heading level={3} className="text-center">Your box</Heading>
</div>
{/* Scrollable products area */}
<div className="flex-1 overflow-y-auto p-4">
{isEmpty ? (
<div className="text-center py-8 text-gray-500">
<Text>Your box is empty</Text>
<Text className="text-sm mt-2">Add products to create your box</Text>
</div>
) : (
<div className="space-y-6">
{/* Show box container if any */}
{boxContainers.length > 0 && (
<div className="border-b pb-4 mb-4">
<Text className="font-medium mb-2">Box Design</Text>
{boxContainers.map((box: BoxItem) => (
<div key={box.compositeKey || box.id} className="flex items-start space-x-3">
<div className="w-20 h-24 relative flex-shrink-0">
<Image
src={box.image}
alt={box.name}
fill
className="object-cover rounded-md"
/>
</div>
<div className="flex-grow">
<div className="flex justify-between">
<div>
<div className="text-base font-medium">{box.name}</div>
<div className="text-sm text-gray-500">${box.price}</div>
</div>
<button
onClick={() => handleRemoveItem(box.id, box.color)}
className="text-gray-400 hover:text-red-500"
aria-label="Remove box"
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Product items */}
{products.length > 0 && (
<>
{products.map((item: BoxItem) => (
<div key={item.compositeKey || item.id} className="flex items-start space-x-3">
<div className="w-20 h-24 relative flex-shrink-0">
<Image
src={item.image}
alt={item.name}
fill
className="object-cover rounded-md"
/>
</div>
<div className="flex-grow flex flex-col justify-between min-w-0">
<div className="flex justify-between">
<div className="max-w-[75%]">
<Text className="text-base font-medium break-words">{item.name}</Text>
<div className="flex items-center mt-1">
<Text className="text-sm text-gray-500">${item.price}</Text>
{item.color && (
<div
className="w-3 h-3 rounded-full ml-2"
style={{ backgroundColor: item.color }}
aria-label="Color"
/>
)}
</div>
</div>
<button
onClick={() => handleRemoveItem(item.id, item.color)}
className="text-gray-400 hover:text-red-500 ml-2"
aria-label="Remove item"
>
<Trash2 size={18} />
</button>
</div>
<div className="flex items-center mt-2">
<div className="flex border border-gray-300 rounded-md">
<button
onClick={() => handleUpdateQuantity(item.id, item.color, item.quantity - 1)}
className="px-2 py-1 border-r border-gray-300 text-gray-500 hover:bg-gray-100 disabled:opacity-50"
disabled={item.quantity <= 1}
aria-label="Decrease quantity"
>
<Minus size={14} />
</button>
<span className="px-3 py-1 flex items-center justify-center w-8 text-center">
{item.quantity}
</span>
<button
onClick={() => handleUpdateQuantity(item.id, item.color, item.quantity + 1)}
className="px-2 py-1 border-l border-gray-300 text-gray-500 hover:bg-gray-100"
aria-label="Increase quantity"
>
<Plus size={14} />
</button>
</div>
</div>
</div>
</div>
))}
</>
)}
</div>
)}
</div>
{/* Fixed price and button at bottom */}
<div className="border-t p-4">
<div className="flex justify-between mb-4">
<Text className="font-medium">Cijena boxa</Text>
<Text className="font-medium">${boxTotal.toFixed(2)}</Text>
</div>
{isCustomizePage ? (
<AddBoxToCartClient />
) : (
<Link
href={isNextStepDisabled ? "#" : nextStepUrl}
className={isNextStepDisabled ? "pointer-events-none" : ""}
>
<Button
variant="primary"
className="w-full py-3"
disabled={isNextStepDisabled}
>
{nextStepText}
</Button>
</Link>
)}
{!isCustomizePage && !hasProducts && !isEmpty && (
<Text className="text-xs text-center text-red-500 mt-2">
Add at least one product to proceed
</Text>
)}
{isCustomizePage && !hasBoxSelected && !isEmpty && (
<Text className="text-xs text-center text-red-500 mt-2">
Select a box design to continue
</Text>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
'use client';
import { Section } from "@/components/ui/Section";
import { Heading } from "@/components/ui/Typography";
import { useEffect, useState } from "react";
import { BuildBoxSidebar } from "./BuildBoxSidebar";
export function ClientSidebarWrapper() {
// Use state to prevent hydration errors
const [isClient, setIsClient] = useState(false);
// After component mounts, set isClient to true
useEffect(() => {
setIsClient(true);
}, []);
// On the server, or during hydration, return a placeholder with the same dimensions
if (!isClient) {
return (
<div className="h-full flex flex-col">
<Section spacing="xs" className="border-b">
<Heading level={3} className="text-center">Your box</Heading>
</Section>
<div className="flex-1 p-4">
{/* Placeholder content to match dimensions */}
<div className="text-center py-8 text-gray-500">
<p>Loading...</p>
</div>
</div>
<div className="border-t p-4">
<div className="flex justify-between mb-4">
<p className="font-medium">Cijena boxa</p>
<p className="font-medium">$0.00</p>
</div>
<button
className="w-full py-3 bg-gray-200 text-gray-500 rounded cursor-not-allowed"
disabled
>
Loading...
</button>
</div>
</div>
);
}
// On the client, after hydration, render the actual sidebar
return <BuildBoxSidebar />;
}

View File

@@ -0,0 +1,24 @@
'use client';
import { useAppSelector } from "@/lib/redux/hooks";
import { selectBoxItems } from "@/lib/redux/slices/boxSlice";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
export function RedirectIfEmptyBox() {
const router = useRouter();
const items = useAppSelector(selectBoxItems);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (isMounted && items.length === 0) {
router.replace('/build-box');
}
}, [items, router, isMounted]);
return null;
}

View File

@@ -0,0 +1,90 @@
'use client';
import { Button } from "@/components/ui/Button";
import { useTranslation } from "@/lib/hooks/useTranslation";
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
import { clearBox, selectBoxItems } from "@/lib/redux/slices/boxSlice";
import { addItem } from "components/cart/actions";
import { useCart } from "components/cart/cart-context";
import { useRouter } from "next/navigation";
import { useState } from "react";
interface AddBoxToCartClientWithTranslationProps {
buttonText?: string;
}
export function AddBoxToCartClientWithTranslation({ buttonText }: AddBoxToCartClientWithTranslationProps) {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const dispatch = useAppDispatch();
const boxItems = useAppSelector(selectBoxItems);
const router = useRouter();
const { addCartItem } = useCart();
// Check if we have a box container
const hasBoxContainer = boxItems.some(item => item.variantId === 'box-container');
// Disable the button if no box container is selected
const isDisabled = !hasBoxContainer;
const handleAddToCart = async () => {
setIsLoading(true);
try {
// Get box container details (for image, name etc.)
const boxContainer = boxItems.find(item => item.variantId === 'box-container');
if (!boxContainer) {
throw new Error('No box container selected');
}
// Generate a unique box ID to group all items together
const boxGroupId = `box-${Date.now()}`;
// Add box to cart with attribute marking it as a box container
await addItem(
null,
boxContainer.id,
1,
[
{ key: "_box_type", value: "container" },
{ key: "_box_group_id", value: boxGroupId }
]
);
// Add all product items with same box group ID
for (const item of boxItems.filter(i => i.variantId !== 'box-container')) {
await addItem(
null,
item.id,
item.quantity,
[
{ key: "_box_type", value: "item" },
{ key: "_box_group_id", value: boxGroupId }
]
);
}
// Clear the box
dispatch(clearBox());
// Redirect to cart page
router.push('/cart');
} catch (error) {
console.error('Error adding box to cart:', error);
} finally {
setIsLoading(false);
}
};
return (
<Button
variant="primary"
onClick={handleAddToCart}
className="w-full py-3"
disabled={isDisabled || isLoading}
>
{isLoading ? 'Loading...' : (buttonText || t('buildBox.customize.addToCart'))}
</Button>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { Button } from "@/components/ui/Button";
import { useTranslation } from "@/lib/hooks/useTranslation";
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
import { addToBox, removeFromBox, selectBoxItems } from "@/lib/redux/slices/boxSlice";
import Image from "next/image";
import { useEffect, useState } from "react";
export interface BoxOption {
id: string;
title: string;
price: number;
image: string;
}
interface BuildBoxCustomizeClientWithTranslationProps {
boxes: BoxOption[];
}
export function BuildBoxCustomizeClientWithTranslation({ boxes }: BuildBoxCustomizeClientWithTranslationProps) {
const { t, locale } = useTranslation();
const dispatch = useAppDispatch();
const [selectedBox, setSelectedBox] = useState<string | null>(null);
const boxItems = useAppSelector(selectBoxItems);
// Učitaj odabranu kutiju iz postojećih item-a prilikom učitavanja stranice
useEffect(() => {
const existingBox = boxItems.find(item => item.variantId === 'box-container');
if (existingBox) {
setSelectedBox(existingBox.id);
}
}, [boxItems]);
const handleSelectBox = (box: BoxOption) => {
// Ako je ista kutija već odabrana, ne radi ništa
if (selectedBox === box.id) return;
setSelectedBox(box.id);
// Ukloni sve postojeće kutije iz košarice
const existingBoxes = boxItems.filter(item => item.variantId === 'box-container');
existingBoxes.forEach(existingBox => {
dispatch(removeFromBox({ id: existingBox.id, color: existingBox.color }));
});
// Add box to the cart
dispatch(addToBox({
id: box.id,
name: box.title,
price: box.price,
image: box.image,
quantity: 1,
variantId: 'box-container', // Special ID to identify this as a box
}));
};
return (
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{boxes.map((box) => {
const isSelected = selectedBox === box.id;
return (
<div
key={box.id}
className={`flex flex-col group relative cursor-pointer ${isSelected ? 'ring-2 ring-primary rounded-lg' : ''}`}
onClick={() => handleSelectBox(box)}
>
<div className="relative aspect-[4/3] overflow-hidden rounded-lg">
<Image
src={box.image}
alt={box.title}
fill
className="object-cover"
/>
{/* Oznaka za odabranu kutiju */}
{isSelected && (
<div className="absolute top-0 right-0 bg-primary text-white m-2 rounded-full p-1 w-6 h-6 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
)}
{/* Mobile button - always visible */}
<div className="absolute inset-0 flex items-center justify-center md:hidden">
<Button
variant={isSelected ? "custom" : "primary"}
className="w-auto shadow-md"
>
{isSelected
? (locale === 'en' ? 'Selected' : 'Odabrano')
: t('buildBox.customize.options.select')}
</Button>
</div>
{/* Desktop button - visible on hover */}
<div className="absolute inset-0 hidden md:flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<Button
variant={isSelected ? "custom" : "primary"}
className="w-auto shadow-md"
>
{isSelected
? (locale === 'en' ? 'Selected' : 'Odabrano')
: t('buildBox.customize.options.select')}
</Button>
</div>
</div>
<div className="pt-4 flex flex-col">
<h3 className="text-lg font-bold">{box.title}</h3>
<p className="text-lg font-medium">${box.price.toFixed(2)}</p>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useTranslation } from "@/lib/hooks/useTranslation";
import Link from "next/link";
import { BoxOption } from "../BuildBoxCustomizeClient";
import { RedirectIfEmptyBox } from "../RedirectIfEmptyBox";
import { BuildBoxCustomizeClientWithTranslation } from "./BuildBoxCustomizeClientWithTranslation";
import { BuildBoxLayout } from "./BuildBoxLayout";
interface BuildBoxCustomizePageContentProps {
boxes: BoxOption[];
}
export function BuildBoxCustomizePageContent({ boxes }: BuildBoxCustomizePageContentProps) {
const { t } = useTranslation();
// Back button component
const BackButton = (
<Link href="/build-box" className="inline-flex items-center text-sm text-gray-600">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1">
<polyline points="15 18 9 12 15 6" />
</svg>
{t('buildBox.customize.back')}
</Link>
);
// If no boxes, display a message
if (!boxes || boxes.length === 0) {
return (
<BuildBoxLayout title={t('buildBox.customize.title')} step={2} backButton={BackButton}>
<div className="text-center py-8">
<p>{t('buildBox.customize.emptyMessage')}</p>
</div>
</BuildBoxLayout>
);
}
return (
<BuildBoxLayout title={t('buildBox.customize.title')} step={2} backButton={BackButton}>
{/* Client component to check if box has items and redirect if empty */}
<RedirectIfEmptyBox />
{/* Boxes Grid - Client Component */}
<BuildBoxCustomizeClientWithTranslation boxes={boxes} />
</BuildBoxLayout>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import { Heading } from "@/components/ui/Typography";
import { useTranslation } from "@/lib/hooks/useTranslation";
import { useAppSelector } from "@/lib/redux/hooks";
import { selectBoxTotal } from "@/lib/redux/slices/boxSlice";
import { container } from "@/lib/utils";
import { ChevronDown, ChevronUp } from "lucide-react";
import { ReactNode, useEffect, useState } from "react";
import { ClientSidebarWrapperWithTranslation } from "./ClientSidebarWrapperWithTranslation";
interface BuildBoxLayoutProps {
children: ReactNode;
title: string;
step: number;
totalSteps?: number;
backButton?: ReactNode;
}
export function BuildBoxLayout({
children,
title,
step,
totalSteps = 2,
backButton
}: BuildBoxLayoutProps) {
const { t } = useTranslation();
const [mobileCartOpen, setMobileCartOpen] = useState(false);
const storeBoxTotal = useAppSelector(selectBoxTotal);
// Dodajemo state za boxTotal koji će se koristiti za prikaz
const [displayBoxTotal, setDisplayBoxTotal] = useState(0);
// Ažuriramo displayBoxTotal samo na klijentskoj strani
useEffect(() => {
setDisplayBoxTotal(storeBoxTotal);
}, [storeBoxTotal]);
// Toggle mobile cart sidebar
const toggleMobileCart = () => {
setMobileCartOpen(!mobileCartOpen);
};
return (
<div className="relative">
{/* Main content */}
<div className={container}>
{/* Custom header with step indicator */}
<div className="mt-8 mb-8">
{/* Za korak 2, gumb nazad je iznad naslova */}
{step === 2 && backButton && (
<div className="mb-4">
{backButton}
</div>
)}
<div className="flex items-center justify-between">
{/* Za korak 1, gumb nazad je pored naslova (ako postoji) */}
{step === 1 && backButton && (
<div>{backButton}</div>
)}
<Heading level={step === 1 ? 2 : 1} className={step === 1 ? "mt-16" : ""}>
{title}:&nbsp;
<span className="text-primary">{t('buildBox.step')} {step}</span>
</Heading>
</div>
</div>
<div className="pr-0 lg:pr-[240px]">
{children}
</div>
{/* Mobile sticky footer with cart button */}
<div className="lg:hidden fixed bottom-0 left-0 right-0 bg-[#E8E8E8] p-4 z-20 shadow-sm">
<div
className="w-full py-3 flex items-center justify-between cursor-pointer"
onClick={toggleMobileCart}
>
<span className="font-medium">{t('buildBox.sidebar.title')}</span>
<ChevronUp size={20} />
</div>
</div>
{/* Add padding at the bottom on mobile to account for the sticky footer */}
<div className="h-20 lg:h-0"></div>
</div>
{/* Desktop sidebar - right side, fixed position */}
<div className="hidden lg:block fixed top-[63px] right-0 w-[230px] h-[calc(100vh-65px)] bg-gray-100 border-l border-gray-200 shadow-sm z-10">
<ClientSidebarWrapperWithTranslation />
</div>
{/* Mobile full-screen sidebar overlay */}
{mobileCartOpen && (
<div className="lg:hidden fixed inset-0 bg-white z-50 flex flex-col h-screen max-h-screen overflow-hidden">
<div className="flex items-center justify-between p-4">
<Heading level={3}>{t('buildBox.sidebar.title')}</Heading>
<div
className="cursor-pointer"
onClick={toggleMobileCart}
>
<ChevronDown size={24} />
</div>
</div>
<div className="flex-1 overflow-y-auto">
<ClientSidebarWrapperWithTranslation isMobile={true} />
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { SortDropdown } from "@/components/products/client/SortDropdown";
import { FilterSidebar } from "@/components/products/FilterSidebar";
import { useProductFilters } from "@/components/products/hooks/useProductFilters";
import { FilterButton } from "@/components/products/ProductComponents/FilterButton";
import { useTranslation } from "@/lib/hooks/useTranslation";
import { Product } from "lib/shopify/types";
import { useState } from "react";
import { ProductGrid } from "../../products/ProductGrid";
import { BuildBoxLayout } from "./BuildBoxLayout";
interface BuildBoxPageContentProps {
products: Product[];
}
export function BuildBoxPageContent({ products }: BuildBoxPageContentProps) {
const { t } = useTranslation();
const [isSidebarOpen, setSidebarOpen] = useState(false);
const {
activeFilters,
sortOption,
sortedProducts,
handleApplyFilters,
handleSortChange,
removeFilter,
clearAllFilters,
formatFilterTag
} = useProductFilters(products);
// If no products, display a message
if (!products || products.length === 0) {
return (
<BuildBoxLayout title={t('buildBox.title')} step={1}>
<div className="text-center py-8">
<p>{t('buildBox.emptyMessage')}</p>
</div>
</BuildBoxLayout>
);
}
return (
<BuildBoxLayout title={t('buildBox.title')} step={1}>
<div className="mb-8">
{/* Filter and Sort Controls */}
<div className="flex justify-between items-center mb-4">
<FilterButton onClick={() => setSidebarOpen(true)} />
<SortDropdown
value={sortOption}
onChange={handleSortChange}
label={t('products.sort.title')}
/>
</div>
</div>
<ProductGrid
products={sortedProducts}
title=""
showControls={false}
/>
{/* Filter Sidebar */}
<FilterSidebar
isOpen={isSidebarOpen}
onClose={() => setSidebarOpen(false)}
onApplyFilters={handleApplyFilters}
activeFilters={activeFilters}
/>
</BuildBoxLayout>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import { Button } from "@/components/ui/Button";
import { Heading, Text } from "@/components/ui/Typography";
import { useTranslation } from "@/lib/hooks/useTranslation";
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
import { BoxItem, removeFromBox, selectBoxItems, selectBoxTotal, updateQuantity } from "@/lib/redux/slices/boxSlice";
import { Minus, Plus, Trash2 } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { AddBoxToCartClient } from "../AddBoxToCartClient";
interface BuildBoxSidebarWithTranslationProps {
isMobile?: boolean;
}
export function BuildBoxSidebarWithTranslation({ isMobile = false }: BuildBoxSidebarWithTranslationProps) {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const items = useAppSelector(selectBoxItems);
const boxTotal = useAppSelector(selectBoxTotal);
const isEmpty = items.length === 0;
const pathname = usePathname();
// Separate box containers from products
const boxContainers = items.filter(item => item.variantId === 'box-container');
const products = items.filter(item => item.variantId !== 'box-container');
// Check if we have products for the first step
const hasProducts = products.length > 0;
// Check if we have a selected box for the second step
const hasBoxSelected = boxContainers.length > 0;
// Determine which page we're on to show appropriate button text
const isCustomizePage = pathname.includes('/customize');
const nextStepUrl = "/build-box/customize";
const nextStepText = t('buildBox.sidebar.nextStep');
// Determine if the next step button should be disabled
const isNextStepDisabled = isEmpty || (!isCustomizePage && !hasProducts);
const handleUpdateQuantity = (id: string, color: string | undefined, newQuantity: number) => {
if (newQuantity < 1) return;
dispatch(updateQuantity({ id, color, quantity: newQuantity }));
};
const handleRemoveItem = (id: string, color: string | undefined) => {
dispatch(removeFromBox({ id, color }));
};
return (
<div className="h-full flex flex-col">
{/* Fixed header - prikazan samo ako nije mobilni prikaz */}
{!isMobile && (
<div className="p-4 pt-8 pb-4 border-b">
<Heading level={3} className="text-center">{t('buildBox.sidebar.title')}</Heading>
</div>
)}
{/* Scrollable products area */}
<div className="flex-1 overflow-y-auto p-4">
{isEmpty ? (
<div className="text-center py-8 text-gray-500">
<Text>{t('buildBox.sidebar.empty')}</Text>
<Text className="text-sm mt-2">{t('buildBox.sidebar.emptySubtext')}</Text>
</div>
) : (
<div className="space-y-6">
{/* Show box container if any */}
{boxContainers.length > 0 && (
<div className="border-b pb-4 mb-4">
<Text className="font-medium mb-2">{t('buildBox.sidebar.boxDesign')}</Text>
{boxContainers.map((box: BoxItem) => (
<div key={box.compositeKey || box.id} className="flex items-start space-x-3">
<div className="w-20 h-24 relative flex-shrink-0">
<Image
src={box.image}
alt={box.name}
fill
className="object-cover rounded-md"
/>
</div>
<div className="flex-grow">
<div className="flex justify-between">
<div>
<div className="text-base font-medium">{box.name}</div>
<div className="text-sm text-gray-500">${box.price}</div>
</div>
<button
onClick={() => handleRemoveItem(box.id, box.color)}
className="text-gray-400 hover:text-red-500"
aria-label="Remove box"
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Product items */}
{products.length > 0 && (
<>
{products.map((item: BoxItem) => (
<div key={item.compositeKey || item.id} className="flex items-start space-x-3">
<div className="w-20 h-24 relative flex-shrink-0">
<Image
src={item.image}
alt={item.name}
fill
className="object-cover rounded-md"
/>
</div>
<div className="flex-grow flex flex-col justify-between h-24">
<div className="flex justify-between">
<div>
<div className="text-base font-medium">${item.price}</div>
{item.color && (
<div className="text-xs text-gray-500 mt-1 flex items-center">
<div
className="w-3 h-3 rounded-full mr-1"
style={{ backgroundColor: item.color }}
/>
<span>Color</span>
</div>
)}
</div>
<button
onClick={() => handleRemoveItem(item.id, item.color)}
className="text-gray-400 hover:text-red-500"
aria-label="Remove item"
>
<Trash2 size={18} />
</button>
</div>
<div className="flex items-center">
<div className="flex border border-gray-300 rounded-md">
<button
onClick={() => handleUpdateQuantity(item.id, item.color, item.quantity - 1)}
className="px-2 py-1 border-r border-gray-300 text-gray-500 hover:bg-gray-100 disabled:opacity-50"
disabled={item.quantity <= 1}
aria-label="Decrease quantity"
>
<Minus size={14} />
</button>
<span className="px-3 py-1 flex items-center justify-center w-8 text-center">
{item.quantity}
</span>
<button
onClick={() => handleUpdateQuantity(item.id, item.color, item.quantity + 1)}
className="px-2 py-1 border-l border-gray-300 text-gray-500 hover:bg-gray-100"
aria-label="Increase quantity"
>
<Plus size={14} />
</button>
</div>
</div>
</div>
</div>
))}
</>
)}
</div>
)}
</div>
{/* Fixed price and button at bottom */}
<div className="border-t p-4">
<div className="flex justify-between mb-4">
<Text className="font-medium">{t('buildBox.sidebar.boxPrice')}</Text>
<Text className="font-medium">${boxTotal.toFixed(2)}</Text>
</div>
{isCustomizePage ? (
<AddBoxToCartClient />
) : (
<Link
href={isNextStepDisabled ? "#" : nextStepUrl}
className={isNextStepDisabled ? "pointer-events-none" : ""}
>
<Button
variant="primary"
className="w-full py-3"
disabled={isNextStepDisabled}
>
{nextStepText}
</Button>
</Link>
)}
{!isCustomizePage && !hasProducts && !isEmpty && (
<Text className="text-xs text-center text-red-500 mt-2">
{t('buildBox.sidebar.addProductWarning')}
</Text>
)}
{isCustomizePage && !hasBoxSelected && !isEmpty && (
<Text className="text-xs text-center text-red-500 mt-2">
{t('buildBox.sidebar.selectBoxWarning')}
</Text>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { Heading } from "@/components/ui/Typography";
import { useTranslation } from "@/lib/hooks/useTranslation";
import { useEffect, useState } from "react";
import { BuildBoxSidebarWithTranslation } from "./BuildBoxSidebarWithTranslation";
interface ClientSidebarWrapperWithTranslationProps {
isMobile?: boolean;
}
export function ClientSidebarWrapperWithTranslation({ isMobile = false }: ClientSidebarWrapperWithTranslationProps) {
const { t } = useTranslation();
// Use state to prevent hydration errors
const [isClient, setIsClient] = useState(false);
// After component mounts, set isClient to true
useEffect(() => {
setIsClient(true);
}, []);
// On the server, or during hydration, return a placeholder with the same dimensions
if (!isClient) {
return (
<div className="h-full flex flex-col">
{!isMobile && (
<div className="p-4 pt-8 pb-4 border-b">
<Heading level={3} className="text-center">{t('buildBox.sidebar.title')}</Heading>
</div>
)}
<div className="flex-1 p-4">
{/* Placeholder content to match dimensions */}
<div className="text-center py-8 text-gray-500">
<p>Loading...</p>
</div>
</div>
<div className="border-t p-4">
<div className="flex justify-between mb-4">
<p className="font-medium">{t('buildBox.sidebar.boxPrice')}</p>
<p className="font-medium">$0.00</p>
</div>
<button
className="w-full py-3 bg-gray-200 text-gray-500 rounded cursor-not-allowed"
disabled
>
Loading...
</button>
</div>
</div>
);
}
// On the client, after hydration, render the actual sidebar
return <BuildBoxSidebarWithTranslation isMobile={isMobile} />;
}

40
components/carousel.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { getCollectionProducts } from 'lib/shopify';
import Link from 'next/link';
import { GridTileImage } from './grid/tile';
export async function Carousel() {
// Collections that start with `hidden-*` are hidden from the search page.
const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
if (!products?.length) return null;
// Purposefully duplicating products to make the carousel loop and not run out of products on wide screens.
const carouselProducts = [...products, ...products, ...products];
return (
<div className="w-full overflow-x-auto pb-6 pt-1">
<ul className="flex gap-4">
{carouselProducts.map((product, i) => (
<li
key={`${product.handle}${i}`}
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
>
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
<GridTileImage
alt={product.title}
label={{
title: product.title,
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode
}}
src={product.featuredImage?.url}
fill
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
/>
</Link>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,161 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Heading } from '@/components/ui/Typography';
import { useTranslation } from '@/lib/hooks/useTranslation';
import { useAppDispatch } from '@/lib/redux/hooks';
import { loadBoxForEditing } from '@/lib/redux/slices/boxSlice';
import Price from 'components/price';
import { CartItem } from 'lib/shopify/types';
import { ChevronDown, ChevronUp, Pencil, Trash2 } from 'lucide-react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { CartProductItem } from './CartProductItem';
import { getUniqueItemKey } from './processCartItems';
interface CartBoxItemProps {
boxItem: CartItem;
boxProducts: CartItem[];
onUpdate: (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => void;
isPending: boolean;
}
export function CartBoxItem({ boxItem, boxProducts, onUpdate, isPending }: CartBoxItemProps) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(true);
const router = useRouter();
const dispatch = useAppDispatch();
// Get box attributes if any
const boxGroupId = boxItem.attributes?.find(attr => attr.key === '_box_group_id')?.value || 'box';
// Handle box deletion - delete box and all products inside it
const handleBoxDelete = () => {
// First update localStorage to reflect the box deletion
try {
const boxStateString = localStorage.getItem('lastBoxState');
if (boxStateString) {
const boxState = JSON.parse(boxStateString);
// Check if this is the box that's currently saved for editing
if (boxState.originalBoxGroupId === boxGroupId) {
// Remove the box from localStorage
localStorage.removeItem('lastBoxState');
}
}
} catch (error) {
console.error('Error updating box state in localStorage on delete', error);
}
// First delete the box container
onUpdate(boxItem.merchandise.id, 'delete', boxItem.id);
// Then delete all products inside the box
boxProducts.forEach(product => {
if (product.id) {
onUpdate(product.merchandise.id, 'delete', product.id);
}
});
};
// Handle box editing
const handleEditBox = () => {
// Prvo pokušaj direktno spremiti trenutni boxGroupId u lokalno stanje
try {
const currentBoxState = localStorage.getItem('lastBoxState');
let boxStateObject = currentBoxState ? JSON.parse(currentBoxState) : {};
// Dodaj originalni boxGroupId da kasnije znamo što trebamo obrisati
boxStateObject.originalBoxGroupId = boxGroupId;
localStorage.setItem('lastBoxState', JSON.stringify(boxStateObject));
} catch (error) {
console.error('Failed to save original box group ID', error);
}
// Attempt to load box state for editing from localStorage
dispatch(loadBoxForEditing());
// Redirect to build-box page
router.push('/build-box');
};
return (
<div className="mb-6 border-b border-gray-200">
{/* Box Header */}
<div className="flex items-center justify-between py-4">
<div className="flex items-center">
{/* Box Image */}
<div className="relative h-24 w-24 flex-shrink-0 overflow-hidden">
<Image
src={boxItem.merchandise.product.featuredImage?.url || ''}
alt={boxItem.merchandise.product.title}
fill
className="object-cover"
/>
</div>
{/* Box Details */}
<div className="pl-4">
<Heading level={4}>
{boxItem.merchandise.product.title}
</Heading>
<Price
amount={boxItem.cost.totalAmount.amount}
currencyCode={boxItem.cost.totalAmount.currencyCode}
className="text-sm mt-1 font-bold"
/>
</div>
</div>
{/* Box Actions */}
<div className="flex items-center gap-6">
<Button
onClick={handleBoxDelete}
variant="default"
className="p-0 h-auto border-0 bg-transparent hover:bg-transparent text-gray-500 hover:text-red-500"
disabled={isPending}
aria-label={t('cart.remove')}
>
<Trash2 size={20} />
</Button>
<Button
onClick={handleEditBox}
variant="default"
className="p-0 h-auto border-0 bg-transparent hover:bg-transparent text-gray-500 hover:text-gray-700"
aria-label={t('cart.editBox')}
>
<Pencil size={20} />
</Button>
<Button
onClick={() => setIsExpanded(!isExpanded)}
variant="default"
className="p-0 h-auto border-0 bg-transparent hover:bg-transparent text-gray-500 hover:text-gray-700"
aria-label={isExpanded ? t('cart.collapseBox') : t('cart.expandBox')}
>
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</Button>
</div>
</div>
{/* Box Products */}
{isExpanded && boxProducts.length > 0 && (
<div className="space-y-4 py-2 pb-4">
<h4 className="text-sm font-medium text-gray-500 mb-2">{t('cart.customBoxContents')}</h4>
{boxProducts.map((item, index) => (
<CartProductItem
key={getUniqueItemKey(item, boxGroupId, index)}
item={item}
onUpdate={onUpdate}
isPending={isPending}
isInBox={true}
boxGroupId={boxGroupId}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Label, Text } from '@/components/ui/Typography';
import { useTranslation } from '@/lib/hooks/useTranslation';
import { AlertCircle, CheckCircle } from 'lucide-react';
import { FormEvent, useState, useTransition } from 'react';
import { validateDiscountCode } from './actions';
export function CartDiscountForm() {
const { t } = useTranslation();
const [isPending, startTransition] = useTransition();
const [couponCode, setCouponCode] = useState('');
const [couponMessage, setCouponMessage] = useState<{ text: string; isValid: boolean } | null>(null);
// Handle coupon validation
const handleCouponSubmit = (e: FormEvent) => {
e.preventDefault();
if (!couponCode.trim()) {
setCouponMessage({
text: 'Please enter a discount code',
isValid: false
});
return;
}
setCouponMessage(null);
startTransition(async () => {
try {
const result = await validateDiscountCode({}, couponCode);
setCouponMessage({
text: result.message,
isValid: result.isValid
});
} catch (error) {
setCouponMessage({
text: 'Error validating coupon code',
isValid: false
});
}
});
};
return (
<div className="mt-6">
<Label className="mb-2">{t('cart.discountCode')}</Label>
<form onSubmit={handleCouponSubmit} className="space-y-2">
<div className="flex">
<input
type="text"
className="flex-1 border rounded-l-md p-3"
placeholder={t('cart.discountCodePlaceholder')}
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
disabled={isPending}
/>
<Button
type="submit"
variant="primary"
className="rounded-l-none"
disabled={isPending}
>
{isPending ? 'Loading...' : t('cart.apply')}
</Button>
</div>
{/* Coupon feedback message */}
{couponMessage && (
<div className={`flex items-center ${couponMessage.isValid ? 'text-green-600' : 'text-red-600'} mt-2`}>
{couponMessage.isValid ?
<CheckCircle size={16} className="mr-1" /> :
<AlertCircle size={16} className="mr-1" />
}
<Text size="sm" as="span" className={couponMessage.isValid ? 'text-green-600' : 'text-red-600'}>
{couponMessage.text}
</Text>
</div>
)}
</form>
</div>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import { useCart } from 'components/cart/cart-context';
import { ShoppingCart } from 'lucide-react';
import Link from 'next/link';
export function CartLink() {
const { cart } = useCart();
const itemCount = cart?.totalQuantity || 0;
return (
<Link href="/cart" className="group -m-2 flex items-center p-2">
<ShoppingCart
className="h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
<span className="ml-2 text-sm font-medium text-gray-700 group-hover:text-gray-800">
{itemCount}
</span>
<span className="sr-only">items in cart, view cart</span>
</Link>
);
}

View File

@@ -0,0 +1,180 @@
'use client';
import { container } from '@/lib/utils';
import { useCart } from 'components/cart/cart-context';
import { useTransition } from 'react';
import { removeItem, updateItemQuantity } from './actions';
import { CartDiscountForm } from './CartDiscountForm';
import { CartSummary } from './CartSummary';
import { EmptyCartMessage } from './EmptyCartMessage';
import { useCartProcessing } from './hooks/useCartProcessing';
import { BoxesSection } from './sections/BoxesSection';
import { CartHeader } from './sections/CartHeader';
import { CartLoading } from './sections/CartLoading';
import { OrderNotes } from './sections/OrderNotes';
import { ProductsSection } from './sections/ProductsSection';
export default function CartPage() {
const { cart, updateCartItem } = useCart();
const [isPending, startTransition] = useTransition();
const { boxes, standaloneProducts, isGroupingComplete, didInitialProcess } = useCartProcessing(cart);
if (!cart?.lines.length) {
return <EmptyCartMessage />;
}
// Show loading state while processing cart items
if (!didInitialProcess || !isGroupingComplete) {
return <CartLoading />;
}
const handleUpdateCartItem = (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => {
// First update the client-side cart state for immediate feedback
updateCartItem(merchandiseId, updateType, itemId);
// Update lastBoxState in localStorage if this is a box item
try {
const boxStateString = localStorage.getItem('lastBoxState');
if (boxStateString) {
const boxState = JSON.parse(boxStateString);
// Check if this item belongs to a box
const cartItem = cart.lines.find(item => {
if (itemId && item.id) {
return item.id === itemId;
}
return item.merchandise.id === merchandiseId;
});
if (cartItem) {
// Check if it's a box item by looking at attributes
const attrs = cartItem.attributes || [];
const boxType = attrs.find(attr => attr.key === '_box_type')?.value;
const boxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
if (boxType && boxGroupId) {
// Find the corresponding item in the stored state
if (boxType === 'item' && boxState.productItems) {
if (updateType === 'delete') {
// Remove the item from productItems
boxState.productItems = boxState.productItems.filter((item: { id: string, variantId: string }) =>
item.id !== cartItem.merchandise.product.id ||
item.variantId !== cartItem.merchandise.id
);
} else {
// Update quantity
const productItem = boxState.productItems.find((item: { id: string, variantId: string }) =>
item.id === cartItem.merchandise.product.id &&
item.variantId === cartItem.merchandise.id
);
if (productItem) {
const newQuantity = updateType === 'plus'
? cartItem.quantity + 1
: Math.max(1, cartItem.quantity - 1);
productItem.quantity = newQuantity;
}
}
// Save updated state back to localStorage
localStorage.setItem('lastBoxState', JSON.stringify(boxState));
}
else if (boxType === 'container' && boxState.boxItems) {
if (updateType === 'delete') {
// Remove the box
boxState.boxItems = boxState.boxItems.filter((item: { id: string }) =>
item.id !== cartItem.merchandise.product.id
);
} else {
// Update quantity
const boxItem = boxState.boxItems.find((item: { id: string }) =>
item.id === cartItem.merchandise.product.id
);
if (boxItem) {
const newQuantity = updateType === 'plus'
? cartItem.quantity + 1
: Math.max(1, cartItem.quantity - 1);
boxItem.quantity = newQuantity;
}
}
// Save updated state back to localStorage
localStorage.setItem('lastBoxState', JSON.stringify(boxState));
}
}
}
}
} catch (error) {
console.error('Error updating box state in localStorage', error);
}
// Then update the server-side cart
startTransition(() => {
if (updateType === 'delete') {
// Call server action to remove item
removeItem({}, merchandiseId, itemId);
} else {
// Find the specific item to update, using both merchandise ID and item ID if provided
let item = cart.lines.find((item: { id?: string, merchandise: { id: string } }) => {
if (itemId && item.id) {
// If we have item ID, use it for more specific matching
return item.id === itemId;
}
// Fall back to merchandise ID only
return item.merchandise.id === merchandiseId;
});
if (item) {
// Calculate new quantity based on the updateType
const newQuantity = updateType === 'plus'
? item.quantity + 1
: Math.max(1, item.quantity - 1);
// Call server action to update quantity
updateItemQuantity({}, { merchandiseId, quantity: newQuantity, itemId });
}
}
});
};
return (
<div className={container}>
<div className="pb-20">
<div className="flex flex-col lg:flex-row lg:justify-between gap-8">
{/* Left Side: Cart Items */}
<div className="lg:w-[62%]">
<CartHeader totalQuantity={cart.totalQuantity} />
{/* Boxes Section */}
<BoxesSection
boxes={boxes}
onUpdate={handleUpdateCartItem}
isPending={isPending}
/>
{/* Standalone Products Section */}
<ProductsSection
products={standaloneProducts}
onUpdate={handleUpdateCartItem}
isPending={isPending}
/>
{/* Order Notes */}
<OrderNotes />
{/* Discount Form */}
<CartDiscountForm />
</div>
{/* Right Side: Order Summary */}
<div className="lg:w-[30%]">
<div className="mt-6 lg:mt-[72px]">
<CartSummary cart={cart} />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { colorHexMap } from '@/components/products/utils/colorUtils';
import { Button } from '@/components/ui/Button';
import { Text } from '@/components/ui/Typography';
import { useTranslation } from '@/lib/hooks/useTranslation';
import { useCart } from 'components/cart/cart-context';
import Price from 'components/price';
import { CartItem } from 'lib/shopify/types';
import { Trash2 } from 'lucide-react';
import Image from 'next/image';
import { QuantityControls } from './QuantityControls';
interface CartProductItemProps {
item: CartItem;
onUpdate: (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => void;
isPending: boolean;
isInBox?: boolean;
boxGroupId?: string;
}
export function CartProductItem({ item, onUpdate, isPending, isInBox = false, boxGroupId }: CartProductItemProps) {
const { t } = useTranslation();
const { cart } = useCart();
// Check if this item has a color option
const colorOption = item.merchandise.selectedOptions.find(option =>
option.name.toLowerCase() === 'color' ||
option.name.toLowerCase() === 'colour' ||
option.name.toLowerCase() === 'boja'
);
// Get the color hex code from the color name if it exists
const colorHex = colorOption
? colorHexMap[colorOption.value.toLowerCase()] || colorOption.value
: null;
// Get the unique ID of this cart item
const itemId = item.id;
// Handle quantity changes
const handleIncrease = () => onUpdate(item.merchandise.id, 'plus', itemId);
const handleDecrease = () => onUpdate(item.merchandise.id, 'minus', itemId);
// Enhanced delete handler
const handleDelete = () => {
// Basic delete operation for this item
onUpdate(item.merchandise.id, 'delete', itemId);
// Special handling for box items
if (isInBox && boxGroupId && cart) {
// Find all items in this box group
const boxItems = cart.lines.filter(line => {
const attrs = line.attributes || [];
const itemBoxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
const itemBoxType = attrs.find(attr => attr.key === '_box_type')?.value;
return itemBoxGroupId === boxGroupId && itemBoxType === 'item' && line.id !== itemId;
});
// If this is the last item (only 1 left - the one we're deleting), also delete the box container
if (boxItems.length === 0) {
// Find the box container
const boxContainer = cart.lines.find(line => {
const attrs = line.attributes || [];
const containerBoxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
const containerBoxType = attrs.find(attr => attr.key === '_box_type')?.value;
return containerBoxGroupId === boxGroupId && containerBoxType === 'container';
});
// Delete the box container too
if (boxContainer && boxContainer.id) {
onUpdate(boxContainer.merchandise.id, 'delete', boxContainer.id);
}
}
}
};
return (
<div className={`flex flex-wrap md:flex-nowrap items-start ${!isInBox ? 'border-b pb-6' : 'pb-4'}`}>
{/* Product Image */}
<div className="relative h-20 w-20 flex-shrink-0 overflow-hidden">
<Image
src={item.merchandise.product.featuredImage?.url || ''}
alt={item.merchandise.product.title}
fill
className="object-cover"
/>
</div>
{/* Product Details */}
<div className="flex-1 pl-4 min-w-0">
<div className="flex flex-col">
<Text weight={isInBox ? 'regular' : 'semibold'} className="pr-2 break-words">
{item.merchandise.product.title}
</Text>
{/* Show color indicator if color is available */}
{colorHex && (
<div className="flex items-center mt-1">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: colorHex }}
aria-label={`${colorOption?.value || 'Unknown'}`}
/>
<Text size="xs" color="muted" as="span" className="ml-2">
{colorOption?.value || ''}
</Text>
</div>
)}
<Price
amount={item.cost.totalAmount.amount}
currencyCode={item.cost.totalAmount.currencyCode}
className="text-sm mt-1"
/>
</div>
</div>
{/* Quantity Controls */}
<div className="flex items-center ml-auto mt-2 md:mt-0">
<div className="mr-4">
<QuantityControls
quantity={item.quantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
isDisabled={isPending}
minQuantity={1}
/>
</div>
<Button
onClick={handleDelete}
variant="default"
className="p-0 h-auto border-0 bg-transparent hover:bg-transparent text-gray-500 hover:text-red-500"
disabled={isPending}
aria-label={t('cart.remove')}
>
<Trash2 size={20} />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Heading, Text } from '@/components/ui/Typography';
import { useTranslation } from '@/lib/hooks/useTranslation';
import Price from 'components/price';
import { Cart } from 'lib/shopify/types';
import { useState } from 'react';
import { redirectToCheckout } from './actions';
interface CartSummaryProps {
cart: Cart;
}
export function CartSummary({ cart }: CartSummaryProps) {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const handleCheckout = async () => {
setIsLoading(true);
await redirectToCheckout();
setIsLoading(false);
};
return (
<div className="border rounded-md p-6 bg-white sticky top-20">
<Heading level={4} className="mb-6">{t('cart.orderSummary')}</Heading>
<div className="space-y-4 mb-6">
<div className="flex justify-between">
<Text weight="semibold">{t('cart.subtotal')}</Text>
<Price
amount={cart.cost.subtotalAmount.amount}
currencyCode={cart.cost.subtotalAmount.currencyCode}
/>
</div>
<div className="flex justify-between">
<Text weight="semibold">{t('cart.shipping')}</Text>
<Text size="sm" color="muted" className="text-right">{t('cart.calculated')}</Text>
</div>
<div className="flex justify-between">
<Text weight="semibold">{t('cart.total')}</Text>
<Price
amount={cart.cost.totalAmount.amount}
currencyCode={cart.cost.totalAmount.currencyCode}
className="text-xl font-bold"
/>
</div>
</div>
<Button
onClick={handleCheckout}
disabled={isLoading}
variant="primary"
fullWidth
size="lg"
>
{isLoading ? "Loading..." : t('cart.continueToCheckout')}
</Button>
</div>
);
}

View File

@@ -0,0 +1,25 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Section } from '@/components/ui/Section';
import { Heading, Text } from '@/components/ui/Typography';
import { useTranslation } from '@/lib/hooks/useTranslation';
export function EmptyCartMessage() {
const { t } = useTranslation();
return (
<Section>
<div className="flex flex-col items-center justify-center text-center p-8 max-w-md mx-auto border rounded-lg bg-gray-50">
<Heading level={3} className="mb-3">{t('cart.emptyCart')}</Heading>
<Text color="muted" className="mb-6">Add some items to your cart to see them here.</Text>
<Button
href="/products"
variant="primary"
>
{t('cart.startShopping')}
</Button>
</div>
</Section>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Minus, Plus } from 'lucide-react';
interface QuantityControlsProps {
quantity: number;
onIncrease: () => void;
onDecrease: () => void;
isDisabled?: boolean;
minQuantity?: number;
}
export function QuantityControls({
quantity,
onIncrease,
onDecrease,
isDisabled = false,
minQuantity = 1
}: QuantityControlsProps) {
return (
<div className="flex border border-gray-300 rounded-md">
<Button
onClick={onDecrease}
variant="default"
className="px-3 py-1 border-0 rounded-none"
disabled={isDisabled || quantity <= minQuantity}
aria-label="Decrease quantity"
size="sm"
>
<Minus size={16} />
</Button>
<input
type="text"
value={quantity}
readOnly
className="w-10 text-center border-x border-gray-300"
/>
<Button
onClick={onIncrease}
variant="default"
className="px-3 py-1 border-0 rounded-none"
disabled={isDisabled}
aria-label="Increase quantity"
size="sm"
>
<Plus size={16} />
</Button>
</div>
);
}

251
components/cart/actions.ts Normal file
View File

@@ -0,0 +1,251 @@
'use server';
import { TAGS } from 'lib/constants';
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export async function addItem(
prevState: any,
selectedVariantId: string | undefined,
quantity: number = 1,
attributes?: { key: string; value: string }[]
) {
const cookieStore = await cookies();
let cartId = cookieStore.get('cartId')?.value;
if (!selectedVariantId) {
return 'Error adding item to cart';
}
try {
if (!cartId) {
const cart = await createCart();
cartId = cart.id!;
cookieStore.set('cartId', cartId);
}
await addToCart(cartId, [{
merchandiseId: selectedVariantId,
quantity,
attributes
}]);
revalidateTag(TAGS.cart);
} catch (e) {
return 'Error adding item to cart';
}
}
export async function removeItem(prevState: any, merchandiseId: string, itemId?: string) {
const cookieStore = await cookies();
let cartId = cookieStore.get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
try {
const cart = await getCart(cartId);
if (!cart) {
return 'Error fetching cart';
}
// Find specific line item - first by itemId if provided, otherwise by merchandiseId
let lineItem;
if (itemId) {
// If we have a specific item ID (e.g., for box items), use that first
lineItem = cart.lines.find((line) => line.id === itemId);
}
// If no item ID was provided or no match was found, fall back to merchandise ID
if (!lineItem) {
lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);
}
if (lineItem && lineItem.id) {
// Check if this is a box item being removed
const lineAttributes = lineItem.attributes || [];
const boxType = lineAttributes.find(attr => attr.key === '_box_type')?.value;
const boxGroupId = lineAttributes.find(attr => attr.key === '_box_group_id')?.value;
// If this is a box item (not container), check if it's the last one before removing
if (boxType === 'item' && boxGroupId) {
// Find all items in this box (excluding the current one we're removing)
const remainingBoxItems = cart.lines.filter(line => {
const attrs = line.attributes || [];
const itemBoxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
const itemBoxType = attrs.find(attr => attr.key === '_box_type')?.value;
return itemBoxGroupId === boxGroupId &&
line.id !== lineItem.id &&
itemBoxType === 'item';
});
// If this is the last item, find the box container and remove both together
if (remainingBoxItems.length === 0) {
const boxContainer = cart.lines.find(line => {
const attrs = line.attributes || [];
const containerBoxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
const containerBoxType = attrs.find(attr => attr.key === '_box_type')?.value;
return containerBoxGroupId === boxGroupId && containerBoxType === 'container';
});
// If box container exists, remove both item and container in a single operation
if (boxContainer && boxContainer.id) {
// Batched removal of both the item and box container
await removeFromCart(cartId, [lineItem.id, boxContainer.id]);
} else {
// Just remove the item if no container found
await removeFromCart(cartId, [lineItem.id]);
}
} else {
// Not the last item, just remove this one
await removeFromCart(cartId, [lineItem.id]);
}
} else {
// Regular item or box container, just remove it
await removeFromCart(cartId, [lineItem.id]);
}
revalidateTag(TAGS.cart);
} else {
return 'Item not found in cart';
}
} catch (e) {
return 'Error removing item from cart';
}
}
export async function updateItemQuantity(
prevState: any,
payload: {
merchandiseId: string;
quantity: number;
itemId?: string;
}
) {
const cookieStore = await cookies();
let cartId = cookieStore.get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
const { merchandiseId, quantity, itemId } = payload;
try {
const cart = await getCart(cartId);
if (!cart) {
return 'Error fetching cart';
}
// Find the specific line item to update
let lineItem;
if (itemId) {
// First try to find by specific item ID (for items in multiple boxes)
lineItem = cart.lines.find(line => line.id === itemId);
}
// Fall back to finding by merchandise ID if item ID didn't work
if (!lineItem) {
lineItem = cart.lines.find(line => line.merchandise.id === merchandiseId);
}
if (lineItem && lineItem.id) {
if (quantity === 0) {
await removeFromCart(cartId, [lineItem.id]);
} else {
await updateCart(cartId, [
{
id: lineItem.id,
merchandiseId,
quantity
}
]);
}
} else if (quantity > 0) {
// If the item doesn't exist in the cart and quantity > 0, add it
await addToCart(cartId, [{ merchandiseId, quantity }]);
}
revalidateTag(TAGS.cart);
} catch (e) {
console.error(e);
return 'Error updating item quantity';
}
}
export async function redirectToCheckout() {
const cookieStore = await cookies();
let cartId = cookieStore.get('cartId')?.value;
if (!cartId) {
return 'No cart found';
}
let cart = await getCart(cartId);
if (!cart) {
return 'Error fetching cart';
}
if (!cart.checkoutUrl) {
return 'No checkout URL available';
}
redirect(cart.checkoutUrl);
}
export async function createCartAndSetCookie() {
const cookieStore = await cookies();
let cart = await createCart();
cookieStore.set('cartId', cart.id!);
}
export async function validateDiscountCode(prevState: any, discountCode: string) {
if (!discountCode || discountCode.trim() === '') {
return {
isValid: false,
message: 'Please enter a discount code'
};
}
const cookieStore = await cookies();
let cartId = cookieStore.get('cartId')?.value;
if (!cartId) {
return {
isValid: false,
message: 'No cart found. Please add items to your cart first.'
};
}
try {
// TODO: Implement discount code validation
const isValid = Math.random() > 0.5;
if (isValid) {
return {
isValid: true,
message: 'Discount code applied successfully!'
};
} else {
return {
isValid: false,
message: 'Invalid discount code. Please try again.'
};
}
} catch (e) {
console.error('Error validating discount code:', e);
return {
isValid: false,
message: 'An error occurred while validating the discount code.'
};
}
}

View File

@@ -0,0 +1,115 @@
'use client';
import { Button } from '@/components/ui/Button';
import { addItem } from 'components/cart/actions';
import { useProduct } from 'components/product/product-context';
import { Product, ProductVariant } from 'lib/shopify/types';
import { useState, useTransition } from 'react';
import { useCart } from './cart-context';
function SubmitButton({
availableForSale,
selectedVariantId,
isLoading
}: {
availableForSale: boolean;
selectedVariantId: string | undefined;
isLoading: boolean;
}) {
if (!availableForSale) {
return (
<Button
disabled
variant="primary"
size="lg"
fullWidth
>
Out Of Stock
</Button>
);
}
if (!selectedVariantId) {
return (
<Button
aria-label="Please select an option"
disabled
variant="primary"
size="lg"
fullWidth
>
Please Select Options
</Button>
);
}
return (
<Button
aria-label="Add to cart"
disabled={isLoading}
variant="primary"
size="lg"
fullWidth
>
{isLoading ? 'Adding...' : 'Add to Cart'}
</Button>
);
}
export function AddToCart({ product, quantity = 1 }: { product: Product; quantity?: number }) {
const { variants, availableForSale } = product;
const { addCartItem } = useCart();
const { state } = useProduct();
const [isPending, startTransition] = useTransition();
const [isLoading, setIsLoading] = useState(false);
const variant = variants.find((variant: ProductVariant) =>
variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()])
);
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
const selectedVariantId = variant?.id || defaultVariantId;
const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!;
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
// Don't proceed if the form is already being processed
if (isLoading) return;
// Don't proceed if there's no variant ID
if (!selectedVariantId) return;
// Set loading state
setIsLoading(true);
// Use the quantity prop passed to the component
// No need to get it from formData since there's no quantity input in the form
const finalVariant = variants.find((v) => v.id === selectedVariantId);
if (!finalVariant) return;
// Call the server action to add the item to the cart
// @ts-ignore - We know our server action accepts quantity
addItem(null, selectedVariantId, quantity)
.then(() => {
// Add to context cart for immediate UI feedback
addCartItem(finalVariant, product, quantity);
});
// Add a small delay before removing loading state for better UX
setTimeout(() => {
setIsLoading(false);
}, 500);
};
return (
<form onSubmit={handleSubmit} className="w-full">
<SubmitButton
availableForSale={availableForSale}
selectedVariantId={selectedVariantId}
isLoading={isLoading}
/>
</form>
);
}

View File

@@ -0,0 +1,217 @@
'use client';
import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types';
import React, { createContext, use, useContext, useEffect, useMemo, useOptimistic, useTransition } from 'react';
type UpdateType = 'plus' | 'minus' | 'delete';
type CartAction =
| { type: 'UPDATE_ITEM'; payload: { merchandiseId: string; updateType: UpdateType; itemId?: string } }
| { type: 'ADD_ITEM'; payload: { variant: ProductVariant; product: Product, quantity: number } };
type CartContextType = {
cart: Cart | undefined;
updateCartItem: (merchandiseId: string, updateType: UpdateType, itemId?: string) => void;
addCartItem: (variant: ProductVariant, product: Product, quantity?: number) => void;
};
const CartContext = createContext<CartContextType | undefined>(undefined);
function calculateItemCost(quantity: number, price: string): string {
return (Number(price) * quantity).toString();
}
function updateCartItem(item: CartItem, updateType: UpdateType): CartItem | null {
if (updateType === 'delete') return null;
const newQuantity = updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
if (newQuantity === 0) return null;
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
const newTotalAmount = calculateItemCost(newQuantity, singleItemAmount.toString());
return {
...item,
quantity: newQuantity,
cost: {
...item.cost,
totalAmount: {
...item.cost.totalAmount,
amount: newTotalAmount
}
}
};
}
function createOrUpdateCartItem(
existingItem: CartItem | undefined,
variant: ProductVariant,
product: Product,
quantity: number = 1
): CartItem {
const newQuantity = existingItem ? existingItem.quantity + quantity : quantity;
const totalAmount = calculateItemCost(newQuantity, variant.price.amount);
return {
id: existingItem?.id,
quantity: newQuantity,
attributes: existingItem?.attributes || [],
cost: {
totalAmount: {
amount: totalAmount,
currencyCode: variant.price.currencyCode
}
},
merchandise: {
id: variant.id,
title: variant.title,
selectedOptions: variant.selectedOptions,
product: {
id: product.id,
handle: product.handle,
title: product.title,
featuredImage: product.featuredImage
}
}
};
}
function updateCartTotals(lines: CartItem[]): Pick<Cart, 'totalQuantity' | 'cost'> {
const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = lines.reduce((sum, item) => sum + Number(item.cost.totalAmount.amount), 0);
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD';
return {
totalQuantity,
cost: {
subtotalAmount: { amount: totalAmount.toString(), currencyCode },
totalAmount: { amount: totalAmount.toString(), currencyCode },
totalTaxAmount: { amount: '0', currencyCode }
}
};
}
function createEmptyCart(): Cart {
return {
id: undefined,
checkoutUrl: '',
totalQuantity: 0,
lines: [],
cost: {
subtotalAmount: { amount: '0', currencyCode: 'USD' },
totalAmount: { amount: '0', currencyCode: 'USD' },
totalTaxAmount: { amount: '0', currencyCode: 'USD' }
}
};
}
function cartReducer(state: Cart | undefined, action: CartAction): Cart {
const currentCart = state || createEmptyCart();
switch (action.type) {
case 'UPDATE_ITEM': {
const { merchandiseId, updateType, itemId } = action.payload;
const updatedLines = currentCart.lines
.map((item) => {
// If itemId is provided, only update the specific item
if (itemId && item.id !== itemId) {
return item;
}
// Otherwise, update by merchandise ID
if (!itemId && item.merchandise.id !== merchandiseId) {
return item;
}
// Update the matching item
return updateCartItem(item, updateType);
})
.filter(Boolean) as CartItem[];
if (updatedLines.length === 0) {
return {
...currentCart,
lines: [],
totalQuantity: 0,
cost: {
...currentCart.cost,
totalAmount: { ...currentCart.cost.totalAmount, amount: '0' }
}
};
}
return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
}
case 'ADD_ITEM': {
const { variant, product, quantity = 1 } = action.payload;
const existingItem = currentCart.lines.find((item) => item.merchandise.id === variant.id);
const updatedItem = createOrUpdateCartItem(existingItem, variant, product, quantity);
const updatedLines = existingItem
? currentCart.lines.map((item) => (item.merchandise.id === variant.id ? updatedItem : item))
: [...currentCart.lines, updatedItem];
return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
}
default:
return currentCart;
}
}
export function CartProvider({
children,
cartPromise
}: {
children: React.ReactNode;
cartPromise: Promise<Cart | undefined>;
}) {
const initialCart = use(cartPromise);
const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer);
const [isPending, startTransition] = useTransition();
// More detailed debugging for cart loading
useEffect(() => {
if (initialCart?.lines?.length) {
// Detailed inspection of the cart items and their attributes
const itemsWithAttributes = initialCart.lines.filter(item =>
item.attributes && item.attributes.length > 0
);
// Log each item with its attributes for debugging
initialCart.lines.forEach((item, index) => {
const attrs = item.attributes || [];
const boxType = attrs.find(a => a.key === 'box_type')?.value;
const boxGroupId = attrs.find(a => a.key === 'box_group_id')?.value;
});
}
}, [initialCart]);
const updateCartItem = (merchandiseId: string, updateType: UpdateType, itemId?: string) => {
startTransition(() => {
updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType, itemId } });
});
};
const addCartItem = (variant: ProductVariant, product: Product, quantity: number = 1) => {
startTransition(() => {
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product, quantity } });
});
};
const value = useMemo(
() => ({
cart: optimisticCart,
updateCartItem,
addCartItem
}),
[optimisticCart]
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}

View File

@@ -0,0 +1,100 @@
'use client';
import { Cart } from 'lib/shopify/types';
import { useEffect, useState } from 'react';
export function useCartProcessing(cart: Cart | undefined) {
const [boxGroups, setBoxGroups] = useState(new Map<string, { box: any; products: any[] }>());
const [standaloneProducts, setStandaloneProducts] = useState<any[]>([]);
const [isGroupingComplete, setIsGroupingComplete] = useState(false);
const [didInitialProcess, setDidInitialProcess] = useState(false);
// First-time cart load processing with delay
useEffect(() => {
if (!cart?.lines?.length) {
setIsGroupingComplete(true);
return;
}
// Use a small timeout to ensure cart data is fully loaded
const timeoutId = setTimeout(() => {
processCartItems();
}, 100); // 100ms delay
return () => clearTimeout(timeoutId);
}, []);
// Process cart items whenever the cart changes after initial load
useEffect(() => {
if (!didInitialProcess && cart?.lines?.length) {
// Skip first update - handled by the first effect
return;
}
if (!cart?.lines?.length) {
setIsGroupingComplete(true);
return;
}
processCartItems();
}, [cart, didInitialProcess]);
// Process cart items function
const processCartItems = () => {
if (!cart) return;
// Set grouping as incomplete at the start
setIsGroupingComplete(false);
const newBoxGroups = new Map<string, { box: any; products: any[] }>();
const newStandaloneProducts: any[] = [];
// First, sort all items into boxes or standalone products
cart.lines.forEach(item => {
// Skip items without attributes (avoid runtime errors)
if (!item.attributes || item.attributes.length === 0) {
newStandaloneProducts.push(item);
return;
}
const boxType = item.attributes.find(attr => attr.key === '_box_type')?.value;
const boxGroupId = item.attributes.find(attr => attr.key === '_box_group_id')?.value;
if (boxType === 'container' && boxGroupId) {
// This is a box container
if (!newBoxGroups.has(boxGroupId)) {
newBoxGroups.set(boxGroupId, { box: item, products: [] });
} else {
newBoxGroups.get(boxGroupId)!.box = item;
}
} else if (boxType === 'item' && boxGroupId) {
// This is an item that belongs in a box
if (!newBoxGroups.has(boxGroupId)) {
newBoxGroups.set(boxGroupId, { box: null, products: [item] });
} else {
newBoxGroups.get(boxGroupId)!.products.push(item);
}
} else {
// This is a standalone product
newStandaloneProducts.push(item);
}
});
setBoxGroups(newBoxGroups);
setStandaloneProducts(newStandaloneProducts);
setDidInitialProcess(true);
// Mark grouping as complete
setIsGroupingComplete(true);
};
// Get boxes array from the map for rendering
const boxes = Array.from(boxGroups.values()).filter(group => group.box);
return {
boxes,
standaloneProducts,
isGroupingComplete,
didInitialProcess
};
}

View File

@@ -0,0 +1,22 @@
// Function to get a unique identifier for a cart item
export function getUniqueItemKey(item: any, boxGroupId?: string, index?: number): string {
// Start with the merchandise id
let key = item.merchandise.id;
// Add the item's own id if available
if (item.id) {
key = `${key}-${item.id}`;
}
// Add box group id if it's part of a box
if (boxGroupId) {
key = `${boxGroupId}-${key}`;
}
// Add index as fallback to ensure uniqueness
if (index !== undefined) {
key = `${key}-${index}`;
}
return key;
}

View File

@@ -0,0 +1,34 @@
'use client';
import { useTranslation } from '@/lib/hooks/useTranslation';
import { CartItem } from 'lib/shopify/types';
import { CartBoxItem } from '../CartBoxItem';
interface BoxesSectionProps {
boxes: Array<{ box: CartItem; products: CartItem[] }>;
onUpdate: (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => void;
isPending: boolean;
}
export function BoxesSection({ boxes, onUpdate, isPending }: BoxesSectionProps) {
const { t } = useTranslation();
if (boxes.length === 0) return null;
return (
<div className="mb-6">
<h2 className="text-lg font-medium mb-4">{t('cart.boxes')}</h2>
<div className="space-y-4">
{boxes.map((boxGroup) => (
<CartBoxItem
key={boxGroup.box.id}
boxItem={boxGroup.box}
boxProducts={boxGroup.products}
onUpdate={onUpdate}
isPending={isPending}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { Heading, Text } from '@/components/ui/Typography';
import { useTranslation } from '@/lib/hooks/useTranslation';
interface CartHeaderProps {
totalQuantity: number;
}
export function CartHeader({ totalQuantity }: CartHeaderProps) {
const { t } = useTranslation();
return (
<div className="my-6 flex items-center justify-between">
<Heading level={2}>{t('cart.title')}</Heading>
<div className="bg-gray-100 rounded-md px-4 py-2">
<Text>{t('cart.itemCount').replace('{count}', totalQuantity.toString())}</Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
'use client';
import { useTranslation } from '@/lib/hooks/useTranslation';
import { container } from '@/lib/utils';
export function CartLoading() {
const { t } = useTranslation();
return (
<div className={container}>
<div className="flex justify-center items-center py-12">
<div className="animate-pulse text-center">
<p className="text-lg">{t('cart.loading')}</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
'use client';
import { Label } from '@/components/ui/Typography';
import { useTranslation } from '@/lib/hooks/useTranslation';
export function OrderNotes() {
const { t } = useTranslation();
return (
<div className="mt-8">
<Label className="mb-2">{t('cart.notes')}</Label>
<textarea
className="w-full border rounded-md p-3 min-h-[100px]"
placeholder={t('cart.notesPlaceholder')}
/>
</div>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import { useTranslation } from '@/lib/hooks/useTranslation';
import { CartItem } from 'lib/shopify/types';
import { CartProductItem } from '../CartProductItem';
import { getUniqueItemKey } from '../processCartItems';
interface ProductsSectionProps {
products: CartItem[];
onUpdate: (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => void;
isPending: boolean;
}
export function ProductsSection({ products, onUpdate, isPending }: ProductsSectionProps) {
const { t } = useTranslation();
if (products.length === 0) return null;
return (
<div className="mb-6">
<h2 className="text-lg font-medium mb-4">{t('cart.products')}</h2>
<div className="space-y-6">
{products.map((item, index) => (
<CartProductItem
key={getUniqueItemKey(item, 'standalone', index)}
item={item}
onUpdate={onUpdate}
isPending={isPending}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { Button } from '@/components/ui/Button';
import { useCookieConsent } from './CookieContext';
export function CookieBanner() {
const { showBanner, acceptAll, openModal } = useCookieConsent();
if (!showBanner) return null;
return (
<div className="fixed bottom-0 left-0 right-0 z-30 bg-white shadow-lg border-t border-gray-200 px-4 py-3">
<div className="container mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-sm text-gray-700 text-center sm:text-left">
Ova web stranica koristi kolačiće za poboljšanje korisničkog iskustva.
<button
onClick={openModal}
className="underline ml-1 hover:text-black"
>
Više informacija
</button>
</p>
<div className="flex flex-row gap-3">
<Button
variant="outline"
size="sm"
className="px-3 whitespace-nowrap"
onClick={openModal}
>
Prilagodi
</Button>
<Button
variant="primary"
size="sm"
className="whitespace-nowrap"
onClick={acceptAll}
>
Prihvati sve
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import { Heading, Text } from '@/components/ui/Typography';
import { ToggleSwitch } from './ToggleSwitch';
interface CookieCategoryCardProps {
title: string;
description: string;
enabled: boolean;
onChange: () => void;
disabled?: boolean;
}
export function CookieCategoryCard({
title,
description,
enabled,
onChange,
disabled = false
}: CookieCategoryCardProps) {
return (
<div className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<Heading level={4}>{title}</Heading>
<ToggleSwitch
enabled={enabled}
onChange={onChange}
disabled={disabled}
/>
</div>
<Text size="sm" className="text-gray-600">
{description}
</Text>
</div>
);
}

View File

@@ -0,0 +1,160 @@
'use client';
import Cookies from 'js-cookie';
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
// Define cookie categories and their default state
export const COOKIE_CATEGORIES = {
NECESSARY: 'necessary',
ANALYTICS: 'analytics',
MARKETING: 'marketing',
PREFERENCES: 'preferences'
} as const;
export type CookieCategory = typeof COOKIE_CATEGORIES[keyof typeof COOKIE_CATEGORIES];
export interface CookiePreferences {
necessary: boolean; // Always true, can't be disabled
analytics: boolean;
marketing: boolean;
preferences: boolean;
}
export const DEFAULT_PREFERENCES: CookiePreferences = {
necessary: true, // Always true
analytics: false,
marketing: false,
preferences: false
};
const CONSENT_COOKIE_NAME = 'cookie-consent';
const COOKIE_EXPIRY_DAYS = 365;
interface CookieContextType {
preferences: CookiePreferences;
hasConsent: boolean;
showBanner: boolean;
showModal: boolean;
acceptAll: () => void;
savePreferences: (newPrefs: CookiePreferences) => void;
openModal: () => void;
closeModal: () => void;
resetConsent: () => void;
}
const CookieContext = createContext<CookieContextType | undefined>(undefined);
export function CookieProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState<CookiePreferences>(DEFAULT_PREFERENCES);
const [hasConsent, setHasConsent] = useState<boolean>(false);
const [showBanner, setShowBanner] = useState<boolean>(false);
const [showModal, setShowModal] = useState<boolean>(false);
// Load stored consent preferences on mount (client-side only)
useEffect(() => {
const storedConsent = Cookies.get(CONSENT_COOKIE_NAME);
if (storedConsent) {
try {
const parsedPreferences = JSON.parse(storedConsent);
setPreferences({
...DEFAULT_PREFERENCES,
...parsedPreferences,
// Ensure necessary cookies are always enabled
necessary: true
});
setHasConsent(true);
setShowBanner(false);
} catch (error) {
console.error('Failed to parse cookie consent', error);
setShowBanner(true);
}
} else {
// No stored consent, show the banner
setShowBanner(true);
}
}, []);
const savePreferences = (newPrefs: CookiePreferences) => {
const updatedPrefs = {
...newPrefs,
necessary: true
};
// Update state
setPreferences(updatedPrefs);
setHasConsent(true);
setShowBanner(false);
setShowModal(false);
// Save to cookie
Cookies.set(CONSENT_COOKIE_NAME, JSON.stringify(updatedPrefs), {
expires: COOKIE_EXPIRY_DAYS,
path: '/',
sameSite: 'strict'
});
applyPreferences(updatedPrefs);
};
// Accept all cookies
const acceptAll = () => {
const allAccepted = {
necessary: true,
analytics: true,
marketing: true,
preferences: true
};
savePreferences(allAccepted);
};
// Reset consent (for testing)
const resetConsent = () => {
Cookies.remove(CONSENT_COOKIE_NAME);
setPreferences(DEFAULT_PREFERENCES);
setHasConsent(false);
setShowBanner(true);
};
const openModal = () => setShowModal(true);
const closeModal = () => setShowModal(false);
// Apply preferences based on consent
const applyPreferences = (prefs: CookiePreferences) => {
// Apply analytics preference (without console logs)
if (prefs.analytics) {
// Enable analytics tracking
// Add analytics initialization code here if needed
} else {
// Disable analytics tracking
// Add analytics disabling code here if needed
}
};
const value = {
preferences,
hasConsent,
showBanner,
showModal,
acceptAll,
savePreferences,
openModal,
closeModal,
resetConsent
};
return (
<CookieContext.Provider value={value}>
{children}
</CookieContext.Provider>
);
}
export function useCookieConsent() {
const context = useContext(CookieContext);
if (context === undefined) {
throw new Error('useCookieConsent must be used within a CookieProvider');
}
return context;
}

View File

@@ -0,0 +1,164 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Heading, Text } from '@/components/ui/Typography';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { useEffect, useState } from 'react';
import { CookieCategoryCard } from './CookieCategoryCard';
import { CookiePreferences, useCookieConsent } from './CookieContext';
// Cookie category descriptions
const COOKIE_DESCRIPTIONS = {
necessary: {
title: 'Neophodni kolačići',
description: 'Ovi kolačići su neophodni za funkcioniranje web stranice i ne mogu biti isključeni. Oni omogućuju osnovne funkcionalnosti poput navigacije stranice i pristupa sigurnim područjima.'
},
analytics: {
title: 'Analitički kolačići',
description: 'Ovi kolačići nam pomažu razumjeti kako posjetitelji koriste našu web stranicu, prikupljajući anonimne statističke podatke. Oni nam pomažu poboljšati korisničko iskustvo i performanse stranice.'
},
marketing: {
title: 'Marketinški kolačići',
description: 'Ovi kolačići se koriste za praćenje posjetitelja na web stranicama. Namjera je prikazati oglase koji su relevantni i privlačni za pojedinog korisnika i time vrijedniji za izdavače i vanjske oglašivače.'
},
preferences: {
title: 'Kolačići za personalizaciju',
description: 'Ovi kolačići omogućuju web stranici da zapamti izbore koje ste napravili (poput korisničkog imena, jezika ili regije) i pružaju poboljšane, personalizirane značajke.'
}
};
export function CookieSettingsModal() {
const { preferences, showModal, savePreferences, closeModal } = useCookieConsent();
const [localPreferences, setLocalPreferences] = useState<CookiePreferences>(preferences);
// Sync with parent preferences when they change
useEffect(() => {
setLocalPreferences(preferences);
}, [preferences]);
const handleToggle = (category: keyof CookiePreferences) => {
if (category === 'necessary') return; // Necessary cookies can't be disabled
setLocalPreferences(prev => ({
...prev,
[category]: !prev[category]
}));
};
const handleSave = () => {
savePreferences(localPreferences);
};
if (!showModal) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-50"
onClick={closeModal}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="p-4 border-b flex justify-between items-center sticky top-0 bg-white z-20">
<Heading level={3}>Postavke kolačića</Heading>
<button
onClick={closeModal}
className="text-gray-500 hover:text-gray-700"
aria-label="Close"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
{/* Content */}
<div className="p-6">
<Text className="mb-6">
Ova web stranica koristi kolačiće za poboljšanje korisničkog iskustva. Možete prilagoditi svoje postavke
kolačića omogućavanjem ili onemogućavanjem svake kategorije. Kolačići označeni kao "Neophodni"
su potrebni za osnovne funkcije web stranice i ne mogu biti isključeni.
</Text>
{/* Cookie categories */}
<div className="space-y-6">
{/* Necessary cookies - always enabled */}
<CookieCategoryCard
title={COOKIE_DESCRIPTIONS.necessary.title}
description={COOKIE_DESCRIPTIONS.necessary.description}
enabled={true}
onChange={() => {}}
disabled={true}
/>
{/* Analytics cookies */}
<CookieCategoryCard
title={COOKIE_DESCRIPTIONS.analytics.title}
description={COOKIE_DESCRIPTIONS.analytics.description}
enabled={localPreferences.analytics}
onChange={() => handleToggle('analytics')}
/>
{/* Marketing cookies */}
<CookieCategoryCard
title={COOKIE_DESCRIPTIONS.marketing.title}
description={COOKIE_DESCRIPTIONS.marketing.description}
enabled={localPreferences.marketing}
onChange={() => handleToggle('marketing')}
/>
{/* Preference cookies */}
<CookieCategoryCard
title={COOKIE_DESCRIPTIONS.preferences.title}
description={COOKIE_DESCRIPTIONS.preferences.description}
enabled={localPreferences.preferences}
onChange={() => handleToggle('preferences')}
/>
</div>
<div className="mt-6 border-t pt-4">
<Heading level={4} className="mb-3">O kolačićima</Heading>
<Text className="mb-4">
Kolačići su male tekstualne datoteke koje web stranice postavljaju na vaš uređaj prilikom posjeta.
Koriste se za pamćenje vaših postavki, poboljšanje funkcionalnosti i prikupljanje analitičkih podataka.
</Text>
<Heading level={4} className="mb-2 mt-4">Kako koristimo kolačiće</Heading>
<Text className="mb-4">
Koristimo različite vrste kolačića za različite svrhe. Neki su neophodni za rad web stranice,
dok drugi nam pomažu optimizirati sadržaj i korisničko iskustvo.
</Text>
<Heading level={4} className="mb-2 mt-4">Vaša prava</Heading>
<Text className="mb-4">
U skladu s EU regulativom o kolačićima, omogućujemo vam upravljanje postavkama kolačića.
Više informacija možete pronaći u našim <a href="/privacy-policy" className="underline">Pravilima privatnosti</a> i
<a href="/terms-of-service" className="underline ml-1">Uvjetima korištenja</a>.
</Text>
</div>
</div>
{/* Footer with buttons */}
<div className="p-4 border-t sticky bottom-0 bg-white">
<div className="flex justify-end space-x-4">
<Button
variant="outline"
onClick={closeModal}
>
Odustani
</Button>
<Button
variant="primary"
onClick={handleSave}
>
Spremi postavke
</Button>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
interface ToggleSwitchProps {
enabled: boolean;
onChange: () => void;
disabled?: boolean;
}
export function ToggleSwitch({ enabled, onChange, disabled = false }: ToggleSwitchProps) {
return (
<button
onClick={onChange}
disabled={disabled}
className={`relative inline-flex items-center ${disabled ? 'cursor-not-allowed opacity-80' : 'cursor-pointer'}`}
type="button"
role="switch"
aria-checked={enabled}
>
<div className={`w-10 h-6 rounded-full transition ${enabled ? 'bg-primary' : 'bg-gray-300'}`}></div>
<div
className={`absolute inset-y-0 left-0 w-6 h-6 bg-white rounded-full border border-gray-300 transform transition ${
enabled ? 'translate-x-4' : 'translate-x-0'
}`}
></div>
</button>
);
}

View File

@@ -0,0 +1,17 @@
'use client';
import { CookieBanner } from './CookieBanner';
import { CookieProvider } from './CookieContext';
import { CookieSettingsModal } from './CookieSettingsModal';
export function CookieConsent({ children }: { children: React.ReactNode }) {
return (
<CookieProvider>
{children}
<CookieBanner />
<CookieSettingsModal />
</CookieProvider>
);
}
export { useCookieConsent } from './CookieContext';

21
components/grid/index.tsx Normal file
View File

@@ -0,0 +1,21 @@
import clsx from 'clsx';
function Grid(props: React.ComponentProps<'ul'>) {
return (
<ul {...props} className={clsx('grid grid-flow-row gap-4', props.className)}>
{props.children}
</ul>
);
}
function GridItem(props: React.ComponentProps<'li'>) {
return (
<li {...props} className={clsx('aspect-square transition-opacity', props.className)}>
{props.children}
</li>
);
}
Grid.Item = GridItem;
export default Grid;

View File

@@ -0,0 +1,61 @@
import { GridTileImage } from 'components/grid/tile';
import { getCollectionProducts } from 'lib/shopify';
import type { Product } from 'lib/shopify/types';
import Link from 'next/link';
function ThreeItemGridItem({
item,
size,
priority
}: {
item: Product;
size: 'full' | 'half';
priority?: boolean;
}) {
return (
<div
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
>
<Link
className="relative block aspect-square h-full w-full"
href={`/product/${item.handle}`}
prefetch={true}
>
<GridTileImage
src={item.featuredImage.url}
fill
sizes={
size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw'
}
priority={priority}
alt={item.title}
label={{
position: size === 'full' ? 'center' : 'bottom',
title: item.title as string,
amount: item.priceRange.maxVariantPrice.amount,
currencyCode: item.priceRange.maxVariantPrice.currencyCode
}}
/>
</Link>
</div>
);
}
export async function ThreeItemGrid() {
// Collections that start with `hidden-*` are hidden from the search page.
const homepageItems = await getCollectionProducts({
collection: 'hidden-homepage-featured-items'
});
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
return (
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]">
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
<ThreeItemGridItem size="half" item={thirdProduct} />
</section>
);
}

42
components/grid/tile.tsx Normal file
View File

@@ -0,0 +1,42 @@
import clsx from 'clsx';
import Image from 'next/image';
import Label from '../Label';
export function GridTileImage({
isInteractive = true,
active,
label,
...props
}: {
isInteractive?: boolean;
active?: boolean;
label?: {
title: string;
amount: string;
currencyCode: string;
position?: 'bottom' | 'center';
};
} & React.ComponentProps<typeof Image>) {
return (
<div
className={clsx(
'group flex h-full w-full items-center justify-center overflow-hidden',
)}
>
{props.src ? (
<Image
className='relative h-full w-full object-contain'
{...props}
/>
) : null}
{label ? (
<Label
title={label.title}
amount={label.amount}
currencyCode={label.currencyCode}
position={label.position}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { CustomCard } from "@/components/ui/CustomCard";
import { Section } from "@/components/ui/Section";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { useTranslation } from "@/lib/hooks/useTranslation";
export function CardSection() {
const { t } = useTranslation();
// Card data from translations
const cardsData = [
{
title: t('cardSection.cards.0.title'),
description: t('cardSection.cards.0.description'),
imageSrc: "/assets/images/card1.png",
imageAlt: t('cardSection.cards.0.imageAlt')
},
{
title: t('cardSection.cards.1.title'),
description: t('cardSection.cards.1.description'),
imageSrc: "/assets/images/card2.png",
imageAlt: t('cardSection.cards.1.imageAlt')
},
{
title: t('cardSection.cards.2.title'),
description: t('cardSection.cards.2.description'),
imageSrc: "/assets/images/card3.png",
imageAlt: t('cardSection.cards.2.imageAlt')
}
];
return (
<Section>
<div className="flex flex-col items-start">
<SectionHeader
title={t('cardSection.title')}
description={t('cardSection.description')}
className="mb-[60px]"
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 w-full">
{cardsData.map((card, index) => (
<CustomCard
key={index}
title={card.title}
description={card.description}
imageSrc={card.imageSrc}
imageAlt={card.imageAlt}
/>
))}
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { Section } from "@/components/ui/Section";
import { Heading, Text } from "@/components/ui/Typography";
import { useTranslation } from "@/lib/hooks/useTranslation";
import { container } from "@/lib/utils";
// Define the review data structure
interface Review {
id: number;
author: string;
comment: string;
}
export function CustomerReviews() {
const { t, locale } = useTranslation();
// Definiraj reviewse po jeziku
const reviews: Review[] = locale === 'en' ? [
{
id: 1,
author: "Emily Johnson",
comment: "\"The Build a Box feature made gift-giving so easy and special!\""
},
{
id: 2,
author: "Michael Smith",
comment: "\"Sent's service exceeded my expectations every time!\""
},
{
id: 3,
author: "Sarah Lee",
comment: "\"The quality of the items was outstanding!\""
}
] : [
{
id: 1,
author: "Ana Kovačić",
comment: "\"Opcija 'Složi kutiju' učinila je darivanje tako jednostavnim i posebnim!\""
},
{
id: 2,
author: "Marko Horvat",
comment: "\"Usluga Sent-a nadmašila je moja očekivanja svaki put!\""
},
{
id: 3,
author: "Ivana Novak",
comment: "\"Kvaliteta proizvoda bila je izvanredna!\""
}
];
return (
<Section className="bg-white overflow-hidden">
{/* Keep the heading inside container */}
<div className={container}>
<Heading level={2} className="text-center mb-[60px]">
{t('customerReviews.title')}
</Heading>
{/* Mobile: Full width scrollable container, Desktop: Grid */}
<div className="md:hidden -mx-4">
<div className="flex overflow-x-auto pb-6 px-4 snap-x no-scrollbar">
{/* Add empty div at start to ensure space */}
<div className="shrink-0 w-[5%]"></div>
{reviews.map((review) => (
<div
key={review.id}
className="bg-secondary shrink-0 w-[85%] sm:w-[70%] h-[235px] p-8 flex flex-col snap-center mx-3"
>
<Text className="mb-6 flex-grow">
{review.comment}
</Text>
<div className="mt-auto">
<Text className="text-primary font-bold">{review.author}</Text>
</div>
</div>
))}
{/* Add empty div at end to ensure space */}
<div className="shrink-0 w-[5%]"></div>
</div>
</div>
{/* Desktop: Original grid layout */}
<div className="hidden md:grid md:grid-cols-3 gap-8">
{reviews.map((review) => (
<div
key={review.id}
className="bg-secondary h-[235px] p-8 flex flex-col"
>
<Text className="mb-6 flex-grow">
{review.comment}
</Text>
<div className="mt-auto">
<Text className="text-primary font-bold">{review.author}</Text>
</div>
</div>
))}
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Heading, Text } from "@/components/ui/Typography";
import { useTranslation } from "@/lib/hooks/useTranslation";
import { container } from "@/lib/utils";
import Image from "next/image";
export function GiftBoxBuilder() {
const { t } = useTranslation();
return (
<div className="relative">
{/* Top frame image */}
<div className="w-full">
<Image
src="/assets/images/Frame4.png"
alt=""
width={1440}
height={140}
className="w-full object-cover"
/>
</div>
{/* Main content with pink background */}
<div className="bg-[#F58EA7]">
<div className={`${container} py-[80px]`}>
<div className="flex flex-col md:flex-row md:items-center">
{/* Image container - takes up full width on mobile, half on desktop */}
<div className="w-full md:w-1/2 h-[240px] md:h-[500px] relative mb-8 md:mb-0">
<Image
src="/assets/images/build-box.png"
alt={t('giftBoxBuilder.altText')}
fill
className="object-cover"
priority
/>
</div>
{/* Text container - takes up full width on mobile, half on desktop */}
<div className="w-full md:w-1/2 md:pl-20">
<Heading level={2} className="mb-4">
{t('giftBoxBuilder.title')}
</Heading>
<Text size="lg" className="mb-8">
{t('giftBoxBuilder.description')}
</Text>
<Button
href="/build-box"
variant="filled"
size="lg"
fullWidthMobile
>
{t('giftBoxBuilder.button')}
</Button>
</div>
</div>
</div>
</div>
{/* Bottom frame image */}
<div className="w-full">
<Image
src="/assets/images/Frame5.png"
alt=""
width={1440}
height={140}
className="w-full object-cover"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Carousel } from "@/components/ui/Carousel";
import { Section } from "@/components/ui/Section";
import { Heading, Text } from "@/components/ui/Typography";
import { useTranslation } from "@/lib/hooks/useTranslation";
import { CarouselSlide } from "@/lib/types/carousel";
import { container } from "@/lib/utils";
import Image from "next/image";
export default function HeroCarousel() {
const { t } = useTranslation();
// Carousel data with translations
const carouselData: CarouselSlide[] = [
{
id: 1,
title: t('hero.title'),
titleColored: t('hero.title_colored'),
titleEnd: t('hero.title_end'),
description: t('hero.description'),
buttonText: t('hero.build_box_button'),
buttonLink: "/build-box",
imageSrc: "/assets/images/carousel1.png"
},
{
id: 2,
title: t('hero.title'),
titleColored: t('hero.title_colored'),
titleEnd: t('hero.title_end'),
description: t('hero.description'),
buttonText: t('hero.build_box_button'),
buttonLink: "/build-box",
imageSrc: "/assets/images/carousel2.png"
},
{
id: 3,
title: t('hero.title'),
titleColored: t('hero.title_colored'),
titleEnd: t('hero.title_end'),
description: t('hero.description'),
buttonText: t('hero.build_box_button'),
buttonLink: "/build-box",
imageSrc: "/assets/images/carousel1.png"
}
];
// Default slide to use as fallback
const defaultSlide: CarouselSlide = {
id: 0,
title: t('hero.title'),
titleColored: t('hero.title_colored'),
titleEnd: t('hero.title_end'),
description: t('hero.description'),
buttonText: t('hero.build_box_button'),
buttonLink: "/build-box",
imageSrc: "/assets/images/image1.png"
};
// Create mobile slide components - each image needs to be in a container div
const mobileImageSlides = carouselData.map((slide, index) => (
<div key={slide.id} className="relative w-full h-full">
{/* Background gradient */}
<div className="absolute inset-0 bg-gradient-to-r from-[#958C87] via-[#A9A19C] via-[#BDBAB3] via-[#C5C1C0] via-[#C7C1C1] via-[#C3BFBE] to-[#C4C0BF]"></div>
{/* Image container - right side with padding */}
<div className="absolute right-0 top-0 h-full w-1/2">
<div className="relative h-full py-8">
<Image
src={slide.imageSrc}
alt={slide.title}
fill
sizes="50vw"
className="object-contain"
priority={index === 0}
/>
</div>
</div>
</div>
));
// Create desktop slide components
const desktopSlides = carouselData.map((slide, index) => (
<div key={slide.id} className="w-full h-full">
{/* Background gradient */}
<div className="absolute inset-0 bg-gradient-to-r from-[#958C87] via-[#A9A19C] via-[#BDBAB3] via-[#C5C1C0] via-[#C7C1C1] via-[#C3BFBE] to-[#C4C0BF]"></div>
{/* Image container - positioned on the right with padding */}
<div className="absolute right-0 top-0 h-full w-[46.3%]">
<div className="relative h-full py-12">
<Image
src={slide.imageSrc}
alt={slide.title}
fill
sizes="46.3vw"
className="object-contain"
priority={index === 0}
/>
</div>
</div>
{/* Container for proper alignment */}
<div className={`relative h-full ${container} flex items-end`}>
{/* Text overlay */}
<div className="pb-32 max-w-2xl">
<Heading level={1} className="mb-4 text-black text-[40px] leading-[48px] md:text-[60px] md:leading-[76px] flex flex-col font-bold">
<span>{slide.title}</span>
<span className="text-primary">{slide.titleColored}</span>
<span>{slide.titleEnd}</span>
</Heading>
<Text size="lg" className="mb-6 text-black text-[18px] leading-[28px]">
{slide.description}
</Text>
<div className="flex gap-4">
<Button
href={slide.buttonLink}
variant="filled"
size="lg"
>
{slide.buttonText}
</Button>
<Button
href="/products"
variant="custom"
size="lg"
>
{t('hero.ready_made_button')}
</Button>
</div>
</div>
</div>
</div>
));
return (
<Section spacing="none" fullWidth={true}>
<div className="w-full">
{/* Mobile version */}
<div className="md:hidden">
{/* Image carousel */}
<div className="relative w-full h-[350px] bg-gradient-to-r from-[#958C87] via-[#A9A19C] via-[#BDBAB3] via-[#C5C1C0] via-[#C7C1C1] via-[#C3BFBE] to-[#C4C0BF]">
<Carousel
slides={mobileImageSlides}
interval={10000}
indicatorsClassName="absolute bottom-4 left-0 right-0"
className="w-full h-full"
/>
</div>
{/* Text content below carousel on mobile */}
<div className="p-6 bg-white">
<Heading level={1} className="mb-3 text-[40px] leading-[48px] flex flex-col font-bold">
<span>{carouselData[0]?.title || defaultSlide.title}</span>
<span className="text-primary">{carouselData[0]?.titleColored || defaultSlide.titleColored}</span>
<span>{carouselData[0]?.titleEnd || defaultSlide.titleEnd}</span>
</Heading>
<Text className="mb-6 text-[18px] leading-[28px]">
{carouselData[0]?.description || defaultSlide.description}
</Text>
<Button
href={carouselData[0]?.buttonLink || defaultSlide.buttonLink}
variant="filled"
size="lg"
fullWidth
>
{carouselData[0]?.buttonText || defaultSlide.buttonText}
</Button>
</div>
</div>
{/* Desktop version */}
<div className="hidden md:block relative h-[600px]">
<Carousel
slides={desktopSlides}
interval={10000}
indicatorsClassName="absolute bottom-6 left-0 right-0"
className="w-full h-full"
/>
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,31 @@
import { CardSection } from "./CardSection";
import { CustomerReviews } from "./CustomerReviews";
import { GiftBoxBuilder } from "./GiftBoxBuilder";
import HeroCarousel from "./HeroCarousel";
import { ProductSliderWrapper } from "./ProductSliderWrapper";
import { WoltDelivery } from "./WoltDelivery";
export default function NewHomePage() {
return (
<div className="min-h-screen bg-white">
{/* Hero Section with Carousel */}
<HeroCarousel />
{/* Card Section */}
<CardSection />
{/* Gift Box Builder Section */}
<GiftBoxBuilder />
{/* Product Slider Section */}
<ProductSliderWrapper />
{/* Customer Reviews Section */}
<CustomerReviews />
{/* Wolt Delivery Section */}
<WoltDelivery />
</div>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { getProductColors } from "@/components/products/utils/colorUtils";
import { Button } from "@/components/ui/Button";
import { ProductCard } from "@/components/ui/ProductCard";
import { cn } from "@/lib/utils";
import useEmblaCarousel from "embla-carousel-react";
import { Product } from "lib/shopify/types";
import { useCallback, useEffect, useState } from "react";
import { FiChevronLeft, FiChevronRight } from "react-icons/fi";
interface ProductSliderProps {
products: Product[];
}
/**
* A product slider component that displays products in a horizontal scrollable slider.
* Uses the Embla Carousel library for the sliding functionality.
*/
export function ProductSlider({ products }: ProductSliderProps) {
const [emblaRef, emblaApi] = useEmblaCarousel({
align: "start",
containScroll: false,
loop: false,
});
const [prevBtnEnabled, setPrevBtnEnabled] = useState(false);
const [nextBtnEnabled, setNextBtnEnabled] = useState(false);
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev();
}, [emblaApi]);
const scrollNext = useCallback(() => {
if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]);
const onSelect = useCallback(() => {
if (!emblaApi) return;
setPrevBtnEnabled(emblaApi.canScrollPrev());
setNextBtnEnabled(emblaApi.canScrollNext());
}, [emblaApi]);
useEffect(() => {
if (!emblaApi) return;
onSelect();
emblaApi.on("select", onSelect);
emblaApi.on("reInit", onSelect);
}, [emblaApi, onSelect]);
return (
<div className="relative">
<div className="overflow-visible" ref={emblaRef}>
<div className="flex">
{products.map((product, index) => (
<div
key={product.id}
className={cn(
"w-[85%] min-[500px]:w-[90%] min-[700px]:w-[48.5%] min-[1100px]:w-[32%] min-w-0 flex-grow-0 flex-shrink-0 mr-[2%]",
)}
>
<ProductCard
title={product.title}
variant={product.variants[0]?.title || ""}
price={parseFloat(product.priceRange.maxVariantPrice.amount)}
imageSrc={product.featuredImage?.url || "/assets/images/placeholder_image.svg"}
slug={product.handle}
product={product}
colors={getProductColors(product)}
/>
</div>
))}
</div>
</div>
{/* Previous button */}
<Button
onClick={scrollPrev}
disabled={!prevBtnEnabled}
variant="default"
size="sm"
className={cn(
"absolute left-[-18px] top-1/2 transform -translate-y-1/2 z-10",
"w-9 h-9 text-sm bg-white shadow-md border border-gray-200 rounded-full",
!prevBtnEnabled && "opacity-50 cursor-not-allowed"
)}
>
<FiChevronLeft className="w-5 h-5" />
</Button>
{/* Next button */}
<Button
onClick={scrollNext}
disabled={!nextBtnEnabled}
variant="default"
size="sm"
className={cn(
"absolute right-[-18px] top-1/2 transform -translate-y-1/2 z-10",
"w-9 h-9 text-sm bg-white shadow-md border border-gray-200 rounded-full",
!nextBtnEnabled && "opacity-50 cursor-not-allowed"
)}
>
<FiChevronRight className="w-5 h-5" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Section } from "@/components/ui/Section";
import { Heading, Text } from "@/components/ui/Typography";
import { useTranslation } from "@/lib/hooks/useTranslation";
import type { Product } from "lib/shopify/types";
import { ProductSlider } from "./ProductSlider";
interface ProductSliderSectionProps {
products: Product[];
}
/**
* Presentation component for the product slider section
* Handles the layout and presentation aspects, but not data fetching
*/
export function ProductSliderSection({ products }: ProductSliderSectionProps) {
const { t } = useTranslation();
return (
<div className="overflow-x-hidden w-full">
<Section spacing="medium">
<div className="mb-8">
{/* Container for header section with relative positioning */}
<div className="relative">
{/* Button positioned absolutely to the right */}
<div className="hidden sm:block absolute right-0 bottom-[0px]">
<Button
href="/search"
variant="custom"
size="lg"
fullWidthMobile={false}
>
{t('productSlider.button')}
</Button>
</div>
{/* Title and description */}
<div className="mb-4">
<Heading level={2}>{t('productSlider.title')}</Heading>
</div>
<div>
<Text size="lg" className="mb-4 sm:mb-0">{t('productSlider.description')}</Text>
</div>
</div>
{/* Mobile full-width button */}
<div className="sm:hidden mt-2">
<Button
href="/search"
variant="custom"
size="lg"
fullWidthMobile
>
{t('productSlider.button')}
</Button>
</div>
</div>
{/* Carousel container s overflow da se vidi 4. proizvod */}
<div className="relative w-full overflow-visible">
<ProductSlider products={products} />
</div>
</Section>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { getCollectionProducts } from 'lib/shopify';
import { ProductSliderSection } from './ProductSliderSection';
/**
* Server component responsible for fetching product data
*/
export async function ProductSliderWrapper() {
// Fetch products from Shopify
// Try to fetch from a dedicated collection first, and if not available, fetch recent products
const products = await getCollectionProducts({
collection: 'hidden-homepage-carousel',
sortKey: 'CREATED_AT',
reverse: true
}).catch(() => {
return [];
});
// If no products found, don't render anything
if (!products || products.length === 0) {
return null;
}
// Render the slider section with fetched products
return <ProductSliderSection products={products} />;
}

View File

@@ -0,0 +1,90 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Heading, Text } from "@/components/ui/Typography";
import { useTranslation } from "@/lib/hooks/useTranslation";
import { container } from "@/lib/utils";
import Image from "next/image";
export function WoltDelivery() {
const { t } = useTranslation();
return (
<div className="relative">
{/* Top wave */}
<div className="w-full">
<Image
src="/assets/images/Frame3.png"
alt=""
width={1440}
height={140}
className="w-full object-cover"
/>
</div>
{/* Main content with background color */}
<div className="bg-wolt">
<div className={`${container} py-[80px] relative`}>
{/* Desktop Wolt logo - only visible on desktop */}
<div className="absolute top-0 right-0 z-10 hidden md:block">
<div className="w-[160px] h-[160px] bg-wolt-blue rounded-md flex items-center justify-center">
<Image
src="/assets/images/wolt.png"
alt="Wolt"
width={120}
height={120}
className="object-contain"
/>
</div>
</div>
{/* Text and image layout */}
<div className="flex flex-col md:flex-row md:items-center">
{/* Text container - comes first on both mobile and desktop */}
<div className="w-full md:w-1/2 md:pr-12 order-1 mb-8 md:mb-0">
<Heading level={2} className="mb-4">
{t('woltDelivery.title')}
</Heading>
<Text size="lg" className="mb-8">
{t('woltDelivery.description')}
</Text>
<Button
href="https://wolt.com"
external
variant="filled"
size="lg"
fullWidthMobile
>
{t('woltDelivery.button')}
</Button>
</div>
{/* Image container with mobile logo */}
<div className="w-full md:w-1/2 h-[310px] md:h-[400px] relative order-2 mb-12 md:mb-0">
<Image
src="/assets/images/wolt-image.png"
alt={t('woltDelivery.altText')}
fill
className="object-cover"
priority
/>
{/* Mobile Wolt logo - positioned at bottom-right of image */}
<div className="absolute bottom-[-40px] right-[-10px] z-10 md:hidden">
<div className="w-[120px] h-[120px] bg-wolt-blue rounded-md flex items-center justify-center">
<Image
src="/assets/images/wolt.png"
alt="Wolt"
width={90}
height={90}
className="object-contain"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
'use client';
import { NextIntlClientProvider } from 'next-intl';
import { ReactNode } from 'react';
type IntlProviderProps = {
locale: string;
children: ReactNode;
messages: any;
};
export function IntlProvider({ locale, messages, children }: IntlProviderProps) {
return (
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import Cookies from 'js-cookie';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
export function LanguageSwitcher() {
const router = useRouter();
const pathname = usePathname();
const [currentLocale, setCurrentLocale] = useState('hr');
// Detect current language from URL
useEffect(() => {
const locale = pathname.startsWith('/en') ? 'en' : 'hr';
setCurrentLocale(locale);
}, [pathname]);
const switchLanguage = (locale: string) => {
// Set cookie for future visits
Cookies.set('NEXT_LOCALE', locale, { expires: 365, path: '/' });
// Construct proper path based on current path and target locale
let newPath;
if (locale === 'en') {
// If switching to English
if (pathname.startsWith('/en')) {
// Already in English, no need to change path
newPath = pathname;
} else {
// Add /en prefix to current path
newPath = `/en${pathname}`;
}
} else {
// If switching to Croatian
if (pathname.startsWith('/en')) {
// Remove /en prefix
newPath = pathname.substring(3) || '/';
} else {
// Already in Croatian, no need to change path
newPath = pathname;
}
}
// Navigate to the new path
router.push(newPath);
};
return (
<div className="flex items-center space-x-2">
<button
onClick={() => switchLanguage('hr')}
className={`px-2 py-1 ${currentLocale === 'hr' ? 'font-bold' : ''}`}
aria-label="Prebaci na hrvatski"
>
HR
</button>
<span>|</span>
<button
onClick={() => switchLanguage('en')}
className={`px-2 py-1 ${currentLocale === 'en' ? 'font-bold' : ''}`}
aria-label="Switch to English"
>
EN
</button>
</div>
);
}

16
components/icons/logo.tsx Normal file
View File

@@ -0,0 +1,16 @@
import clsx from 'clsx';
export default function LogoIcon(props: React.ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-label={`${process.env.SITE_NAME} logo`}
viewBox="0 0 32 28"
{...props}
className={clsx('h-4 w-4 fill-black dark:fill-white', props.className)}
>
<path d="M21.5758 9.75769L16 0L0 28H11.6255L21.5758 9.75769Z" />
<path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" />
</svg>
);
}

View File

@@ -0,0 +1,32 @@
import Grid from 'components/grid';
import { GridTileImage } from 'components/grid/tile';
import { Product } from 'lib/shopify/types';
import Link from 'next/link';
export default function ProductGridItems({ products }: { products: Product[] }) {
return (
<>
{products.map((product) => (
<Grid.Item key={product.handle} className="animate-fadeIn">
<Link
className="relative inline-block h-full w-full"
href={`/product/${product.handle}`}
prefetch={true}
>
<GridTileImage
alt={product.title}
label={{
title: product.title,
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode
}}
src={product.featuredImage?.url}
fill
sizes="(min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
/>
</Link>
</Grid.Item>
))}
</>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
const categories = {
'Men': [
{ name: 'T-Shirts', href: '/collections/mens-t-shirts' },
{ name: 'Hoodies', href: '/collections/mens-hoodies' },
{ name: 'Pants', href: '/collections/mens-pants' },
{ name: 'Accessories', href: '/collections/mens-accessories' }
],
'Women': [
{ name: 'Dresses', href: '/collections/womens-dresses' },
{ name: 'Tops', href: '/collections/womens-tops' },
{ name: 'Bottoms', href: '/collections/womens-bottoms' },
{ name: 'Accessories', href: '/collections/womens-accessories' }
]
};
export default function Categories() {
const [activeSection, setActiveSection] = useState<string | null>(null);
return (
<div className="relative">
<div className="flex gap-6">
{Object.keys(categories).map((section) => (
<button
key={section}
className="relative py-4 text-sm font-medium text-gray-700 hover:text-gray-900"
onMouseEnter={() => setActiveSection(section)}
onMouseLeave={() => setActiveSection(null)}
>
{section}
</button>
))}
</div>
{/* Dropdown menu */}
{activeSection && (
<div
className="absolute left-0 top-full z-10 w-48 rounded-md bg-white py-2 shadow-lg ring-1 ring-black ring-opacity-5"
onMouseEnter={() => setActiveSection(activeSection)}
onMouseLeave={() => setActiveSection(null)}
>
{categories[activeSection as keyof typeof categories].map((category) => (
<Link
key={category.name}
href={category.href}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
{category.name}
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import { useCookieConsent } from "@/components/cookies";
import { useTranslation } from "@/lib/hooks/useTranslation";
import { container } from "@/lib/utils";
import Image from "next/image";
import Link from "next/link";
export function Footer() {
const { t } = useTranslation();
const { openModal } = useCookieConsent();
// Definiraj linkove s prijevodima
const navLinks = [
{ name: t('footer.build_box'), href: "/build-box" },
{ name: t('footer.products'), href: "/products" },
{ name: t('footer.about'), href: "/about" }
];
const legalLinks = [
{ name: t('footer.privacy_policy'), href: "/privacy-policy" },
{ name: t('footer.terms_of_service'), href: "/terms-of-service" },
{ name: t('footer.cookies_settings'), href: "#", isButton: true }
];
const socialLinks = [
{ name: t('footer.social.facebook'), href: "https://facebook.com/sentshop", icon: "/assets/images/Facebook.png" },
{ name: t('footer.social.instagram'), href: "https://instagram.com/sentshop", icon: "/assets/images/Instagram.png" }
];
const handleCookiesSettings = () => {
openModal();
};
return (
<footer className="bg-gray-100">
<div className={container}>
{/* Main footer content */}
<div className="py-12">
<div className="flex flex-col md:flex-row md:justify-between md:items-center">
{/* Logo */}
<div className="flex justify-center md:justify-start mb-8 md:mb-0">
<Link href="/" aria-label="Logo">
<Image
src="/assets/images/logo.svg"
alt="SENT logo"
width={125}
height={35}
className="w-[125px] h-auto object-contain"
/>
</Link>
</div>
{/* Navigation */}
<nav className="flex flex-col items-center md:flex-row md:space-x-8 mb-8 md:mb-0">
{navLinks.map((link, index) => (
<Link
key={index}
href={link.href}
className="text-[16px] font-bold text-gray-700 hover:text-gray-900 mb-4 md:mb-0"
>
{link.name}
</Link>
))}
</nav>
{/* Social media icons */}
<div className="flex items-center justify-center md:justify-end space-x-6 mb-8 md:mb-0">
{socialLinks.map((social, index) => (
<Link
key={index}
href={social.href}
aria-label={social.name}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-gray-700"
>
<Image
src={social.icon}
alt={social.name}
width={20}
height={20}
/>
</Link>
))}
</div>
</div>
</div>
{/* Legal footer */}
<div className="border-t border-gray-200">
<div className="py-6">
<div className="flex flex-col md:flex-row justify-center items-center text-[14px] leading-[21px] text-gray-500 space-y-4 md:space-y-0 md:space-x-6">
<span>&copy; {new Date().getFullYear()} Sent. {t('footer.all_rights_reserved')}</span>
{legalLinks.map((link, index) => (
link.isButton ? (
<button
key={index}
onClick={handleCookiesSettings}
className="hover:text-gray-700 text-[14px] leading-[21px]"
>
{link.name}
</button>
) : (
<Link
key={index}
href={link.href}
className="hover:text-gray-700 text-[14px] leading-[21px]"
>
{link.name}
</Link>
)
))}
</div>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,252 @@
'use client';
import { LanguageSwitcher } from '@/components/i18n/LanguageSwitcher';
import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/lib/hooks/useTranslation';
import { container } from '@/lib/utils';
import { useCart } from 'components/cart/cart-context';
import { Menu, X } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
export default function Navbar() {
const { t, locale } = useTranslation();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { cart } = useCart();
const pathname = usePathname();
// Create links with translations and proper prefix
const getNavLinks = () => {
const links = [
{ name: t('navbar.build_box'), path: '/build-box' },
{ name: t('navbar.products'), path: '/products' },
{ name: t('navbar.about'), path: '/about' }
];
// Add locale prefix for English paths
if (locale === 'en') {
return links.map(link => ({
...link,
href: `/en${link.path}`
}));
}
// Return paths as is for Croatian
return links.map(link => ({
...link,
href: link.path
}));
};
const navLinks = getNavLinks();
// Close mobile menu on resize to desktop
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 768) {
setIsMenuOpen(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Prevent scrolling when mobile menu is open
useEffect(() => {
if (isMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isMenuOpen]);
// Create home link with proper prefix
const homeLink = locale === 'en' ? '/en' : '/';
// Create cart link with proper prefix
const cartLink = locale === 'en' ? '/en/cart' : '/cart';
return (
<>
<header className="border-b border-gray-200 sticky top-0 z-40 bg-white">
<div className={`${container} flex h-16 items-center justify-between`}>
{/* Logo */}
<Link href={homeLink} className="flex items-center h-12">
<Image
src="/assets/images/logo.svg"
alt="Logo"
width={125}
height={35}
className="w-[125px] h-auto object-contain"
priority
/>
</Link>
{/* Desktop Nav and Cart */}
<div className="hidden md:flex items-center space-x-8">
{navLinks.map((link) => {
const isActive = pathname === link.href ||
(link.href !== homeLink && pathname.startsWith(link.href));
return (
<Link
key={link.name}
href={link.href}
className={`text-sm font-medium transition-colors ${
isActive
? 'text-primary hover:text-primary-dark'
: 'text-black hover:text-primary'
}`}
>
{link.name}
</Link>
);
})}
<LanguageSwitcher />
<Link
href={cartLink}
className="inline-flex h-10 items-center justify-center rounded-button bg-primary px-6 py-2 text-sm font-medium text-white relative hover:bg-primary-dark"
>
<span>{t('navbar.cart')}</span>
<Image
src="/assets/images/cart-icon.png"
alt="Cart"
width={20}
height={20}
className="ml-2"
/>
{cart?.totalQuantity ? (
<span className="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white">
{cart.totalQuantity}
</span>
) : null}
</Link>
</div>
{/* Mobile menu button and cart */}
<div className="md:hidden flex items-center space-x-4">
<Link
href={cartLink}
className="relative text-black"
aria-label="View cart"
>
<Image
src="/assets/images/cart-icon-black.png"
alt="Cart"
width={24}
height={24}
/>
{cart?.totalQuantity ? (
<span className="absolute -right-2 -top-3 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white">
{cart.totalQuantity}
</span>
) : null}
</Link>
<button
className="text-gray-500"
onClick={() => setIsMenuOpen(true)}
aria-label="Open menu"
>
<Menu className="h-6 w-6" />
</button>
</div>
{/* Mobile menu */}
{isMenuOpen && (
<div className="fixed inset-0 z-50 bg-white md:hidden">
{/* Mobile menu header - use same container and height as main navbar */}
<div className={`${container} flex h-16 items-center justify-between`}>
<Link
href={homeLink}
className="flex items-center h-12"
onClick={() => setIsMenuOpen(false)}
>
<Image
src="/assets/images/logo.svg"
alt="Logo"
width={125}
height={35}
className="w-[125px] h-auto object-contain"
priority
/>
</Link>
<button
className="text-gray-500"
onClick={() => setIsMenuOpen(false)}
aria-label="Close menu"
>
<X className="h-6 w-6" />
</button>
</div>
{/* Mobile menu links */}
<div className={`${container} flex-1 flex flex-col py-4`}>
<div className="space-y-4">
{navLinks.map((link) => {
const isActive = pathname === link.href ||
(link.href !== homeLink && pathname.startsWith(link.href));
return (
<Link
key={link.name}
href={link.href}
className={`block text-lg font-medium py-2 transition-colors ${
isActive
? 'text-primary'
: 'text-black hover:text-primary'
}`}
onClick={() => setIsMenuOpen(false)}
>
{link.name}
</Link>
);
})}
<div className="py-2">
<LanguageSwitcher />
</div>
<Link
href={cartLink}
className="mt-4 flex h-12 w-full items-center justify-center relative"
onClick={() => setIsMenuOpen(false)}
>
<Button
variant="primary"
size="lg"
className="w-full h-full flex items-center justify-center"
>
<span>{t('navbar.cart')}</span>
<Image
src="/assets/images/cart-icon.png"
alt="Cart"
width={20}
height={20}
className="ml-2"
/>
{cart?.totalQuantity ? (
<span className="absolute right-4 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white">
{cart.totalQuantity}
</span>
) : null}
</Button>
</Link>
</div>
</div>
</div>
)}
</div>
</header>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More