chore: transfer repo
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal 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"
|
||||||
34
.gitea/workflows/deploy-staging.yml
Normal file
34
.gitea/workflows/deploy-staging.yml
Normal 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
38
.gitignore
vendored
Normal 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
28
.vscode/launch.json
vendored
Normal 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
9
.vscode/settings.json
vendored
Normal 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
61
Dockerfile
Normal 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
74
README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
[](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
12
app/[page]/layout.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/[page]/opengraph-image.tsx
Normal file
11
app/[page]/opengraph-image.tsx
Normal 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
45
app/[page]/page.tsx
Normal 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
11
app/about/page.tsx
Normal 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
21
app/api/products/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/api/revalidate/route.ts
Normal file
6
app/api/revalidate/route.ts
Normal 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);
|
||||||
|
}
|
||||||
10
app/build-box/customize/page.tsx
Normal file
10
app/build-box/customize/page.tsx
Normal 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
11
app/build-box/page.tsx
Normal 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
10
app/cart/page.tsx
Normal 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
19
app/error.tsx
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
28
app/fonts.css
Normal file
28
app/fonts.css
Normal 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
153
app/globals.css
Normal 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
92
app/layout.tsx
Normal 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
8
app/opengraph-image.tsx
Normal 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
20
app/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
app/privacy-policy/page.tsx
Normal file
64
app/privacy-policy/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
app/product/[handle]/page.tsx
Normal file
122
app/product/[handle]/page.tsx
Normal 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
11
app/products/page.tsx
Normal 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
15
app/robots.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
11
app/search/[collection]/opengraph-image.tsx
Normal file
11
app/search/[collection]/opengraph-image.tsx
Normal 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 });
|
||||||
|
}
|
||||||
45
app/search/[collection]/page.tsx
Normal file
45
app/search/[collection]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/search/children-wrapper.tsx
Normal file
10
app/search/children-wrapper.tsx
Normal 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
24
app/search/layout.tsx
Normal 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
18
app/search/loading.tsx
Normal 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
38
app/search/page.tsx
Normal 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">"{searchValue}"</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
54
app/sitemap.ts
Normal 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];
|
||||||
|
}
|
||||||
78
app/terms-of-service/page.tsx
Normal file
78
app/terms-of-service/page.tsx
Normal 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
29
app/test-boxes/page.tsx
Normal 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
21
components.json
Normal 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
34
components/Label.tsx
Normal 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;
|
||||||
15
components/LoadingDots.tsx
Normal file
15
components/LoadingDots.tsx
Normal 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;
|
||||||
104
components/about/AboutPageContent.tsx
Normal file
104
components/about/AboutPageContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
components/build-box/AddBoxToCartClient.tsx
Normal file
173
components/build-box/AddBoxToCartClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/build-box/BuildBoxClientPage.tsx
Normal file
53
components/build-box/BuildBoxClientPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
components/build-box/BuildBoxCustomizeClient.tsx
Normal file
119
components/build-box/BuildBoxCustomizeClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
components/build-box/BuildBoxCustomizePage.tsx
Normal file
21
components/build-box/BuildBoxCustomizePage.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
24
components/build-box/BuildBoxMobileSummary.tsx
Normal file
24
components/build-box/BuildBoxMobileSummary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
components/build-box/BuildBoxPage.tsx
Normal file
12
components/build-box/BuildBoxPage.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
203
components/build-box/BuildBoxSidebar.tsx
Normal file
203
components/build-box/BuildBoxSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
components/build-box/ClientSidebarWrapper.tsx
Normal file
49
components/build-box/ClientSidebarWrapper.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
24
components/build-box/RedirectIfEmptyBox.tsx
Normal file
24
components/build-box/RedirectIfEmptyBox.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
components/build-box/client/BuildBoxCustomizePageContent.tsx
Normal file
47
components/build-box/client/BuildBoxCustomizePageContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
components/build-box/client/BuildBoxLayout.tsx
Normal file
113
components/build-box/client/BuildBoxLayout.tsx
Normal 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}:
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
components/build-box/client/BuildBoxPageContent.tsx
Normal file
71
components/build-box/client/BuildBoxPageContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
components/build-box/client/BuildBoxSidebarWithTranslation.tsx
Normal file
210
components/build-box/client/BuildBoxSidebarWithTranslation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
40
components/carousel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
components/cart/CartBoxItem.tsx
Normal file
161
components/cart/CartBoxItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
components/cart/CartDiscountForm.tsx
Normal file
84
components/cart/CartDiscountForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
components/cart/CartLink.tsx
Normal file
23
components/cart/CartLink.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
components/cart/CartPage.tsx
Normal file
180
components/cart/CartPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
components/cart/CartProductItem.tsx
Normal file
142
components/cart/CartProductItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
components/cart/CartSummary.tsx
Normal file
64
components/cart/CartSummary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/cart/EmptyCartMessage.tsx
Normal file
25
components/cart/EmptyCartMessage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
components/cart/QuantityControls.tsx
Normal file
51
components/cart/QuantityControls.tsx
Normal 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
251
components/cart/actions.ts
Normal 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.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
115
components/cart/add-to-cart.tsx
Normal file
115
components/cart/add-to-cart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
components/cart/cart-context.tsx
Normal file
217
components/cart/cart-context.tsx
Normal 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;
|
||||||
|
}
|
||||||
100
components/cart/hooks/useCartProcessing.ts
Normal file
100
components/cart/hooks/useCartProcessing.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
22
components/cart/processCartItems.tsx
Normal file
22
components/cart/processCartItems.tsx
Normal 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;
|
||||||
|
}
|
||||||
34
components/cart/sections/BoxesSection.tsx
Normal file
34
components/cart/sections/BoxesSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/cart/sections/CartHeader.tsx
Normal file
20
components/cart/sections/CartHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
components/cart/sections/CartLoading.tsx
Normal file
18
components/cart/sections/CartLoading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
components/cart/sections/OrderNotes.tsx
Normal file
18
components/cart/sections/OrderNotes.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
components/cart/sections/ProductsSection.tsx
Normal file
34
components/cart/sections/ProductsSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
components/cookies/CookieBanner.tsx
Normal file
45
components/cookies/CookieBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
components/cookies/CookieCategoryCard.tsx
Normal file
36
components/cookies/CookieCategoryCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
components/cookies/CookieContext.tsx
Normal file
160
components/cookies/CookieContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
164
components/cookies/CookieSettingsModal.tsx
Normal file
164
components/cookies/CookieSettingsModal.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
components/cookies/ToggleSwitch.tsx
Normal file
27
components/cookies/ToggleSwitch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
components/cookies/index.tsx
Normal file
17
components/cookies/index.tsx
Normal 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
21
components/grid/index.tsx
Normal 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;
|
||||||
61
components/grid/three-items.tsx
Normal file
61
components/grid/three-items.tsx
Normal 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
42
components/grid/tile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
components/home/CardSection.tsx
Normal file
56
components/home/CardSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
components/home/CustomerReviews.tsx
Normal file
107
components/home/CustomerReviews.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/home/GiftBoxBuilder.tsx
Normal file
73
components/home/GiftBoxBuilder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
components/home/HeroCarousel.tsx
Normal file
184
components/home/HeroCarousel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
components/home/NewHomePage.tsx
Normal file
31
components/home/NewHomePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
components/home/ProductSlider.tsx
Normal file
107
components/home/ProductSlider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
components/home/ProductSliderSection.tsx
Normal file
69
components/home/ProductSliderSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/home/ProductSliderWrapper.tsx
Normal file
25
components/home/ProductSliderWrapper.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
90
components/home/WoltDelivery.tsx
Normal file
90
components/home/WoltDelivery.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
components/i18n/IntlProvider.tsx
Normal file
18
components/i18n/IntlProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
components/i18n/LanguageSwitcher.tsx
Normal file
68
components/i18n/LanguageSwitcher.tsx
Normal 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
16
components/icons/logo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
components/layout/ProductGridItems.tsx
Normal file
32
components/layout/ProductGridItems.tsx
Normal 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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
components/layout/categories.tsx
Normal file
59
components/layout/categories.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
components/layout/footer.tsx
Normal file
120
components/layout/footer.tsx
Normal 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>© {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
components/layout/navbar/index.tsx
Normal file
252
components/layout/navbar/index.tsx
Normal 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
Reference in New Issue
Block a user