Initial commit: ManoonOils headless store
This commit is contained in:
27
Dockerfile
Normal file
27
Dockerfile
Normal 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"]
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
@@ -17,4 +21,4 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
67
src/app/[locale]/page.tsx
Normal file
67
src/app/[locale]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/app/[locale]/products/[slug]/page.tsx
Normal file
77
src/app/[locale]/products/[slug]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,16 +2,25 @@ import "./globals.css";
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
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.",
|
description: "Discover our premium collection of natural oils for hair and skin care. Handmade with love.",
|
||||||
robots: "index, follow",
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { getProducts } from "@/lib/woocommerce";
|
|
||||||
import Header from "@/components/layout/Header";
|
|
||||||
import Footer from "@/components/layout/Footer";
|
|
||||||
import Hero from "@/components/home/Hero";
|
|
||||||
import TickerBar from "@/components/home/TickerBar";
|
|
||||||
import ProductShowcase from "@/components/home/ProductShowcase";
|
|
||||||
import BrandStory from "@/components/home/BrandStory";
|
|
||||||
import Testimonials from "@/components/home/Testimonials";
|
|
||||||
import Newsletter from "@/components/home/Newsletter";
|
|
||||||
|
|
||||||
export default async function Home() {
|
|
||||||
const products = await getProducts();
|
|
||||||
|
|
||||||
const publishedProducts = products.filter(
|
|
||||||
(p) => p.status === "publish" && p.stock_status === "instock"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen pt-16 md:pt-20">
|
|
||||||
<Header />
|
|
||||||
<TickerBar />
|
|
||||||
<Hero />
|
|
||||||
<ProductShowcase products={publishedProducts.slice(0, 4)} />
|
|
||||||
<BrandStory />
|
|
||||||
<Testimonials />
|
|
||||||
<Newsletter />
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { getProductBySlug, getProducts, formatPrice, getProductImage } from "@/lib/woocommerce";
|
|
||||||
import Header from "@/components/layout/Header";
|
|
||||||
import Footer from "@/components/layout/Footer";
|
|
||||||
import ProductDetail from "@/components/product/ProductDetail";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
params: Promise<{ slug: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props) {
|
|
||||||
const { slug } = await params;
|
|
||||||
const product = await getProductBySlug(slug);
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
return { title: "Product Not Found" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `${product.name} - ManoonOils`,
|
|
||||||
description: product.short_description || product.description.slice(0, 160),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ProductPage({ params }: Props) {
|
|
||||||
const { slug } = await params;
|
|
||||||
const product = await getProductBySlug(slug);
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const allProducts = await getProducts();
|
|
||||||
const relatedProducts = allProducts
|
|
||||||
.filter((p) => p.id !== product.id && p.status === "publish")
|
|
||||||
.slice(0, 4);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen pt-16 md:pt-20">
|
|
||||||
<Header />
|
|
||||||
<ProductDetail product={product} relatedProducts={relatedProducts} />
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,5 +11,6 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
sitemap: `${baseUrl}/sitemap.xml`,
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
|
host: baseUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default function CartDrawer() {
|
|||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-foreground-muted mb-6">Your cart is empty</p>
|
<p className="text-foreground-muted mb-6">Your cart is empty</p>
|
||||||
<Link
|
<Link
|
||||||
href="/products"
|
href="/en/products"
|
||||||
onClick={closeCart}
|
onClick={closeCart}
|
||||||
className="inline-block px-6 py-3 bg-foreground text-white"
|
className="inline-block px-6 py-3 bg-foreground text-white"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function Hero() {
|
|||||||
transition={{ duration: 0.8, delay: 0.8 }}
|
transition={{ duration: 0.8, delay: 0.8 }}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href="/products"
|
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"
|
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
|
Shop Now
|
||||||
|
|||||||
@@ -18,17 +18,17 @@ export default function Footer() {
|
|||||||
<h4 className="font-serif mb-4">Quick Links</h4>
|
<h4 className="font-serif mb-4">Quick Links</h4>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
<li>
|
<li>
|
||||||
<Link href="/products" className="text-foreground-muted hover:text-foreground transition-colors">
|
<Link href="/en/products" className="text-foreground-muted hover:text-foreground transition-colors">
|
||||||
Products
|
Products
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/about" className="text-foreground-muted hover:text-foreground transition-colors">
|
<Link href="/en/about" className="text-foreground-muted hover:text-foreground transition-colors">
|
||||||
About Us
|
About Us
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/contact" className="text-foreground-muted hover:text-foreground transition-colors">
|
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
|
||||||
Contact
|
Contact
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -39,12 +39,12 @@ export default function Footer() {
|
|||||||
<h4 className="font-serif mb-4">Customer Service</h4>
|
<h4 className="font-serif mb-4">Customer Service</h4>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
<li>
|
<li>
|
||||||
<Link href="/contact" className="text-foreground-muted hover:text-foreground transition-colors">
|
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
|
||||||
Shipping Info
|
Shipping Info
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/contact" className="text-foreground-muted hover:text-foreground transition-colors">
|
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
|
||||||
Returns
|
Returns
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ export default function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center space-x-8">
|
<nav className="hidden md:flex items-center space-x-8">
|
||||||
<Link href="/products" className="text-foreground hover:text-accent-dark transition-colors">
|
<Link href="/en/products" className="text-foreground hover:text-accent-dark transition-colors">
|
||||||
Products
|
Products
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/about" className="text-foreground hover:text-accent-dark transition-colors">
|
<Link href="/en/about" className="text-foreground hover:text-accent-dark transition-colors">
|
||||||
About
|
About
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/contact" className="text-foreground hover:text-accent-dark transition-colors">
|
<Link href="/en/contact" className="text-foreground hover:text-accent-dark transition-colors">
|
||||||
Contact
|
Contact
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ interface MobileMenuProps {
|
|||||||
|
|
||||||
export default function MobileMenu({ onClose }: MobileMenuProps) {
|
export default function MobileMenu({ onClose }: MobileMenuProps) {
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ href: "/", label: "Home" },
|
{ href: "/en", label: "Home" },
|
||||||
{ href: "/products", label: "Products" },
|
{ href: "/en/products", label: "Products" },
|
||||||
{ href: "/about", label: "About" },
|
{ href: "/en/about", label: "About" },
|
||||||
{ href: "/contact", label: "Contact" },
|
{ href: "/en/contact", label: "Contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
15
src/i18n/request.tsx
Normal file
15
src/i18n/request.tsx
Normal 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
6
src/i18n/routing.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineRouting } from 'next-intl/routing';
|
||||||
|
|
||||||
|
export const routing = defineRouting({
|
||||||
|
locales: ['en', 'sr'],
|
||||||
|
defaultLocale: 'en'
|
||||||
|
});
|
||||||
@@ -4,7 +4,6 @@ import { locales } from "./i18n";
|
|||||||
export default createMiddleware({
|
export default createMiddleware({
|
||||||
locales,
|
locales,
|
||||||
defaultLocale: "en",
|
defaultLocale: "en",
|
||||||
localePrefix: "always",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
Reference in New Issue
Block a user