├── .prettierignore ├── .prettierrc ├── app ├── favicon.ico ├── globals.css ├── not-found.tsx ├── products │ ├── layout.tsx │ ├── page.tsx │ └── [id] │ │ └── page.tsx ├── layout.tsx └── page.tsx ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── postcss.config.mjs ├── lib ├── types │ ├── review.ts │ └── product.ts ├── components │ ├── ProductListSkeleton.tsx │ ├── Topbar.tsx │ ├── SearchBar.tsx │ ├── Review.tsx │ └── ProductList.tsx └── utils │ └── getProduct.ts ├── next.config.ts ├── Dockerfile ├── docker-composer.yml ├── eslint.config.mjs ├── tailwind.config.ts ├── README.md ├── .gitignore ├── package.json └── tsconfig.json /.prettierignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "endOfLine": "lf", 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yurii0419/online-shopping/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFoundPage() { 2 | return

Page not Found

; 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /lib/types/review.ts: -------------------------------------------------------------------------------- 1 | type IReview = { 2 | rating: number; 3 | comment: string; 4 | date: string; 5 | reviewerName: string; 6 | reviewerEmail: string; 7 | }; 8 | 9 | export default IReview; 10 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | images: { 5 | domains: ["cdn.dummyjson.com"], 6 | }, 7 | }; 8 | 9 | export default nextConfig; 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:21.1-bookworm-slim 2 | RUN apt-get update 3 | WORKDIR /usr/src/app 4 | COPY package*.json ./ 5 | RUN npm install --legacy-peer-deps 6 | COPY . . 7 | RUN npm run build 8 | 9 | EXPOSE 3000 10 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /docker-composer.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - "3000:3000" 10 | environment: 11 | NODE_ENV: development 12 | volumes: 13 | - .:/usr/src/app 14 | command: npm start -------------------------------------------------------------------------------- /lib/components/ProductListSkeleton.tsx: -------------------------------------------------------------------------------- 1 | export default function ProductListSkeleton() { 2 | return ( 3 |
4 | {Array(10).fill(0).map((_, index) => ( 5 |
6 | ))} 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/products/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | 3 | export const metadata: Metadata = { 4 | title: "Product Page", 5 | description: "Product Page", 6 | keywords: "products, online shopping, ecommerce", 7 | }; 8 | 9 | export default function ProductLayout({ 10 | children, 11 | }: Readonly<{ 12 | children: React.ReactNode; 13 | }>) { 14 | return children; 15 | } 16 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/utils/getProduct.ts: -------------------------------------------------------------------------------- 1 | import { cache } from "react"; 2 | import IProduct from "../types/product"; 3 | 4 | const getProduct = cache(async (id: number): Promise => { 5 | const response = await fetch(`https://dummyjson.com/products/${id}`); 6 | if (!response.ok) { 7 | throw new Error("Failed to fetch product data"); 8 | } 9 | return response.json(); 10 | }); 11 | 12 | export default getProduct; 13 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: [ 5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./lib/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } satisfies Config; 19 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "./globals.css"; 3 | import Topbar from "@/lib/components/Topbar"; 4 | 5 | export const metadata: Metadata = { 6 | title: "Online Shopping", 7 | description: "Online Shopping", 8 | }; 9 | 10 | export default function RootLayout({ 11 | children, 12 | }: Readonly<{ 13 | children: React.ReactNode; 14 | }>) { 15 | return ( 16 | 17 | 18 | 19 | {children} 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /lib/components/Topbar.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | const Topbar = () => { 5 | return ( 6 |
7 | 8 | Next.js logo 9 | 10 | 11 |
12 | Home 13 | Products 14 |
15 |
16 | ); 17 | }; 18 | 19 | export default Topbar; 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Online Shopping 2 | 3 | ### About the application 4 | 5 | This is a simple online shopping application built with [Next.js](https://nextjs.org/) and [Tailwind CSS](https://tailwindcss.com/). 6 | 7 | - Cache product item details result 8 | - Product api service (https://dummyjson.com/) 9 | 10 | 11 | ### Core Tech Stack 12 | - Next.js (latest stable) 13 | - Tailwind CSS 14 | - TypeScript 15 | - Git (with a clear commit history) 16 | - Docker (basic containerization) 17 | 18 | ### How to run 19 | 20 | ```bash 21 | git clone [github link] 22 | npm run build 23 | npm run start 24 | ``` 25 | 26 | ### Total working hours - 3 hrs 27 | 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /lib/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { usePathname, useRouter } from "next/navigation"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export default function SearchBar() { 7 | const [searchQuery, setSearchQuery] = useState('') 8 | const router = useRouter(); 9 | const pathname = usePathname(); 10 | 11 | useEffect(() => { 12 | router.push(`${pathname}?search=${searchQuery}`, { scroll: false }); 13 | }, [searchQuery]); 14 | 15 | return ( 16 | setSearchQuery(e.target.value)} 21 | className="w-full p-3 mb-5 border rounded-md" 22 | /> 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shop-online", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "dayjs": "^1.11.13", 13 | "next": "15.1.6", 14 | "react": "^19.0.0", 15 | "react-dom": "^19.0.0" 16 | }, 17 | "devDependencies": { 18 | "@eslint/eslintrc": "^3", 19 | "@types/node": "^20", 20 | "@types/react": "^19", 21 | "@types/react-dom": "^19", 22 | "eslint": "^9", 23 | "eslint-config-next": "15.1.6", 24 | "postcss": "^8", 25 | "prettier": "3.5.0", 26 | "tailwindcss": "^3.4.1", 27 | "typescript": "^5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "allowSyntheticDefaultImports": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /lib/components/Review.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import IReview from "../types/review"; 3 | 4 | const Review = ({ review }: { review: IReview }) => { 5 | return ( 6 |
7 |
8 |
9 | {Array(parseInt(`${review.rating}`) + 1) 10 | .fill(0) 11 | .map((_, index: number) => ( 12 | 13 | ))} 14 |
15 |

16 | {review.reviewerName} {dayjs(review.date).format("DD/MM/YYYY")} 17 |

18 |
19 | 20 |

{review.comment}

21 |
22 | ); 23 | }; 24 | 25 | export default Review; 26 | -------------------------------------------------------------------------------- /lib/types/product.ts: -------------------------------------------------------------------------------- 1 | import IReview from "./review"; 2 | 3 | type IProduct = { 4 | id: number; 5 | title: string; 6 | description: string; 7 | category: string; 8 | price: number; 9 | discountPercentage: number; 10 | rating: number; 11 | stock: number; 12 | tags: string[]; 13 | brand: string; 14 | sku: string; 15 | weight: number; 16 | dimensions: { 17 | width: number; 18 | height: number; 19 | depth: number; 20 | }; 21 | warrantyInformation: string; 22 | shippingInformation: string; 23 | availabilityStatus: string; 24 | reviews: IReview[]; 25 | returnPolicy: string; 26 | minimumOrderQuantity: number; 27 | meta: { 28 | createdAt: string; 29 | updatedAt: string; 30 | barcode: string; 31 | qrCode: string; 32 | }; 33 | images: string[]; 34 | thumbnail: string; 35 | }; 36 | 37 | export default IProduct; 38 | -------------------------------------------------------------------------------- /app/products/page.tsx: -------------------------------------------------------------------------------- 1 | import ProductList from "@/lib/components/ProductList"; 2 | import ProductListSkeleton from "@/lib/components/ProductListSkeleton"; 3 | import SearchBar from "@/lib/components/SearchBar"; 4 | import { Suspense } from "react" 5 | 6 | export default async function ProductsPage({ searchParams }: { searchParams: Promise<{ search: string }> }) { 7 | const { search } = await searchParams; 8 | const response = await fetch(`https://dummyjson.com/products/search?q=${search || ''}`); 9 | const data = await response.json(); 10 | 11 | return ( 12 |
13 |

Products

14 | }> 15 | 16 | 17 | }> 18 | 19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /lib/components/ProductList.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import IProduct from "../types/product" 3 | import Image from "next/image" 4 | 5 | const ProductList = ({ products }: { products: IProduct[] }) => { 6 | return
7 | {products.map((product) => ( 8 | 13 | {product.title} 14 |
15 |

{product.title}

16 | ${product.price} 17 |
18 | 19 | ))} 20 |
21 | } 22 | 23 | export default ProductList -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | const products = [ 5 | { 6 | id: 1, 7 | title: "Essence Mascara Lash Princess", 8 | imageUrl: "https://cdn.dummyjson.com/products/images/beauty/Essence%20Mascara%20Lash%20Princess/thumbnail.png", 9 | }, 10 | { 11 | id: 2, 12 | title: "Powder Canister", 13 | imageUrl: "https://cdn.dummyjson.com/products/images/beauty/Eyeshadow%20Palette%20with%20Mirror/thumbnail.png", 14 | }, 15 | { 16 | id: 3, 17 | title: "Essence Mascara Lash Princess", 18 | imageUrl: "https://cdn.dummyjson.com/products/images/beauty/Powder%20Canister/thumbnail.png", 19 | }, 20 | ]; 21 | 22 | export default function Home() { 23 | return ( 24 |
25 |

Popular Products

26 |
27 |
28 | {products.map((product) => ( 29 | 34 | {product.title} 35 |

{product.title}

36 | 37 | ))} 38 |
39 | 40 | View All Products 41 | 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /app/products/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Review from "@/lib/components/Review"; 2 | import getProduct from "@/lib/utils/getProduct"; 3 | import { Metadata } from "next"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | 7 | export async function generateMetadata({ params }: { params: Promise<{ id: number }> }): Promise { 8 | const { id } = await params; 9 | const product = await getProduct(id); 10 | 11 | return { 12 | title: product.title, 13 | description: product.description, 14 | }; 15 | } 16 | 17 | export default async function ProductPage({ params }: { params: Promise<{ id: number }> }) { 18 | const { id } = await params; 19 | const product = await getProduct(id); 20 | 21 | return ( 22 |
23 |
24 |

{product.title}

25 | 26 | Product List 27 | 28 |
29 | 30 |
31 | {product.title} 32 |
33 |

SKU: {product.sku}

34 |

${product.price}

35 |

36 | Dimensions: {`${product.dimensions.width} x ${product.dimensions.height} x ${product.dimensions.depth}`} 37 |

38 |
39 |
40 | {Array(parseInt(`${product.rating}`) + 1) 41 | .fill(0) 42 | .map((_, index: number) => ( 43 | 44 | ))} 45 |
46 |

47 | {product.rating} ({product.reviews.length} reviews) 48 |

49 |
50 | 51 |

{product.description}

52 |
53 |
54 | 55 |
56 |

Reviews

57 | {product.reviews.map((review, index) => ( 58 | 59 | ))} 60 |
61 | 62 |
63 |

Product Images

64 |
65 | {product.images.map((image, index) => ( 66 | {`Image 67 | ))} 68 |
69 |
70 |
71 | ); 72 | } 73 | --------------------------------------------------------------------------------