Working state: Serbian at root (/), English at /en, proper i18n structure

This commit is contained in:
Neo
2026-03-04 08:48:13 +00:00
commit cbb49ba64f
51 changed files with 10050 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM node:22-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json* ./
RUN npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs || true
RUN adduser --system --uid 1001 nextjs || true
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

24
next.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import createNextIntlPlugin from "next-intl/plugin";
import type { NextConfig } from "next";
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{
protocol: "https",
hostname: "manoonoils.com",
pathname: "/**",
},
{
protocol: "https",
hostname: "minio-api.nodecrew.me",
pathname: "/**",
},
],
},
};
export default withNextIntl(nextConfig);

7683
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "manoonoils-store",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@woocommerce/woocommerce-rest-api": "^1.0.2",
"framer-motion": "^12.34.4",
"next": "16.1.6",
"next-intl": "^4.8.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/manoon-logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

66
src/app/about/page.tsx Normal file
View File

@@ -0,0 +1,66 @@
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
export const metadata = {
title: "About - ManoonOils",
description: "Learn about ManoonOils - our story, mission, and commitment to natural beauty.",
};
export default function AboutPage() {
return (
<main className="min-h-screen pt-16 md:pt-20">
<Header />
<section className="py-20 px-4">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl md:text-5xl font-serif text-center mb-8">
Our Story
</h1>
<div className="prose prose-lg max-w-none text-foreground-muted space-y-6">
<p>
ManoonOils was born from a passion for natural beauty and the belief
that the best skincare comes from nature itself. Our journey began with
a simple question: how can we create products that truly nurture both
hair and skin?
</p>
<p>
We believe in the power of natural ingredients. Every oil in our
collection is carefully selected for its unique properties and
benefits. From nourishing oils that restore hair vitality to serums
that rejuvenate skin, we craft each product with love and attention
to detail.
</p>
<h2 className="font-serif text-2xl text-foreground mt-8 mb-4">
Our Mission
</h2>
<p>
Our mission is to provide premium quality, natural products that
enhance your daily beauty routine. We are committed to:
</p>
<ul className="list-disc pl-6 space-y-2">
<li>Using only the finest natural ingredients</li>
<li>Cruelty-free and ethical production</li>
<li>Sustainable packaging practices</li>
<li>Transparency in our formulations</li>
</ul>
<h2 className="font-serif text-2xl text-foreground mt-8 mb-4">
Handmade with Love
</h2>
<p>
Every bottle of ManoonOils is handcrafted with care. We small-batch
produce our products to ensure the highest quality and freshness.
When you use ManoonOils, you can feel confident that you're using
something made with genuine care and expertise.
</p>
</div>
</div>
</section>
<Footer />
</main>
);
}

114
src/app/contact/page.tsx Normal file
View File

@@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
export default function ContactPage() {
const [formData, setFormData] = useState({
name: "",
email: "",
message: "",
});
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setSubmitted(true);
};
return (
<main className="min-h-screen pt-16 md:pt-20">
<Header />
<section className="py-20 px-4">
<div className="max-w-2xl mx-auto">
<h1 className="text-4xl md:text-5xl font-serif text-center mb-8">
Contact Us
</h1>
<p className="text-foreground-muted text-center mb-12">
Have questions? We'd love to hear from you.
</p>
{submitted ? (
<div className="bg-green-50 text-green-700 p-6 text-center">
<p className="text-lg">Thank you for your message!</p>
<p className="mt-2">We'll get back to you soon.</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
Name
</label>
<input
type="text"
id="name"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 border border-border focus:outline-none focus:border-foreground"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email
</label>
<input
type="email"
id="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-3 border border-border focus:outline-none focus:border-foreground"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-2">
Message
</label>
<textarea
id="message"
required
rows={5}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
className="w-full px-4 py-3 border border-border focus:outline-none focus:border-foreground resize-none"
/>
</div>
<button
type="submit"
className="w-full py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
>
Send Message
</button>
</form>
)}
<div className="mt-16 pt-8 border-t border-border/30">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
<div>
<h3 className="font-serif mb-2">Email</h3>
<p className="text-foreground-muted">hello@manoonoils.com</p>
</div>
<div>
<h3 className="font-serif mb-2">Shipping</h3>
<p className="text-foreground-muted">Free over 3000 RSD</p>
</div>
<div>
<h3 className="font-serif mb-2">Location</h3>
<p className="text-foreground-muted">Serbia</p>
</div>
</div>
</div>
</div>
</section>
<Footer />
</main>
);
}

66
src/app/en/about/page.tsx Normal file
View File

@@ -0,0 +1,66 @@
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
export const metadata = {
title: "About - ManoonOils",
description: "Learn about ManoonOils - our story, mission, and commitment to natural beauty.",
};
export default function AboutPage() {
return (
<main className="min-h-screen pt-16 md:pt-20">
<Header />
<section className="py-20 px-4">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl md:text-5xl font-serif text-center mb-8">
Our Story
</h1>
<div className="prose prose-lg max-w-none text-foreground-muted space-y-6">
<p>
ManoonOils was born from a passion for natural beauty and the belief
that the best skincare comes from nature itself. Our journey began with
a simple question: how can we create products that truly nurture both
hair and skin?
</p>
<p>
We believe in the power of natural ingredients. Every oil in our
collection is carefully selected for its unique properties and
benefits. From nourishing oils that restore hair vitality to serums
that rejuvenate skin, we craft each product with love and attention
to detail.
</p>
<h2 className="font-serif text-2xl text-foreground mt-8 mb-4">
Our Mission
</h2>
<p>
Our mission is to provide premium quality, natural products that
enhance your daily beauty routine. We are committed to:
</p>
<ul className="list-disc pl-6 space-y-2">
<li>Using only the finest natural ingredients</li>
<li>Cruelty-free and ethical production</li>
<li>Sustainable packaging practices</li>
<li>Transparency in our formulations</li>
</ul>
<h2 className="font-serif text-2xl text-foreground mt-8 mb-4">
Handmade with Love
</h2>
<p>
Every bottle of ManoonOils is handcrafted with care. We small-batch
produce our products to ensure the highest quality and freshness.
When you use ManoonOils, you can feel confident that you're using
something made with genuine care and expertise.
</p>
</div>
</div>
</section>
<Footer />
</main>
);
}

114
src/app/en/contact/page.tsx Normal file
View File

@@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
export default function ContactPage() {
const [formData, setFormData] = useState({
name: "",
email: "",
message: "",
});
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setSubmitted(true);
};
return (
<main className="min-h-screen pt-16 md:pt-20">
<Header />
<section className="py-20 px-4">
<div className="max-w-2xl mx-auto">
<h1 className="text-4xl md:text-5xl font-serif text-center mb-8">
Contact Us
</h1>
<p className="text-foreground-muted text-center mb-12">
Have questions? We'd love to hear from you.
</p>
{submitted ? (
<div className="bg-green-50 text-green-700 p-6 text-center">
<p className="text-lg">Thank you for your message!</p>
<p className="mt-2">We'll get back to you soon.</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
Name
</label>
<input
type="text"
id="name"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 border border-border focus:outline-none focus:border-foreground"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email
</label>
<input
type="email"
id="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-3 border border-border focus:outline-none focus:border-foreground"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-2">
Message
</label>
<textarea
id="message"
required
rows={5}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
className="w-full px-4 py-3 border border-border focus:outline-none focus:border-foreground resize-none"
/>
</div>
<button
type="submit"
className="w-full py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
>
Send Message
</button>
</form>
)}
<div className="mt-16 pt-8 border-t border-border/30">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
<div>
<h3 className="font-serif mb-2">Email</h3>
<p className="text-foreground-muted">hello@manoonoils.com</p>
</div>
<div>
<h3 className="font-serif mb-2">Shipping</h3>
<p className="text-foreground-muted">Free over 3000 RSD</p>
</div>
<div>
<h3 className="font-serif mb-2">Location</h3>
<p className="text-foreground-muted">Serbia</p>
</div>
</div>
</div>
</div>
</section>
<Footer />
</main>
);
}

67
src/app/en/page.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { getProducts } from "@/lib/woocommerce";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import ProductCard from "@/components/product/ProductCard";
export const metadata = {
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
description: "Discover our premium collection of natural oils for hair and skin care. Handmade with love.",
};
export default async function Homepage() {
const products = await getProducts();
const publishedProducts = products.filter((p) => p.status === "publish").slice(0, 4);
return (
<main className="min-h-screen">
<Header />
{/* Hero Section */}
<section className="relative h-[80vh] flex items-center justify-center bg-gradient-to-b from-white to-background-ice">
<div className="text-center px-4">
<h1 className="text-5xl md:text-7xl font-serif mb-6">
ManoonOils
</h1>
<p className="text-xl md:text-2xl text-foreground-muted mb-8">
Premium Natural Oils for Hair & Skin
</p>
<a
href="/products"
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium hover:bg-opacity-90 transition-all"
>
Shop Now
</a>
</div>
</section>
{/* Products Section */}
{publishedProducts.length > 0 && (
<section className="py-20 px-4">
<div className="max-w-7xl mx-auto">
<h2 className="text-4xl font-serif text-center mb-12">Our Products</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
{publishedProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
))}
</div>
</div>
</section>
)}
{/* About Teaser */}
<section className="py-20 px-4 bg-background-ice">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl font-serif mb-6">Natural & Pure</h2>
<p className="text-lg text-foreground-muted mb-8">
Our oils are crafted with love using only the finest natural ingredients.
</p>
<a href="/about" className="text-foreground border-b border-foreground pb-1">
Learn More
</a>
</div>
</section>
<Footer />
</main>
);
}

View File

@@ -0,0 +1,77 @@
import { getProducts } from "@/lib/woocommerce";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
export async function generateStaticParams() {
try {
const products = await getProducts();
return products.map((product) => ({
slug: product.slug || product.id.toString(),
}));
} catch {
return [];
}
}
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let product = null;
try {
const products = await getProducts();
product = products.find((p) => (p.slug || p.id.toString()) === slug);
} catch (e) {
// Fallback
}
if (!product) {
return (
<main className="min-h-screen">
<Header />
<div className="pt-24 text-center">
<h1 className="text-2xl">Product not found</h1>
</div>
<Footer />
</main>
);
}
const image = product.images?.[0]?.src || '/placeholder.jpg';
const price = product.sale_price || product.price;
return (
<main className="min-h-screen">
<Header />
<section className="pt-24 pb-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden">
<img
src={image}
alt={product.name}
className="object-cover w-full h-full"
/>
</div>
<div className="flex flex-col">
<h1 className="text-4xl font-serif mb-4">{product.name}</h1>
<div className="text-2xl mb-6">{price} RSD</div>
<div className="prose max-w-none mb-8" dangerouslySetInnerHTML={{ __html: product.description || '' }} />
<button
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium text-center hover:bg-opacity-90 transition-all"
>
Add to Cart
</button>
</div>
</div>
</div>
</section>
<Footer />
</main>
);
}

View File

@@ -0,0 +1,41 @@
import { getProducts } from "@/lib/woocommerce";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import ProductCard from "@/components/product/ProductCard";
export const metadata = {
title: "Products - ManoonOils",
description: "Browse our collection of premium natural oils for hair and skin care.",
};
export default async function ProductsPage() {
const products = await getProducts();
const publishedProducts = products.filter((p) => p.status === "publish");
return (
<main className="min-h-screen pt-16 md:pt-20">
<Header />
<section className="py-20 px-4">
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl md:text-5xl font-serif text-center mb-16">
All Products
</h1>
{publishedProducts.length === 0 ? (
<p className="text-center text-foreground-muted">No products available</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
{publishedProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
))}
</div>
)}
</div>
</section>
<Footer />
</main>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

55
src/app/globals.css Normal file
View File

@@ -0,0 +1,55 @@
@import "tailwindcss";
:root {
--background: #f0f4f8;
--background-ice: #e8f0f5;
--foreground: #1a1a1a;
--foreground-muted: #666666;
--accent: #a8c5d8;
--accent-dark: #7ba3bc;
--white: #ffffff;
--border: #d1d9e0;
}
@theme inline {
--color-background: var(--background);
--color-background-ice: var(--background-ice);
--color-foreground: var(--foreground);
--color-foreground-muted: var(--foreground-muted);
--color-accent: var(--accent);
--color-accent-dark: var(--accent-dark);
--color-white: var(--white);
--color-border: var(--border);
--font-display: var(--font-cedrat);
--font-body: var(--font-dm-sans);
}
@font-face {
font-family: 'Cedrat Display';
src: url('https://fonts.gstatic.com/s/cedratdisplay/v16/0nkoC9_pK3CvS5lZuZ7MAUmK5w.woff2') format('woff2');
font-weight: 400 900;
font-display: swap;
}
@font-face {
font-family: 'DM Sans';
src: url('https://fonts.gstatic.com/s/dmsans/v15/rP2tp2ywxg089UriI5-g4vlH9VoD8CmcqZG40F9JadbnoEwAopxhS2f3ZGMZpg.woff2') format('woff2');
font-weight: 400 700;
font-display: swap;
}
* {
box-sizing: border-box;
}
body {
background: var(--background);
color: var(--foreground);
font-family: 'DM Sans', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Cedrat Display', serif;
}

31
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,31 @@
import "./globals.css";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "ManoonOils - Premium Natural Oils for Hair & Skin",
template: "%s | ManoonOils",
},
description: "Discover our premium collection of natural oils for hair and skin care. Handmade with love.",
robots: "index, follow",
openGraph: {
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
description: "Discover our premium collection of natural oils for hair and skin care.",
type: "website",
locale: "en_US",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="antialiased">
{children}
</body>
</html>
);
}

67
src/app/page.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { getProducts } from "@/lib/woocommerce";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import ProductCard from "@/components/product/ProductCard";
export const metadata = {
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
description: "Discover our premium collection of natural oils for hair and skin care. Handmade with love.",
};
export default async function Homepage() {
const products = await getProducts();
const publishedProducts = products.filter((p) => p.status === "publish").slice(0, 4);
return (
<main className="min-h-screen">
<Header />
{/* Hero Section */}
<section className="relative h-[80vh] flex items-center justify-center bg-gradient-to-b from-white to-background-ice">
<div className="text-center px-4">
<h1 className="text-5xl md:text-7xl font-serif mb-6">
ManoonOils
</h1>
<p className="text-xl md:text-2xl text-foreground-muted mb-8">
Premium Natural Oils for Hair & Skin
</p>
<a
href="/products"
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium hover:bg-opacity-90 transition-all"
>
Shop Now
</a>
</div>
</section>
{/* Products Section */}
{publishedProducts.length > 0 && (
<section className="py-20 px-4">
<div className="max-w-7xl mx-auto">
<h2 className="text-4xl font-serif text-center mb-12">Our Products</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
{publishedProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
))}
</div>
</div>
</section>
)}
{/* About Teaser */}
<section className="py-20 px-4 bg-background-ice">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl font-serif mb-6">Natural & Pure</h2>
<p className="text-lg text-foreground-muted mb-8">
Our oils are crafted with love using only the finest natural ingredients.
</p>
<a href="/about" className="text-foreground border-b border-foreground pb-1">
Learn More
</a>
</div>
</section>
<Footer />
</main>
);
}

View File

@@ -0,0 +1,77 @@
import { getProducts } from "@/lib/woocommerce";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
export async function generateStaticParams() {
try {
const products = await getProducts();
return products.map((product) => ({
slug: product.slug || product.id.toString(),
}));
} catch {
return [];
}
}
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let product = null;
try {
const products = await getProducts();
product = products.find((p) => (p.slug || p.id.toString()) === slug);
} catch (e) {
// Fallback
}
if (!product) {
return (
<main className="min-h-screen">
<Header />
<div className="pt-24 text-center">
<h1 className="text-2xl">Product not found</h1>
</div>
<Footer />
</main>
);
}
const image = product.images?.[0]?.src || '/placeholder.jpg';
const price = product.sale_price || product.price;
return (
<main className="min-h-screen">
<Header />
<section className="pt-24 pb-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden">
<img
src={image}
alt={product.name}
className="object-cover w-full h-full"
/>
</div>
<div className="flex flex-col">
<h1 className="text-4xl font-serif mb-4">{product.name}</h1>
<div className="text-2xl mb-6">{price} RSD</div>
<div className="prose max-w-none mb-8" dangerouslySetInnerHTML={{ __html: product.description || '' }} />
<button
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium text-center hover:bg-opacity-90 transition-all"
>
Add to Cart
</button>
</div>
</div>
</div>
</section>
<Footer />
</main>
);
}

41
src/app/products/page.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { getProducts } from "@/lib/woocommerce";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import ProductCard from "@/components/product/ProductCard";
export const metadata = {
title: "Products - ManoonOils",
description: "Browse our collection of premium natural oils for hair and skin care.",
};
export default async function ProductsPage() {
const products = await getProducts();
const publishedProducts = products.filter((p) => p.status === "publish");
return (
<main className="min-h-screen pt-16 md:pt-20">
<Header />
<section className="py-20 px-4">
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl md:text-5xl font-serif text-center mb-16">
All Products
</h1>
{publishedProducts.length === 0 ? (
<p className="text-center text-foreground-muted">No products available</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
{publishedProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
))}
</div>
)}
</div>
</section>
<Footer />
</main>
);
}

16
src/app/robots.ts Normal file
View File

@@ -0,0 +1,16 @@
import { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
return {
rules: [
{
userAgent: "*",
allow: "/",
},
],
sitemap: `${baseUrl}/sitemap.xml`,
host: baseUrl,
};
}

45
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,45 @@
import { MetadataRoute } from "next";
import { getProducts } from "@/lib/woocommerce";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
const products = await getProducts();
const productUrls = products
.filter((p) => p.status === "publish")
.map((product) => ({
url: `${baseUrl}/products/${product.slug}`,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 0.8,
}));
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: "daily",
priority: 1,
},
{
url: `${baseUrl}/products`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.9,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.6,
},
{
url: `${baseUrl}/contact`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.6,
},
...productUrls,
];
}

View File

@@ -0,0 +1,125 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { useCartStore } from "@/stores/cartStore";
import { formatPrice } from "@/lib/woocommerce";
export default function CartDrawer() {
const { items, isOpen, closeCart, removeItem, updateQuantity, getTotal } = useCartStore();
const total = getTotal();
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
className="fixed inset-0 bg-black/50 z-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={closeCart}
/>
<motion.div
className="fixed top-0 right-0 bottom-0 w-full max-w-md bg-white z-50 shadow-xl flex flex-col"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "tween", duration: 0.3 }}
>
<div className="flex items-center justify-between p-6 border-b border-border/30">
<h2 className="text-xl font-serif">Your Cart</h2>
<button
onClick={closeCart}
className="p-2"
aria-label="Close cart"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
{items.length === 0 ? (
<div className="text-center py-12">
<p className="text-foreground-muted mb-6">Your cart is empty</p>
<Link
href="/en/products"
onClick={closeCart}
className="inline-block px-6 py-3 bg-foreground text-white"
>
Continue Shopping
</Link>
</div>
) : (
<div className="space-y-6">
{items.map((item) => (
<div key={item.id} className="flex gap-4">
<div className="w-20 h-20 bg-background-ice relative flex-shrink-0">
{item.image && (
<Image
src={item.image}
alt={item.name}
fill
className="object-cover"
/>
)}
</div>
<div className="flex-1">
<h3 className="font-serif text-sm">{item.name}</h3>
<p className="text-foreground-muted text-sm mt-1">
{formatPrice(item.price)}
</p>
<div className="flex items-center gap-3 mt-2">
<button
onClick={() => updateQuantity(item.id, item.quantity - 1)}
className="w-8 h-8 border border-border flex items-center justify-center"
>
-
</button>
<span>{item.quantity}</span>
<button
onClick={() => updateQuantity(item.id, item.quantity + 1)}
className="w-8 h-8 border border-border flex items-center justify-center"
>
+
</button>
<button
onClick={() => removeItem(item.id)}
className="ml-auto text-foreground-muted hover:text-red-500"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{items.length > 0 && (
<div className="p-6 border-t border-border/30">
<div className="flex items-center justify-between mb-4">
<span className="font-serif">Subtotal</span>
<span className="font-serif text-lg">{formatPrice(total.toString())}</span>
</div>
<a
href="https://manoonoils.com/checkout"
className="block w-full py-3 bg-foreground text-white text-center font-medium hover:bg-accent-dark transition-colors"
>
Checkout
</a>
</div>
)}
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { motion } from "framer-motion";
export default function BrandStory() {
return (
<section className="py-20 px-4 bg-background-ice">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
<motion.div
initial={{ opacity: 0, x: -30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl md:text-5xl font-serif mb-6">
Our Story
</h2>
<div className="space-y-4 text-foreground-muted leading-relaxed">
<p>
ManoonOils was born from a passion for natural beauty and the belief
that the best skincare comes from nature itself.
</p>
<p>
Our premium oils are carefully crafted using only the finest natural
ingredients, handpicked for their nourishing and restorative properties.
</p>
<p>
Each product is made with love and attention to detail, ensuring
that you receive the very best for your hair and skin.
</p>
</div>
</motion.div>
<motion.div
className="relative aspect-square bg-gradient-to-br from-accent/20 to-accent-dark/20"
initial={{ opacity: 0, x: 30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.2 }}
>
<div className="absolute inset-8 border border-foreground/10" />
<div className="absolute inset-12 bg-white/50 backdrop-blur-sm flex items-center justify-center">
<span className="font-serif text-3xl text-foreground/60">ManoonOils</span>
</div>
</motion.div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
export default function Hero() {
return (
<section className="relative h-screen min-h-[600px] flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-background-ice/50 to-background" />
<div className="absolute inset-0 opacity-5">
<div className="absolute top-20 left-10 w-64 h-64 rounded-full bg-accent blur-3xl" />
<div className="absolute bottom-20 right-10 w-96 h-96 rounded-full bg-accent-dark blur-3xl" />
</div>
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
>
<span className="inline-block text-sm tracking-[0.3em] text-foreground-muted mb-6">
PREMIUM NATURAL OILS
</span>
</motion.div>
<motion.h1
className="text-5xl md:text-7xl lg:text-8xl font-serif mb-6 leading-tight"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
>
ManoonOils
</motion.h1>
<motion.p
className="text-xl md:text-2xl text-foreground-muted mb-10 font-light"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.6 }}
>
For hair and skin care
</motion.p>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.8 }}
>
<Link
href="/en/products"
className="inline-block px-10 py-4 bg-foreground text-white text-lg tracking-wide hover:bg-accent-dark transition-colors duration-300"
>
Shop Now
</Link>
</motion.div>
</div>
<motion.div
className="absolute bottom-10 left-1/2 -translate-x-1/2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.2, duration: 0.8 }}
>
<div className="w-6 h-10 border-2 border-foreground/30 rounded-full flex justify-center pt-2">
<motion.div
className="w-1 h-2 bg-foreground/50 rounded-full"
animate={{ y: [0, 12, 0] }}
transition={{ repeat: Infinity, duration: 1.5 }}
/>
</div>
</motion.div>
</section>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
export default function Newsletter() {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setSubmitted(true);
};
return (
<section className="py-20 px-4 bg-foreground text-white">
<div className="max-w-3xl mx-auto text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<h2 className="text-3xl md font-serif mb-:text-4xl4">
Stay Updated
</h2>
<p className="text-white/60 mb-8">
Subscribe to receive news about new products and special offers
</p>
</motion.div>
{submitted ? (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-accent"
>
Thank you for subscribing!
</motion.p>
) : (
<motion.form
onSubmit={handleSubmit}
className="flex flex-col sm:flex-row gap-4 max-w-lg mx-auto"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
className="flex-1 px-6 py-3 bg-white/10 border border-white/20 text-white placeholder:text-white/40 focus:outline-none focus:border-accent"
/>
<button
type="submit"
className="px-8 py-3 bg-accent hover:bg-accent-dark transition-colors"
>
Subscribe
</button>
</motion.form>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { motion } from "framer-motion";
import { WooProduct } from "@/lib/woocommerce";
import ProductCard from "@/components/product/ProductCard";
interface ProductShowcaseProps {
products: WooProduct[];
}
export default function ProductShowcase({ products }: ProductShowcaseProps) {
if (!products || products.length === 0) return null;
return (
<section className="py-20 px-4">
<div className="max-w-7xl mx-auto">
<motion.div
className="text-center mb-16"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<h2 className="text-4xl md:text-5xl font-serif mb-4">Our Products</h2>
<p className="text-foreground-muted max-w-2xl mx-auto">
Discover our premium collection of natural oils for hair and skin care
</p>
</motion.div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
{products.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { motion } from "framer-motion";
const testimonials = [
{
quote: "My hair has never looked better. The Morning Glow oil is a miracle in a bottle!",
author: "Ana M.",
},
{
quote: "Finally found products that truly work. My skin feels amazing every day.",
author: "Jelena K.",
},
{
quote: "The Anti-age Serum is my go-to. Love the natural ingredients.",
author: "Milica P.",
},
];
export default function Testimonials() {
return (
<section className="py-20 px-4">
<div className="max-w-7xl mx-auto">
<motion.div
className="text-center mb-16"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<h2 className="text-4xl md:text-5xl font-serif mb-4">
What Our Customers Say
</h2>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{testimonials.map((testimonial, index) => (
<motion.div
key={index}
className="bg-white p-8 shadow-sm"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: index * 0.1 }}
>
<svg
className="w-8 h-8 text-accent mb-4"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
</svg>
<p className="font-serif text-lg italic mb-4 text-foreground-muted">
{testimonial.quote}
</p>
<p className="font-medium"> {testimonial.author}</p>
</motion.div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { motion } from "framer-motion";
export default function TickerBar() {
const items = [
"Free shipping on orders over 3000 RSD",
"Natural ingredients",
"Cruelty-free",
"Handmade with love",
];
return (
<div className="bg-foreground text-white py-3 overflow-hidden">
<motion.div
className="flex whitespace-nowrap"
animate={{ x: ["0%", "-50%"] }}
transition={{
x: {
repeat: Infinity,
repeatType: "loop",
duration: 20,
ease: "linear",
},
}}
>
{[...items, ...items, ...items, ...items].map((item, index) => (
<span key={index} className="mx-8 text-sm tracking-wide">
{item}
</span>
))}
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import Link from "next/link";
export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="bg-background-ice border-t border-border/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="md:col-span-2">
<h3 className="text-2xl font-serif mb-4">ManoonOils</h3>
<p className="text-foreground-muted max-w-md">
Premium natural oils for hair and skin care. Crafted with love for your daily beauty routine.
</p>
</div>
<div>
<h4 className="font-serif mb-4">Quick Links</h4>
<ul className="space-y-2">
<li>
<Link href="/en/products" className="text-foreground-muted hover:text-foreground transition-colors">
Products
</Link>
</li>
<li>
<Link href="/en/about" className="text-foreground-muted hover:text-foreground transition-colors">
About Us
</Link>
</li>
<li>
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
Contact
</Link>
</li>
</ul>
</div>
<div>
<h4 className="font-serif mb-4">Customer Service</h4>
<ul className="space-y-2">
<li>
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
Shipping Info
</Link>
</li>
<li>
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
Returns
</Link>
</li>
<li>
<a href="https://manoonoils.com" className="text-foreground-muted hover:text-foreground transition-colors">
WooCommerce Store
</a>
</li>
</ul>
</div>
</div>
<div className="border-t border-border/30 mt-12 pt-8 text-center text-foreground-muted text-sm">
<p>&copy; {currentYear} ManoonOils. All rights reserved.</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import { useCartStore } from "@/stores/cartStore";
import { formatPrice } from "@/lib/woocommerce";
import MobileMenu from "./MobileMenu";
import CartDrawer from "@/components/cart/CartDrawer";
export default function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { items, toggleCart } = useCartStore();
const itemCount = items.reduce((count, item) => count + item.quantity, 0);
return (
<>
<header className="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16 md:h-20">
<button
className="md:hidden p-2"
onClick={() => setMobileMenuOpen(true)}
aria-label="Open menu"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<Link href="/" className="flex-shrink-0">
<img
src="/manoon-logo.jpg"
alt="ManoonOils"
className="h-8 w-[124px] md:h-10 md:w-[154px]"
/>
</Link>
<nav className="hidden md:flex items-center space-x-8">
<Link href="/en/products" className="text-foreground hover:text-accent-dark transition-colors">
Products
</Link>
<Link href="/en/about" className="text-foreground hover:text-accent-dark transition-colors">
About
</Link>
<Link href="/en/contact" className="text-foreground hover:text-accent-dark transition-colors">
Contact
</Link>
</nav>
<button
className="p-2 relative"
onClick={toggleCart}
aria-label="Open cart"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
{itemCount > 0 && (
<span className="absolute -top-1 -right-1 bg-accent-dark text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">
{itemCount}
</span>
)}
</button>
</div>
</div>
</header>
<AnimatePresence>
{mobileMenuOpen && (
<MobileMenu onClose={() => setMobileMenuOpen(false)} />
)}
</AnimatePresence>
<CartDrawer />
</>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
interface MobileMenuProps {
onClose: () => void;
}
export default function MobileMenu({ onClose }: MobileMenuProps) {
const menuItems = [
{ href: "/en", label: "Home" },
{ href: "/en/products", label: "Products" },
{ href: "/en/about", label: "About" },
{ href: "/en/contact", label: "Contact" },
];
return (
<>
<motion.div
className="fixed inset-0 bg-black/50 z-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
className="fixed top-0 left-0 bottom-0 w-80 bg-white z-50 shadow-xl"
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "tween", duration: 0.3 }}
>
<div className="p-6">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-serif">ManoonOils</h2>
<button
onClick={onClose}
className="p-2"
aria-label="Close menu"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="space-y-4">
{menuItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="block text-xl font-serif py-2 border-b border-border/30"
onClick={onClose}
>
{item.label}
</Link>
))}
</nav>
</div>
</motion.div>
</>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce";
interface ProductCardProps {
product: WooProduct;
index?: number;
}
export default function ProductCard({ product, index = 0 }: ProductCardProps) {
const image = getProductImage(product);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Link href={`/products/${product.slug}`} className="group block">
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden mb-4">
{image && (
<Image
src={image}
alt={product.name}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
)}
{product.stock_status === "outofstock" && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<span className="text-white font-medium">Out of Stock</span>
</div>
)}
</div>
<h3 className="font-serif text-lg mb-1 group-hover:text-accent-dark transition-colors">
{product.name}
</h3>
<p className="text-foreground-muted">
{product.price ? formatPrice(product.price) : "Contact for price"}
</p>
</Link>
</motion.div>
);
}

View File

@@ -0,0 +1,182 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { motion } from "framer-motion";
import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce";
import { useCartStore } from "@/stores/cartStore";
import ProductCard from "@/components/product/ProductCard";
interface ProductDetailProps {
product: WooProduct;
relatedProducts: WooProduct[];
}
export default function ProductDetail({ product, relatedProducts }: ProductDetailProps) {
const [selectedImage, setSelectedImage] = useState(0);
const [quantity, setQuantity] = useState(1);
const [activeTab, setActiveTab] = useState<"details" | "ingredients" | "usage">("details");
const addItem = useCartStore((state) => state.addItem);
const images = product.images?.length > 0
? product.images
: [{ id: 0, src: "/placeholder-product.jpg", alt: product.name }];
const handleAddToCart = () => {
addItem({
id: product.id,
name: product.name,
price: product.price || product.regular_price,
quantity,
image: images[0]?.src || "",
sku: product.sku || "",
});
};
const stripHtml = (html: string) => {
return html.replace(/<[^>]*>/g, "");
};
return (
<>
<section className="py-12 md:py-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6 }}
>
<div className="relative aspect-square bg-background-ice mb-4">
{images[selectedImage] && (
<Image
src={images[selectedImage].src}
alt={images[selectedImage].alt || product.name}
fill
className="object-cover"
priority
/>
)}
</div>
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto">
{images.map((image, index) => (
<button
key={image.id}
onClick={() => setSelectedImage(index)}
className={`relative w-20 h-20 flex-shrink-0 ${
selectedImage === index ? "ring-2 ring-foreground" : ""
}`}
>
<Image
src={image.src}
alt={image.alt || product.name}
fill
className="object-cover"
/>
</button>
))}
</div>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<h1 className="text-3xl md:text-4xl font-serif mb-4">
{product.name}
</h1>
<p className="text-2xl text-foreground-muted mb-6">
{product.price ? formatPrice(product.price) : "Contact for price"}
</p>
<div className="prose prose-sm max-w-none mb-8 text-foreground-muted">
<p>{stripHtml(product.short_description || product.description.slice(0, 200))}</p>
</div>
{product.stock_status === "instock" ? (
<div className="flex items-center gap-4 mb-8">
<div className="flex items-center border border-border">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="px-4 py-3"
>
-
</button>
<span className="px-4 py-3">{quantity}</span>
<button
onClick={() => setQuantity(quantity + 1)}
className="px-4 py-3"
>
+
</button>
</div>
<button
onClick={handleAddToCart}
className="flex-1 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
>
Add to Cart
</button>
</div>
) : (
<div className="py-3 bg-red-50 text-red-600 text-center mb-8">
Out of Stock
</div>
)}
<div className="border-t border-border/30">
<div className="flex border-b border-border/30">
{(["details", "ingredients", "usage"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 py-4 font-medium capitalize ${
activeTab === tab
? "border-b-2 border-foreground"
: "text-foreground-muted"
}`}
>
{tab}
</button>
))}
</div>
<div className="py-6 text-foreground-muted">
{activeTab === "details" && (
<p>{stripHtml(product.description)}</p>
)}
{activeTab === "ingredients" && (
<p>Natural ingredients - Contact for detailed information.</p>
)}
{activeTab === "usage" && (
<p>Apply to clean skin or hair. Use daily for best results.</p>
)}
</div>
</div>
</motion.div>
</div>
</div>
</section>
{relatedProducts.length > 0 && (
<section className="py-12 px-4 bg-background-ice">
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-serif text-center mb-8">
You May Also Like
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
{relatedProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
))}
</div>
</div>
</section>
)}
</>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
export default async function LocaleProvider({
children,
locale,
}: {
children: React.ReactNode;
locale: string;
}) {
const locales = ["en", "sr"];
if (!locales.includes(locale)) notFound();
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
);
}

15
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import { getRequestConfig } from "next-intl/server";
import { notFound } from "next/navigation";
export const locales = ["en", "sr"] as const;
export type Locale = (typeof locales)[number];
export default getRequestConfig(async ({ locale }) => {
const currentLocale = locale || "sr";
if (!locales.includes(currentLocale as Locale)) notFound();
return {
locale: currentLocale,
messages: (await import(`./messages/${currentLocale}.json`)).default,
};
});

53
src/i18n/messages/en.json Normal file
View File

@@ -0,0 +1,53 @@
{
"Navigation": {
"home": "Home",
"products": "Products",
"about": "About",
"contact": "Contact"
},
"Home": {
"hero": {
"title": "Premium Natural Oils",
"subtitle": "For hair and skin care"
},
"ticker": {
"text": "Free shipping on orders over 3000 RSD • Natural ingredients • Cruelty-free • Handmade with love"
},
"products": {
"title": "Our Products"
},
"bestsellers": {
"title": "Best Sellers"
},
"story": {
"title": "Our Story",
"description": "ManoonOils was born from a passion for natural beauty..."
},
"newsletter": {
"title": "Stay Updated",
"placeholder": "Enter your email",
"button": "Subscribe"
}
},
"Product": {
"addToCart": "Add to Cart",
"outOfStock": "Out of Stock",
"details": "Details",
"ingredients": "Ingredients",
"usage": "How to Use",
"related": "You May Also Like"
},
"Cart": {
"title": "Your Cart",
"empty": "Your cart is empty",
"continueShopping": "Continue Shopping",
"checkout": "Checkout",
"subtotal": "Subtotal",
"remove": "Remove"
},
"Footer": {
"quickLinks": "Quick Links",
"customerService": "Customer Service",
"copyright": "All rights reserved."
}
}

53
src/i18n/messages/sr.json Normal file
View File

@@ -0,0 +1,53 @@
{
"Navigation": {
"home": "Početna",
"products": "Proizvodi",
"about": "O nama",
"contact": "Kontakt"
},
"Home": {
"hero": {
"title": "Premium prirodna ulja",
"subtitle": "Za negu kose i kože"
},
"ticker": {
"text": "Besplatna dostava za porudžbine preko 3000 RSD • Prirodni sastojci • Bez okrutnosti • Ručno sa ljubavlju"
},
"products": {
"title": "Naši proizvodi"
},
"bestsellers": {
"title": "Najprodavaniji"
},
"story": {
"title": "Naša priča",
"description": "ManoonOils je rođen iz strasti za prirodnu lepotu..."
},
"newsletter": {
"title": "Ostanite u toku",
"placeholder": "Unesite vaš email",
"button": "Pretplati se"
}
},
"Product": {
"addToCart": "Dodaj u korpu",
"outOfStock": "Nema na stanju",
"details": "Detalji",
"ingredients": "Sastojci",
"usage": "Način upotrebe",
"related": "Takođe će vam se svideti"
},
"Cart": {
"title": "Vaša korpa",
"empty": "Vaša korpa je prazna",
"continueShopping": "Nastavite kupovinu",
"checkout": "Kupovina",
"subtotal": "Ukupno",
"remove": "Ukloni"
},
"Footer": {
"quickLinks": "Brze veze",
"customerService": "Korisnička podrška",
"copyright": "Sva prava zadržana."
}
}

15
src/i18n/request.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`./messages/${locale}.json`)).default
};
});

6
src/i18n/routing.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'sr'],
defaultLocale: 'sr'
});

107
src/lib/woocommerce.ts Normal file
View File

@@ -0,0 +1,107 @@
import WooCommerceRestApi from "@woocommerce/woocommerce-rest-api";
const api = new WooCommerceRestApi({
url: process.env.NEXT_PUBLIC_WOOCOMMERCE_URL || "",
consumerKey: process.env.NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_KEY || "",
consumerSecret: process.env.NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_SECRET || "",
version: "wc/v3",
});
export interface WooProduct {
id: number;
name: string;
slug: string;
price: string;
regular_price: string;
sale_price: string;
description: string;
short_description: string;
status: "publish" | "draft" | "private";
stock_status: "instock" | "outofstock";
images: { id: number; src: string; alt: string }[];
sku: string;
categories: { id: number; name: string; slug: string }[];
meta_data: { key: string; value: string }[];
}
export interface WooCategory {
id: number;
name: string;
slug: string;
description: string;
image: { src: string } | null;
}
export async function getProducts(perPage = 100): Promise<WooProduct[]> {
try {
const response = await api.get("products", { per_page: perPage });
return response.data;
} catch (error) {
console.error("Error fetching products:", error);
return [];
}
}
export async function getProduct(id: number): Promise<WooProduct | null> {
try {
const response = await api.get(`products/${id}`);
return response.data;
} catch (error) {
console.error(`Error fetching product ${id}:`, error);
return null;
}
}
export async function getProductBySlug(slug: string): Promise<WooProduct | null> {
try {
const response = await api.get("products", { slug });
return response.data[0] || null;
} catch (error) {
console.error(`Error fetching product by slug ${slug}:`, error);
return null;
}
}
export async function getCategories(): Promise<WooCategory[]> {
try {
const response = await api.get("product-categories", { per_page: 100 });
return response.data;
} catch (error) {
console.error("Error fetching categories:", error);
return [];
}
}
export async function getProductsByCategory(
categoryId: number
): Promise<WooProduct[]> {
try {
const response = await api.get("products", {
category: categoryId,
per_page: 100,
});
return response.data;
} catch (error) {
console.error(`Error fetching products for category ${categoryId}:`, error);
return [];
}
}
export function formatPrice(price: string, currency = "RSD"): string {
const num = parseFloat(price);
if (isNaN(num)) return "0 RSD";
return new Intl.NumberFormat("sr-RS", {
style: "currency",
currency: currency,
minimumFractionDigits: 0,
}).format(num);
}
export function getProductImage(product: WooProduct): string {
if (product.images && product.images.length > 0) {
return product.images[0].src;
}
return "/placeholder-product.jpg";
}
export default api;

13
src/middleware.ts Normal file
View File

@@ -0,0 +1,13 @@
import createMiddleware from "next-intl/middleware";
import { defineRouting } from 'next-intl/routing';
const routing = defineRouting({
locales: ['en', 'sr'],
defaultLocale: 'sr'
});
export default createMiddleware(routing);
export const config = {
matcher: ['/((?!api|_next|_vercel|).*)']
};

86
src/stores/cartStore.ts Normal file
View File

@@ -0,0 +1,86 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface CartItem {
id: number;
name: string;
price: string;
quantity: number;
image: string;
sku: string;
}
interface CartStore {
items: CartItem[];
isOpen: boolean;
addItem: (item: CartItem) => void;
removeItem: (id: number) => void;
updateQuantity: (id: number, quantity: number) => void;
toggleCart: () => void;
openCart: () => void;
closeCart: () => void;
clearCart: () => void;
getTotal: () => number;
getItemCount: () => number;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
isOpen: false,
addItem: (item) => {
const items = get().items;
const existingItem = items.find((i) => i.id === item.id);
if (existingItem) {
set({
items: items.map((i) =>
i.id === item.id
? { ...i, quantity: i.quantity + item.quantity }
: i
),
});
} else {
set({ items: [...items, item] });
}
set({ isOpen: true });
},
removeItem: (id) => {
set({ items: get().items.filter((i) => i.id !== id) });
},
updateQuantity: (id, quantity) => {
if (quantity <= 0) {
set({ items: get().items.filter((i) => i.id !== id) });
} else {
set({
items: get().items.map((i) =>
i.id === id ? { ...i, quantity } : i
),
});
}
},
toggleCart: () => set({ isOpen: !get().isOpen }),
openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }),
clearCart: () => set({ items: [] }),
getTotal: () => {
return get().items.reduce((total, item) => {
return total + parseFloat(item.price) * item.quantity;
}, 0);
},
getItemCount: () => {
return get().items.reduce((count, item) => count + item.quantity, 0);
},
}),
{
name: "manoonoils-cart",
}
)
);

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}