feat: add product catalog with URL-based category filtering
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m48s
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m48s
This commit is contained in:
Generated
+27
-11
@@ -4,21 +4,19 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="refactor: migrate project to TypeScript and translate comments to English">
|
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="feat: add cart and product pages, quantity controls, and first-visit development notice">
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/app/layout.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/layout.tsx" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/app/products/[id]/page.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/products/[id]/page.tsx" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/app/page.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/page.tsx" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/components/CartPage.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/CartPage.tsx" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/app/providers.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/providers.tsx" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/components/AboutSection.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/AboutSection.tsx" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/components/CartDrawer.tsx" beforeDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/components/Footer.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Footer.tsx" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/components/Footer.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Footer.tsx" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/components/Header.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Header.tsx" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/components/Header.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Header.tsx" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/components/HorizontalShowcase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/HorizontalShowcase.tsx" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/components/HorizontalShowcase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/HorizontalShowcase.tsx" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/components/MobileShowcase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/MobileShowcase.tsx" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/components/MobileShowcase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/MobileShowcase.tsx" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/components/VendingCard.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/VendingCard.tsx" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/components/ProductDetails.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/ProductDetails.tsx" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/components/ShowcaseSection.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/ShowcaseSection.tsx" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/data/commerce.ts" beforeDir="false" afterPath="$PROJECT_DIR$/data/commerce.ts" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/data/content.ts" beforeDir="false" afterPath="$PROJECT_DIR$/data/content.ts" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/data/content.ts" beforeDir="false" afterPath="$PROJECT_DIR$/data/content.ts" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/store/cartSlice.ts" beforeDir="false" afterPath="$PROJECT_DIR$/store/cartSlice.ts" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/types/domain.ts" beforeDir="false" afterPath="$PROJECT_DIR$/types/domain.ts" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/types/domain.ts" beforeDir="false" afterPath="$PROJECT_DIR$/types/domain.ts" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
@@ -219,7 +217,8 @@
|
|||||||
<workItem from="1789362137185" duration="377000" />
|
<workItem from="1789362137185" duration="377000" />
|
||||||
<workItem from="1789362644534" duration="6512000" />
|
<workItem from="1789362644534" duration="6512000" />
|
||||||
<workItem from="1789383269194" duration="2337000" />
|
<workItem from="1789383269194" duration="2337000" />
|
||||||
<workItem from="1789445417779" duration="2914000" />
|
<workItem from="1789445417779" duration="6709000" />
|
||||||
|
<workItem from="1789456504019" duration="1703000" />
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00001" summary="update">
|
<task id="LOCAL-00001" summary="update">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
@@ -501,7 +500,23 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1789385239281</updated>
|
<updated>1789385239281</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="36" />
|
<task id="LOCAL-00036" summary="feat: add cart and product pages, quantity controls, and first-visit development notice">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1789449280006</created>
|
||||||
|
<option name="number" value="00036" />
|
||||||
|
<option name="presentableId" value="LOCAL-00036" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1789449280006</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00037" summary="feat: add cart and product pages, quantity controls, and first-visit development notice">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1789451442320</created>
|
||||||
|
<option name="number" value="00037" />
|
||||||
|
<option name="presentableId" value="LOCAL-00037" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1789451442320</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="38" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -524,6 +539,7 @@
|
|||||||
<MESSAGE value="feat: add landing page device images and fix machine branding" />
|
<MESSAGE value="feat: add landing page device images and fix machine branding" />
|
||||||
<MESSAGE value="refactor: remove disabled home sections and their unused assets" />
|
<MESSAGE value="refactor: remove disabled home sections and their unused assets" />
|
||||||
<MESSAGE value="refactor: migrate project to TypeScript and translate comments to English" />
|
<MESSAGE value="refactor: migrate project to TypeScript and translate comments to English" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="refactor: migrate project to TypeScript and translate comments to English" />
|
<MESSAGE value="feat: add cart and product pages, quantity controls, and first-visit development notice" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="feat: add cart and product pages, quantity controls, and first-visit development notice" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -34,6 +34,8 @@ npm start
|
|||||||
- `app/page.tsx`: صفحه اصلی شامل هیرو، ویترین دستگاهها، مزایا، درباره ما و محلهای مناسب نصب.
|
- `app/page.tsx`: صفحه اصلی شامل هیرو، ویترین دستگاهها، مزایا، درباره ما و محلهای مناسب نصب.
|
||||||
- `app/layout.tsx`: هدر، فوتر و پیام توسعه مشترک بین صفحات.
|
- `app/layout.tsx`: هدر، فوتر و پیام توسعه مشترک بین صفحات.
|
||||||
- `app/cart/page.tsx`: صفحه مستقل سبد خرید.
|
- `app/cart/page.tsx`: صفحه مستقل سبد خرید.
|
||||||
|
- `app/products/page.tsx`: همه محصولات با فیلتر دستهبندی ذخیرهشده در URL؛ برای نمونه `/products?category=warm-vending`.
|
||||||
|
- `data/products.ts`: دستهبندیها و شناسه سه محصول منتخب ویترین اسکرولی Home؛ سایر محصولات فقط در فهرست کامل نمایش داده میشوند.
|
||||||
- `app/products/[id]/page.tsx`: صفحه جزئیات هر دستگاه، تصاویر، ویژگیها و کنترل تعداد.
|
- `app/products/[id]/page.tsx`: صفحه جزئیات هر دستگاه، تصاویر، ویژگیها و کنترل تعداد.
|
||||||
- `components/HeroSection.tsx`: اسلایدر تصاویر دستگاهها.
|
- `components/HeroSection.tsx`: اسلایدر تصاویر دستگاهها.
|
||||||
- `components/ShowcaseSection.tsx`: انتخاب نمایش موبایل یا ویترین افقی دسکتاپ.
|
- `components/ShowcaseSection.tsx`: انتخاب نمایش موبایل یا ویترین افقی دسکتاپ.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {notFound} from 'next/navigation';
|
|||||||
import {content} from '@/data/content';
|
import {content} from '@/data/content';
|
||||||
import ProductDetails from '@/components/ProductDetails';
|
import ProductDetails from '@/components/ProductDetails';
|
||||||
|
|
||||||
type Props = {params: {id: string}};
|
type Props = { params: { id: string } };
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return content.fa.machines.map(machine => ({id: machine.id}));
|
return content.fa.machines.map(machine => ({id: machine.id}));
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import {Suspense} from 'react';
|
||||||
|
import type {Metadata} from 'next';
|
||||||
|
import ProductsPage from '@/components/ProductsPage';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {title: 'همه محصولات | روبات مارکت'};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="min-h-screen pt-28 px-6" aria-busy="true"><div className="h-60 rounded-3xl bg-surface animate-pulse"/></div>}>
|
||||||
|
<ProductsPage/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,13 +19,13 @@ export default function CartPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-7xl min-h-[65vh] px-5 md:px-8 pt-28 pb-20">
|
<main className="mx-auto max-w-7xl min-h-[65vh] px-5 md:px-8 pt-28 pb-20">
|
||||||
<Link href="/#showcase" className="text-brand-400 hover:underline">{copy.continueShopping}</Link>
|
<Link href="/products" className="text-brand-400 hover:underline">{copy.continueShopping}</Link>
|
||||||
<h1 className="text-3xl md:text-4xl font-extrabold text-primary mt-6">{t.cart.title}</h1>
|
<h1 className="text-3xl md:text-4xl font-extrabold text-primary mt-6">{t.cart.title}</h1>
|
||||||
<p className="text-secondary mt-3 mb-10">{copy.cartDescription}</p>
|
<p className="text-secondary mt-3 mb-10">{copy.cartDescription}</p>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="rounded-3xl border border-subtle bg-surface p-10 text-center">
|
<div className="rounded-3xl border border-subtle bg-surface p-10 text-center">
|
||||||
<Empty description={t.cart.empty}/>
|
<Empty description={t.cart.empty}/>
|
||||||
<Link href="/#showcase" className="inline-block mt-6 text-brand-400 hover:underline">{copy.continueShopping}</Link>
|
<Link href="/products" className="inline-block mt-6 text-brand-400 hover:underline">{copy.continueShopping}</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid lg:grid-cols-[minmax(0,1fr),340px] gap-8 items-start">
|
<div className="grid lg:grid-cols-[minmax(0,1fr),340px] gap-8 items-start">
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
// Company information, quick links, social links, and contact details.
|
// Company information, quick links, social links, and contact details.
|
||||||
|
|
||||||
|
import {commerceCopy} from '@/data/commerce';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
import { PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
import { useLanguage } from '@/app/context/LanguageContext';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const { t } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const c = t.contactInfo;
|
const c = t.contactInfo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,7 +36,7 @@ export default function Footer() {
|
|||||||
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.quickLinks}</h3>
|
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.quickLinks}</h3>
|
||||||
<ul className="flex flex-col gap-2 text-sm text-secondary">
|
<ul className="flex flex-col gap-2 text-sm text-secondary">
|
||||||
<li><Link href="/#hero" className="hover:text-brand-400">{t.nav.home}</Link></li>
|
<li><Link href="/#hero" className="hover:text-brand-400">{t.nav.home}</Link></li>
|
||||||
<li><Link href="/#showcase" className="hover:text-brand-400">{t.nav.showcase}</Link></li>
|
<li><Link href="/products" className="hover:text-brand-400">{commerceCopy[locale].allProducts}</Link></li>
|
||||||
<li><Link href="/#about" className="hover:text-brand-400">{t.nav.about}</Link></li>
|
<li><Link href="/#about" className="hover:text-brand-400">{t.nav.about}</Link></li>
|
||||||
<li><Link href="/#locations" className="hover:text-brand-400">{t.nav.locations}</Link></li>
|
<li><Link href="/#locations" className="hover:text-brand-400">{t.nav.locations}</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -11,19 +11,20 @@ import {ShoppingCartOutlined, MenuOutlined, CloseOutlined, UserOutlined} from '@
|
|||||||
import {useAppSelector} from '@/store/hooks';
|
import {useAppSelector} from '@/store/hooks';
|
||||||
import {selectCartCount} from '@/store/cartSlice';
|
import {selectCartCount} from '@/store/cartSlice';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {commerceCopy} from '@/data/commerce';
|
||||||
import ThemeToggle from './ThemeToggle';
|
import ThemeToggle from './ThemeToggle';
|
||||||
import LanguageToggle from './LanguageToggle';
|
import LanguageToggle from './LanguageToggle';
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const cartCount = useAppSelector(selectCartCount);
|
const cartCount = useAppSelector(selectCartCount);
|
||||||
const {t} = useLanguage();
|
const {t, locale} = useLanguage();
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{href: '/#hero', label: t.nav.home},
|
{href: '/#hero', label: t.nav.home},
|
||||||
{href: '/#showcase', label: t.nav.showcase},
|
{href: '/products', label: commerceCopy[locale].allProducts},
|
||||||
{href: '/#about', label: t.nav.about},
|
{href: '/#about', label: t.nav.about},
|
||||||
{href: '/#locations', label: t.nav.locations},
|
{href: '/#locations', label: t.nav.locations},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import {gsap} from 'gsap';
|
import {gsap} from 'gsap';
|
||||||
import {ScrollTrigger} from 'gsap/ScrollTrigger';
|
import {ScrollTrigger} from 'gsap/ScrollTrigger';
|
||||||
import VendingCard from './VendingCard';
|
import VendingCard from './VendingCard';
|
||||||
import {showcaseVideoSrc} from '@/data/content';
|
import {showcaseVideoSrc} from '@/data/content';
|
||||||
|
import {featuredMachines} from '@/data/products';
|
||||||
|
import {commerceCopy} from '@/data/commerce';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
@@ -13,7 +16,8 @@ export default function HorizontalShowcase() {
|
|||||||
const sectionRef = useRef<HTMLElement>(null);
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const {t} = useLanguage();
|
const {t, locale} = useLanguage();
|
||||||
|
const copy = commerceCopy[locale];
|
||||||
const [videoAvailable, setVideoAvailable] = useState(true);
|
const [videoAvailable, setVideoAvailable] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,8 +94,17 @@ export default function HorizontalShowcase() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={trackRef} className="horizontal-track relative z-10" dir="ltr">
|
<div className="relative z-20 h-40 pt-20 bg-page-85 backdrop-blur-md">
|
||||||
{t.machines.map((machine) => (
|
<div className="max-w-7xl h-full mx-auto px-8 flex justify-between items-center gap-4">
|
||||||
|
<h2 className="text-2xl font-bold text-primary">{copy.featuredProducts}</h2>
|
||||||
|
<Link href="/products" className="text-brand-400 hover:underline">{copy.allProducts}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={trackRef}
|
||||||
|
className="horizontal-track relative z-10 [&_.horizontal-panel]:h-full [&_.spotlight-stage]:max-h-[calc(100vh-12rem)]"
|
||||||
|
style={{height: 'calc(100vh - 10rem)'}} dir="ltr">
|
||||||
|
{featuredMachines(t.machines).map((machine) => (
|
||||||
<VendingCard key={machine.id} machine={machine}/>
|
<VendingCard key={machine.id} machine={machine}/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import {featuredMachines} from '@/data/products';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
import {commerceCopy} from '@/data/commerce';
|
import {commerceCopy} from '@/data/commerce';
|
||||||
import QuantityControl from './QuantityControl';
|
import QuantityControl from './QuantityControl';
|
||||||
@@ -10,9 +11,9 @@ export default function MobileShowcase() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="showcase" className="relative w-full py-8 px-4" dir={dir}>
|
<section id="showcase" className="relative w-full py-8 px-4" dir={dir}>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex gap-4 overflow-x-auto snap-x snap-mandatory pb-4">
|
||||||
{t.machines.map(machine => (
|
{featuredMachines(t.machines).map(machine => (
|
||||||
<article key={machine.id} className="bg-surface border border-subtle rounded-2xl p-4">
|
<article key={machine.id} className="w-[90%] shrink-0 snap-start bg-surface border border-subtle rounded-2xl p-4">
|
||||||
<Link href={`/products/${machine.id}`} className="flex gap-4 items-center">
|
<Link href={`/products/${machine.id}`} className="flex gap-4 items-center">
|
||||||
<img src={machine.image} alt={machine.name} className="w-24 h-28 object-contain shrink-0"/>
|
<img src={machine.image} alt={machine.name} className="w-24 h-28 object-contain shrink-0"/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function ProductDetails({id}: {id: string}) {
|
|||||||
<nav aria-label={copy.details} className="text-sm text-secondary flex flex-wrap gap-3 mb-8">
|
<nav aria-label={copy.details} className="text-sm text-secondary flex flex-wrap gap-3 mb-8">
|
||||||
<Link href="/" className="hover:text-brand-400">{t.nav.home}</Link>
|
<Link href="/" className="hover:text-brand-400">{t.nav.home}</Link>
|
||||||
<span aria-hidden="true">/</span>
|
<span aria-hidden="true">/</span>
|
||||||
<Link href="/#showcase" className="hover:text-brand-400">{t.nav.showcase}</Link>
|
<Link href="/products" className="hover:text-brand-400">{t.nav.showcase}</Link>
|
||||||
<span aria-hidden="true">/</span><span>{machine.shortLabel}</span>
|
<span aria-hidden="true">/</span><span>{machine.shortLabel}</span>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="grid md:grid-cols-2 gap-10 lg:gap-16 items-start">
|
<div className="grid md:grid-cols-2 gap-10 lg:gap-16 items-start">
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {commerceCopy} from '@/data/commerce';
|
||||||
|
import {filterMachines, productCategories} from '@/data/products';
|
||||||
|
import QuantityControl from './QuantityControl';
|
||||||
|
|
||||||
|
export default function ProductsPage() {
|
||||||
|
const {t, locale} = useLanguage();
|
||||||
|
const copy = commerceCopy[locale];
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const category = searchParams.get('category') ?? '';
|
||||||
|
const products = filterMachines(t.machines, category);
|
||||||
|
const knownCategory = productCategories.some(item => item.id === category);
|
||||||
|
|
||||||
|
const setCategory = (value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) params.set('category', value);
|
||||||
|
else params.delete('category');
|
||||||
|
// Preserve other query parameters and allow browser Back to restore filters.
|
||||||
|
const query = params.toString();
|
||||||
|
router.push(query ? `${pathname}?${query}` : pathname, {scroll: false});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-7xl mx-auto px-5 md:px-8 pt-28 pb-20 min-h-[70vh]">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-extrabold text-primary">{copy.allProducts}</h1>
|
||||||
|
<p className="text-secondary mt-4 leading-7">{copy.productsDescription}</p>
|
||||||
|
<div className="mt-8 mb-8 rounded-2xl border border-subtle bg-surface p-5 flex flex-wrap items-end gap-4">
|
||||||
|
<div className="flex flex-col gap-2 w-full sm:w-72">
|
||||||
|
<label htmlFor="product-category" className="text-sm font-semibold text-primary">{copy.category}</label>
|
||||||
|
<select id="product-category" value={category} onChange={event => setCategory(event.target.value)}
|
||||||
|
className="w-full border border-subtle bg-elevated text-primary rounded-xl px-4 py-3 focus:outline-brand">
|
||||||
|
<option value="">{copy.allCategories}</option>
|
||||||
|
{category && !knownCategory && <option value={category}>{copy.unknownCategory}</option>}
|
||||||
|
{productCategories.map(item => <option key={item.id} value={item.id}>{item.label[locale]}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{category && <button onClick={() => setCategory('')} className="text-brand-400 py-3 hover:underline">{copy.clearFilters}</button>}
|
||||||
|
<p aria-live="polite" aria-atomic="true" className="text-secondary text-sm py-3 sm:ms-auto">
|
||||||
|
{products.length.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')} {copy.results}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{products.length ? (
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{products.map(machine => (
|
||||||
|
<article key={machine.id} className="flex flex-col rounded-3xl border border-subtle bg-surface p-5">
|
||||||
|
<Link href={`/products/${machine.id}`} className="rounded-2xl bg-page p-5">
|
||||||
|
<img src={machine.image} alt={machine.name} className="w-full h-60 object-contain"/>
|
||||||
|
</Link>
|
||||||
|
<span className="text-brand-400 text-xs mt-5">{productCategories.find(item => item.id === machine.category)?.label[locale]}</span>
|
||||||
|
<h2 className="text-xl font-bold text-primary mt-2"><Link href={`/products/${machine.id}`}>{machine.name}</Link></h2>
|
||||||
|
<p className="text-secondary text-sm leading-7 mt-3 mb-5">{machine.description}</p>
|
||||||
|
<p className="text-primary text-lg font-bold mt-auto mb-4">{machine.priceLabel}</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<QuantityControl product={machine}/>
|
||||||
|
<Link href={`/products/${machine.id}`} className="text-brand-400 text-sm hover:underline">{copy.details}</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <div className="text-center text-secondary border border-subtle rounded-2xl p-12">{copy.noProducts}</div>}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {commerceCopy} from '@/data/commerce';
|
||||||
import HorizontalShowcase from './HorizontalShowcase';
|
import HorizontalShowcase from './HorizontalShowcase';
|
||||||
import MobileShowcase from './MobileShowcase';
|
import MobileShowcase from './MobileShowcase';
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768;
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
export default function ShowcaseSection() {
|
export default function ShowcaseSection() {
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = commerceCopy[locale];
|
||||||
const [isMobile, setIsMobile] = useState<boolean | null>(null);
|
const [isMobile, setIsMobile] = useState<boolean | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,6 +28,15 @@ export default function ShowcaseSection() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (isMobile === null) return null;
|
if (isMobile === null) return null;
|
||||||
|
if (!isMobile) return <HorizontalShowcase/>;
|
||||||
|
|
||||||
return isMobile ? <MobileShowcase/> : <HorizontalShowcase/>;
|
return (
|
||||||
|
<>
|
||||||
|
<div className="max-w-7xl mx-auto px-5 md:px-8 py-8 flex flex-wrap justify-between items-center gap-4">
|
||||||
|
<h2 className="text-2xl font-bold text-primary">{copy.featuredProducts}</h2>
|
||||||
|
<Link href="/products" className="text-brand-400 hover:underline">{copy.allProducts}</Link>
|
||||||
|
</div>
|
||||||
|
<MobileShowcase/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import type {Locale} from '@/types/domain';
|
import type {Locale} from '@/types/domain';
|
||||||
|
|
||||||
const fa = {
|
const fa = {
|
||||||
|
allProducts: 'همه محصولات',
|
||||||
|
featuredProducts: 'محصولات منتخب',
|
||||||
|
productsDescription: 'دستگاه مناسب خود را انتخاب کنید و مشخصات آن را بررسی کنید.',
|
||||||
|
category: 'دستهبندی محصولات',
|
||||||
|
allCategories: 'همه دستهبندیها',
|
||||||
|
clearFilters: 'پاک کردن فیلتر',
|
||||||
|
noProducts: 'محصولی با این دستهبندی پیدا نشد.',
|
||||||
|
unknownCategory: 'دستهبندی نامعتبر',
|
||||||
|
results: 'محصول',
|
||||||
details: 'جزئیات محصول',
|
details: 'جزئیات محصول',
|
||||||
increase: 'افزایش تعداد',
|
increase: 'افزایش تعداد',
|
||||||
decrease: 'کاهش تعداد',
|
decrease: 'کاهش تعداد',
|
||||||
@@ -27,6 +36,15 @@ const fa = {
|
|||||||
export const commerceCopy: Record<Locale, Record<keyof typeof fa, string>> = {
|
export const commerceCopy: Record<Locale, Record<keyof typeof fa, string>> = {
|
||||||
fa,
|
fa,
|
||||||
en: {
|
en: {
|
||||||
|
allProducts: 'All products',
|
||||||
|
featuredProducts: 'Featured products',
|
||||||
|
productsDescription: 'Choose a machine and explore its specifications.',
|
||||||
|
category: 'Product category',
|
||||||
|
allCategories: 'All categories',
|
||||||
|
clearFilters: 'Clear filter',
|
||||||
|
noProducts: 'No products found in this category.',
|
||||||
|
unknownCategory: 'Unknown category',
|
||||||
|
results: 'products',
|
||||||
details: 'Product details',
|
details: 'Product details',
|
||||||
increase: 'Increase quantity',
|
increase: 'Increase quantity',
|
||||||
decrease: 'Decrease quantity',
|
decrease: 'Decrease quantity',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {FloatingSnack, Locale, Machine, SiteContent} from '@/types/domain';
|
|||||||
const machinesFa: Machine[] = [
|
const machinesFa: Machine[] = [
|
||||||
{
|
{
|
||||||
id: 'rm-35',
|
id: 'rm-35',
|
||||||
|
category: 'warm-vending',
|
||||||
name: 'روبات مارکت ۳۵ کانال',
|
name: 'روبات مارکت ۳۵ کانال',
|
||||||
shortLabel: '۳۵ کانال',
|
shortLabel: '۳۵ کانال',
|
||||||
priceLabel: '4,180,000,000 ریال',
|
priceLabel: '4,180,000,000 ریال',
|
||||||
@@ -21,6 +22,7 @@ const machinesFa: Machine[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rm-48',
|
id: 'rm-48',
|
||||||
|
category: 'warm-vending',
|
||||||
name: 'روبات مارکت ۴۸ کانال',
|
name: 'روبات مارکت ۴۸ کانال',
|
||||||
shortLabel: '۴۸ کانال',
|
shortLabel: '۴۸ کانال',
|
||||||
priceLabel: '4,980,000,000 ریال',
|
priceLabel: '4,980,000,000 ریال',
|
||||||
@@ -33,6 +35,7 @@ const machinesFa: Machine[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rm-60',
|
id: 'rm-60',
|
||||||
|
category: 'warm-vending',
|
||||||
name: 'روبات مارکت ۶۰ کانال',
|
name: 'روبات مارکت ۶۰ کانال',
|
||||||
shortLabel: '۶۰ کانال',
|
shortLabel: '۶۰ کانال',
|
||||||
priceLabel: '5,480,000,000 ریال',
|
priceLabel: '5,480,000,000 ریال',
|
||||||
@@ -48,6 +51,7 @@ const machinesFa: Machine[] = [
|
|||||||
const machinesEn: Machine[] = [
|
const machinesEn: Machine[] = [
|
||||||
{
|
{
|
||||||
id: 'rm-35',
|
id: 'rm-35',
|
||||||
|
category: 'warm-vending',
|
||||||
name: 'Robot Market RM-35',
|
name: 'Robot Market RM-35',
|
||||||
shortLabel: '35 Channels',
|
shortLabel: '35 Channels',
|
||||||
priceLabel: '4,180,000,000 Rials',
|
priceLabel: '4,180,000,000 Rials',
|
||||||
@@ -60,6 +64,7 @@ const machinesEn: Machine[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rm-48',
|
id: 'rm-48',
|
||||||
|
category: 'warm-vending',
|
||||||
name: 'Robot Market RM-48',
|
name: 'Robot Market RM-48',
|
||||||
shortLabel: '48 Channels',
|
shortLabel: '48 Channels',
|
||||||
priceLabel: '4,980,000,000 Rials',
|
priceLabel: '4,980,000,000 Rials',
|
||||||
@@ -72,6 +77,7 @@ const machinesEn: Machine[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rm-60',
|
id: 'rm-60',
|
||||||
|
category: 'warm-vending',
|
||||||
name: 'Robot Market RM-60',
|
name: 'Robot Market RM-60',
|
||||||
shortLabel: '60 Channels',
|
shortLabel: '60 Channels',
|
||||||
priceLabel: '5,480,000,000 Rials',
|
priceLabel: '5,480,000,000 Rials',
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type {Locale, Machine, ProductCategory} from '@/types/domain';
|
||||||
|
|
||||||
|
export const productCategories: {id: ProductCategory; label: Record<Locale, string>}[] = [
|
||||||
|
{id: 'warm-vending', label: {fa: 'وندینگ گرم', en: 'Warm vending'}},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Keep the home showcase independent of future catalog additions.
|
||||||
|
const featuredIds = ['rm-35', 'rm-48', 'rm-60'];
|
||||||
|
|
||||||
|
export function featuredMachines(machines: Machine[]): Machine[] {
|
||||||
|
return featuredIds.flatMap(id => machines.filter(machine => machine.id === id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterMachines(machines: Machine[], category: string): Machine[] {
|
||||||
|
return category ? machines.filter(machine => machine.category === category) : machines;
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
export type Locale = 'fa' | 'en';
|
export type Locale = 'fa' | 'en';
|
||||||
export type Direction = 'rtl' | 'ltr';
|
export type Direction = 'rtl' | 'ltr';
|
||||||
export type Theme = 'light' | 'dark';
|
export type Theme = 'light' | 'dark';
|
||||||
|
export type ProductCategory = 'warm-vending';
|
||||||
|
|
||||||
export interface Machine {
|
export interface Machine {
|
||||||
id: string;
|
id: string;
|
||||||
|
category: ProductCategory;
|
||||||
name: string;
|
name: string;
|
||||||
shortLabel: string;
|
shortLabel: string;
|
||||||
priceLabel: string;
|
priceLabel: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user