├── .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 |
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 |
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 |
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 |
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 |
67 | ))}
68 |
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------