├── LEARN.md ├── .eslintrc.json ├── app ├── favicon.ico ├── globals.css ├── (routes) │ ├── product │ │ ├── loading.tsx │ │ └── [productId] │ │ │ └── page.tsx │ ├── category │ │ ├── loading.tsx │ │ └── [categoryId] │ │ │ ├── components │ │ │ ├── mobile-filters.tsx │ │ │ └── filter.tsx │ │ │ └── page.tsx │ └── cart │ │ ├── page.tsx │ │ └── components │ │ ├── cart-item.tsx │ │ └── summary.tsx ├── layout.tsx └── page.tsx ├── public └── images │ ├── image-1.jpg │ ├── image-2.jpg │ ├── nike-reactx.png │ ├── nike-just-do-it.jpg │ └── graph-paper.svg ├── postcss.config.js ├── fonts ├── BRFirma-font │ ├── BRFirma-Bold.woff2 │ ├── BRFirma-Light.woff2 │ ├── BRFirma-Medium.woff2 │ ├── BRFirma-Regular.woff2 │ └── BRFirma-SemiBold.woff2 └── Futura-Condensed-Extra-Bold.woff2 ├── lib └── utils.ts ├── next.config.js ├── providers ├── toast-provider.tsx └── modal-provider.tsx ├── components ├── ui │ ├── no-results.tsx │ ├── container.tsx │ ├── skeleton.tsx │ ├── currency.tsx │ ├── icon-button.tsx │ ├── button.tsx │ ├── modal.tsx │ └── product-card.tsx ├── product-list.tsx ├── billboard.tsx ├── preview-modal.tsx ├── navbar-actions.tsx ├── main-nav.tsx ├── gallery │ ├── gallery-tab.tsx │ └── index.tsx ├── navbar.tsx ├── info.tsx └── footer.tsx ├── actions ├── get-sizes.tsx ├── get-colors.tsx ├── get-categories.tsx ├── get-product.tsx ├── get-category.tsx ├── get-billboard.tsx └── get-products.tsx ├── .gitignore ├── hooks ├── use-preview-modal.ts └── use-cart.tsx ├── README.md ├── tailwind.config.js ├── types.ts ├── tsconfig.json ├── package.json └── LICENSE /LEARN.md: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OgaDavid/nike-shop/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/images/image-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OgaDavid/nike-shop/HEAD/public/images/image-1.jpg -------------------------------------------------------------------------------- /public/images/image-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OgaDavid/nike-shop/HEAD/public/images/image-2.jpg -------------------------------------------------------------------------------- /public/images/nike-reactx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OgaDavid/nike-shop/HEAD/public/images/nike-reactx.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/images/nike-just-do-it.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OgaDavid/nike-shop/HEAD/public/images/nike-just-do-it.jpg -------------------------------------------------------------------------------- /fonts/BRFirma-font/BRFirma-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OgaDavid/nike-shop/HEAD/fonts/BRFirma-font/BRFirma-Bold.woff2 -------------------------------------------------------------------------------- /fonts/BRFirma-font/BRFirma-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OgaDavid/nike-shop/HEAD/fonts/BRFirma-font/BRFirma-Light.woff2 -------------------------------------------------------------------------------- /fonts/BRFirma-font/BRFirma-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OgaDavid/nike-shop/HEAD/fonts/BRFirma-font/BRFirma-Medium.woff2 -------------------------------------------------------------------------------- /fonts/Futura-Condensed-Extra-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OgaDavid/nike-shop/HEAD/fonts/Futura-Condensed-Extra-Bold.woff2 -------------------------------------------------------------------------------- /fonts/BRFirma-font/BRFirma-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OgaDavid/nike-shop/HEAD/fonts/BRFirma-font/BRFirma-Regular.woff2 -------------------------------------------------------------------------------- /fonts/BRFirma-font/BRFirma-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OgaDavid/nike-shop/HEAD/fonts/BRFirma-font/BRFirma-SemiBold.woff2 -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | domains: ["res.cloudinary.com"], 5 | }, 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /providers/toast-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Toaster } from "react-hot-toast"; 4 | 5 | const ToastProvider = () => { 6 | return ; 7 | }; 8 | 9 | export default ToastProvider; 10 | -------------------------------------------------------------------------------- /components/ui/no-results.tsx: -------------------------------------------------------------------------------- 1 | const NoResults = () => { 2 | return ( 3 |
4 | No Results Found! 5 |
6 | ); 7 | } 8 | 9 | export default NoResults; -------------------------------------------------------------------------------- /actions/get-sizes.tsx: -------------------------------------------------------------------------------- 1 | import { Size } from "@/types"; 2 | 3 | const URL = `${process.env.NEXT_PUBLIC_API_URL}/sizes` 4 | 5 | const getSizes = async (): Promise => { 6 | const res = await fetch(URL); 7 | 8 | return res.json(); 9 | } 10 | 11 | export default getSizes -------------------------------------------------------------------------------- /actions/get-colors.tsx: -------------------------------------------------------------------------------- 1 | import { Color } from "@/types"; 2 | 3 | const URL = `${process.env.NEXT_PUBLIC_API_URL}/colors` 4 | 5 | const getColors = async (): Promise => { 6 | const res = await fetch(URL); 7 | 8 | return res.json(); 9 | } 10 | 11 | export default getColors -------------------------------------------------------------------------------- /actions/get-categories.tsx: -------------------------------------------------------------------------------- 1 | import { Category } from "@/types"; 2 | 3 | const URL = `${process.env.NEXT_PUBLIC_API_URL}/categories` 4 | 5 | const getCategories = async (): Promise => { 6 | const res = await fetch(URL); 7 | 8 | return res.json(); 9 | } 10 | 11 | export default getCategories -------------------------------------------------------------------------------- /actions/get-product.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from "@/types"; 2 | 3 | const URL = `${process.env.NEXT_PUBLIC_API_URL}/products`; 4 | 5 | const getProduct = async ( id: string ): Promise => { 6 | const res = await fetch(`${URL}/${id}`); 7 | 8 | return res.json(); 9 | }; 10 | 11 | export default getProduct; 12 | -------------------------------------------------------------------------------- /actions/get-category.tsx: -------------------------------------------------------------------------------- 1 | import { Category } from "@/types"; 2 | 3 | const URL = `${process.env.NEXT_PUBLIC_API_URL}/categories`; 4 | 5 | const getCategory = async ( id: string ): Promise => { 6 | const res = await fetch(`${URL}/${id}`); 7 | 8 | return res.json(); 9 | }; 10 | 11 | export default getCategory; 12 | -------------------------------------------------------------------------------- /actions/get-billboard.tsx: -------------------------------------------------------------------------------- 1 | import { Billboard } from "@/types"; 2 | 3 | const URL = `${process.env.NEXT_PUBLIC_API_URL}/billboards`; 4 | 5 | const getBillboard = async ( id: string ): Promise => { 6 | const res = await fetch(`${URL}/${id}`); 7 | 8 | return res.json(); 9 | }; 10 | 11 | export default getBillboard; 12 | -------------------------------------------------------------------------------- /components/ui/container.tsx: -------------------------------------------------------------------------------- 1 | interface ContainerProps { 2 | children: React.ReactNode 3 | } 4 | 5 | const Container: React.FC = ({ children }) => { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | } 12 | 13 | export default Container; -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | const Skeleton = ({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) => { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export default Skeleton; -------------------------------------------------------------------------------- /providers/modal-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import PreviewModal from "@/components/preview-modal"; 4 | import { useEffect, useState } from "react"; 5 | 6 | const ModalProvider = () => { 7 | const [isMounted, setisMounted] = useState(false); 8 | 9 | useEffect(() => { 10 | setisMounted(true); 11 | }, []); 12 | 13 | if (!isMounted) { 14 | return null; 15 | } 16 | return ( 17 | <> 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default ModalProvider; 24 | -------------------------------------------------------------------------------- /.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.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /hooks/use-preview-modal.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { Product } from "@/types"; 3 | 4 | interface PreviewModalStore { 5 | isOpen: boolean; 6 | data?: Product; 7 | onOpen: (data: Product) => void; 8 | onClose: () => void; 9 | } 10 | 11 | const usePreviewModal = create((set) => ({ 12 | isOpen: false, 13 | data: undefined, 14 | onOpen: (data: Product) => set({ data, isOpen: true }), 15 | onClose: () => set({ isOpen: false }), 16 | })); 17 | 18 | export default usePreviewModal; 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `PS: The hosted link does not work because planetscale discontinued their hobby/free plan. I can't export the data to migrate to another database like railway or another mySQL database because i have to add a valid credit card and pay about $39 to wake the database before i can export.` 2 | 3 | # Watch Demo Here ✨ 4 | Click to watch the video 5 | 6 | [![Watch the video](https://i.vimeocdn.com/video/1950067409-ad88f67d9e974336f57eb148a9dad5fa966c98c0461387f130e63d17b88bfc2d-d?mw=1100&mh=620&q=70)](https://vimeo.com/1029679616) 7 |
8 |
9 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | height: 100%; 9 | } 10 | 11 | .scrollbar-hide::-webkit-scrollbar { 12 | display: none; 13 | } 14 | 15 | .social-icons { 16 | color: '#888888' 17 | } 18 | 19 | .social-icons:hover { 20 | color: white; 21 | cursor: pointer; 22 | transition: all .3s ease-in-out; 23 | } 24 | 25 | .graph { 26 | background-image: url('/images/graph-paper.svg'); 27 | } 28 | 29 | .subtle { 30 | background: rgba(255, 255, 255, 0.7); 31 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './components/**/*.{js,ts,jsx,tsx,mdx}', 6 | './app/**/*.{js,ts,jsx,tsx,mdx}', 7 | ], 8 | theme: { 9 | extend: { 10 | backgroundImage: { 11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 12 | 'gradient-conic': 13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /components/ui/currency.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | export const Formatter = new Intl.NumberFormat("en-NG", { 6 | style: "currency", 7 | currency: "NGN", 8 | }); 9 | 10 | interface CurrencyProps { 11 | value: string | number; 12 | } 13 | 14 | const Currency: React.FC = ({ value }) => { 15 | const [isMounted, setIsMounted] = useState(false); 16 | 17 | useEffect(() => { 18 | setIsMounted(true); 19 | }, []); 20 | 21 | if (!isMounted) { 22 | return null; 23 | } 24 | return
{Formatter.format(Number(value))}
; 25 | }; 26 | 27 | export default Currency; 28 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | export interface Billboard { 2 | id: string; 3 | label: string; 4 | imageUrl: string; 5 | } 6 | 7 | export interface Category { 8 | id: string; 9 | name: string; 10 | billboard: Billboard; 11 | } 12 | 13 | export interface Image { 14 | id: string; 15 | url: string; 16 | } 17 | 18 | export interface Size { 19 | id: string; 20 | name: string; 21 | value: string; 22 | } 23 | export interface Color { 24 | id: string; 25 | name: string; 26 | value: string; 27 | } 28 | 29 | export interface Product { 30 | id: string; 31 | category: Category; 32 | description: string, 33 | name: string; 34 | price: string; 35 | size: Size; 36 | color: Color; 37 | images: Image[]; 38 | } 39 | -------------------------------------------------------------------------------- /components/ui/icon-button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { MouseEventHandler } from "react"; 3 | 4 | interface IconButtonProps { 5 | onClick?: MouseEventHandler | undefined; 6 | icon: React.ReactElement; 7 | className?: string; 8 | } 9 | 10 | const IconButton: React.FC = ({ 11 | onClick, 12 | icon, 13 | className, 14 | }) => { 15 | return ( 16 | 25 | ); 26 | }; 27 | 28 | export default IconButton; 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": 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 | -------------------------------------------------------------------------------- /components/product-list.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from "@/types"; 2 | import NoResults from "@/components/ui/no-results"; 3 | import ProductCard from "./ui/product-card"; 4 | 5 | interface ProductListProps { 6 | title: string; 7 | items: Product[]; 8 | } 9 | 10 | const ProductList: React.FC = ({ title, items }) => { 11 | return ( 12 |
13 |

{title}

14 | { items.length === 0 && } 15 |
16 | {items.map((item) => ( 17 | 18 | ))} 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default ProductList; 25 | -------------------------------------------------------------------------------- /actions/get-products.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from "@/types"; 2 | import qs from 'query-string' 3 | 4 | const URL = `${process.env.NEXT_PUBLIC_API_URL}/products`; 5 | 6 | interface Query { 7 | categoryId?: string; 8 | colorId?: string; 9 | sizeId?: string; 10 | description?: string; 11 | isFeatured?: boolean; 12 | } 13 | 14 | const getProducts = async (query: Query): Promise => { 15 | const url = qs.stringifyUrl({ 16 | url: URL, 17 | query: { 18 | description: query.description, 19 | colorId: query.colorId, 20 | sizeId: query.sizeId, 21 | categoryId: query.categoryId, 22 | isFeatured: query.isFeatured 23 | } 24 | }); 25 | 26 | const res = await fetch(url); 27 | 28 | return res.json(); 29 | }; 30 | 31 | export default getProducts; 32 | -------------------------------------------------------------------------------- /components/billboard.tsx: -------------------------------------------------------------------------------- 1 | import { Billboard as BillboardType } from "@/types"; 2 | 3 | interface BillboardProps { 4 | data: BillboardType; 5 | } 6 | 7 | export const Billboard: React.FC = ({ data }) => { 8 | return ( 9 |
10 |
14 |
15 |
16 | {data.label} 17 |
18 |
19 |
20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sneaker-head-frontend", 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 | "@headlessui/react": "^1.7.15", 13 | "@types/node": "20.4.0", 14 | "@types/react": "18.2.14", 15 | "@types/react-dom": "18.2.6", 16 | "autoprefixer": "10.4.14", 17 | "axios": "^1.4.0", 18 | "clsx": "^1.2.1", 19 | "eslint": "8.44.0", 20 | "eslint-config-next": "13.4.9", 21 | "lucide-react": "^0.258.0", 22 | "next": "13.4.9", 23 | "postcss": "8.4.25", 24 | "query-string": "^8.1.0", 25 | "react": "18.2.0", 26 | "react-dom": "18.2.0", 27 | "react-hot-toast": "^2.4.1", 28 | "tailwind-merge": "^1.13.2", 29 | "tailwindcss": "3.3.2", 30 | "typescript": "5.1.6", 31 | "zustand": "^4.3.9" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { forwardRef } from "react"; 3 | 4 | export interface ButtonProps 5 | extends React.ButtonHTMLAttributes {} 6 | 7 | const Button = forwardRef( 8 | ({ className, children, disabled, type = "button", ...props }, ref) => { 9 | return ( 10 | 34 | ); 35 | } 36 | ); 37 | 38 | Button.displayName = "Button"; 39 | 40 | export default Button; 41 | -------------------------------------------------------------------------------- /components/preview-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import usePreviewModal from "@/hooks/use-preview-modal"; 4 | import Modal from "./ui/modal"; 5 | import Gallery from "./gallery"; 6 | import Info from "./info"; 7 | 8 | const PreviewModal = () => { 9 | 10 | const previewModal = usePreviewModal(); 11 | const product = usePreviewModal((state) => state.data) 12 | 13 | if (!product) { 14 | return null 15 | } 16 | return ( 17 | 18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 |
27 | ); 28 | } 29 | 30 | 31 | export default PreviewModal; -------------------------------------------------------------------------------- /components/navbar-actions.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Button from "@/components/ui/button"; 4 | import useCart from "@/hooks/use-cart"; 5 | import { ShoppingBag } from "lucide-react"; 6 | import { useRouter } from "next/navigation"; 7 | import { useEffect, useState } from "react"; 8 | 9 | const NavbarActions = () => { 10 | const [isMounted, setIsMounted] = useState(false); 11 | 12 | useEffect(() => { 13 | setIsMounted(true); 14 | }, []); 15 | 16 | const cart = useCart(); 17 | const router = useRouter(); 18 | 19 | if (!isMounted) { 20 | return null; 21 | } 22 | return ( 23 |
24 | 30 |
31 | ); 32 | }; 33 | 34 | export default NavbarActions; 35 | -------------------------------------------------------------------------------- /app/(routes)/product/loading.tsx: -------------------------------------------------------------------------------- 1 | import Container from "@/components/ui/container"; 2 | import Skeleton from "@/components/ui/skeleton"; 3 | 4 | const Loading = () => { 5 | return ( 6 | 7 |
8 |
9 |
10 | 11 |
12 | 13 |
14 |
15 |
16 | 17 | 18 | 19 |
20 |
21 |
22 |
23 | ); 24 | } 25 | 26 | export default Loading; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 David oga 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /components/main-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { Category } from "@/types"; 5 | import Link from "next/link"; 6 | import { usePathname } from "next/navigation"; 7 | 8 | interface MainNavProps { 9 | data: Category[]; 10 | } 11 | 12 | const MainNav: React.FC = ({ data }) => { 13 | const pathName = usePathname(); 14 | 15 | const routes = data.map((route) => ({ 16 | href: `/category/${route.id}`, 17 | label: route.name, 18 | active: pathName === `/category/${route.id}`, 19 | })); 20 | return ( 21 | 35 | ); 36 | }; 37 | 38 | export default MainNav; 39 | -------------------------------------------------------------------------------- /components/gallery/gallery-tab.tsx: -------------------------------------------------------------------------------- 1 | import NextImage from "next/image"; 2 | import { Tab } from "@headlessui/react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | import { Image } from "@/types"; 6 | 7 | interface GalleryTabProps { 8 | image: Image; 9 | } 10 | 11 | const GalleryTab: React.FC = ({ 12 | image 13 | }) => { 14 | return ( 15 | 18 | {({ selected }) => ( 19 |
20 | 21 | 27 | 28 | 34 |
35 | )} 36 |
37 | ); 38 | } 39 | 40 | export default GalleryTab; -------------------------------------------------------------------------------- /app/(routes)/category/loading.tsx: -------------------------------------------------------------------------------- 1 | import Container from "@/components/ui/container"; 2 | import Skeleton from "@/components/ui/skeleton"; 3 | 4 | const Loading = () => { 5 | return ( 6 | 7 |
8 | 9 |
10 |
11 | 12 |
13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 |
24 |
25 |
26 | ); 27 | } 28 | 29 | export default Loading; -------------------------------------------------------------------------------- /hooks/use-cart.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { toast } from "react-hot-toast"; 3 | import { persist, createJSONStorage } from "zustand/middleware"; 4 | 5 | import { Product } from "@/types"; 6 | 7 | interface CartStore { 8 | items: Product[]; 9 | addItem: (data: Product) => void; 10 | removeItem: (id: string) => void; 11 | removeAll: () => void; 12 | } 13 | 14 | const useCart = create( 15 | persist( 16 | (set, get) => ({ 17 | items: [], 18 | addItem: (data: Product) => { 19 | const currentItems = get().items; 20 | const existingItem = currentItems.find((item) => item.id === data.id); 21 | 22 | if (existingItem) { 23 | return toast.error("Item already in cart."); 24 | } 25 | 26 | set({ items: [...get().items, data] }); 27 | toast.success("Item added to cart."); 28 | }, 29 | removeItem: (id: string) => { 30 | set({ items: [...get().items.filter((item) => item.id !== id)] }); 31 | toast.success("Item removed from cart."); 32 | }, 33 | removeAll: () => set({ items: [] }), 34 | }), 35 | { 36 | name: "cart-storage", 37 | storage: createJSONStorage(() => localStorage), 38 | } 39 | ) 40 | ); 41 | 42 | export default useCart; 43 | -------------------------------------------------------------------------------- /components/gallery/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import NextImage from "next/image"; 4 | import { Tab } from "@headlessui/react"; 5 | 6 | import { Image } from "@/types"; 7 | 8 | import GalleryTab from "./gallery-tab"; 9 | 10 | interface GalleryProps { 11 | images: Image[]; 12 | } 13 | 14 | const Gallery: React.FC = ({ 15 | images = [] 16 | }) => { 17 | return ( 18 | 19 |
20 | 21 | {images.map((image) => ( 22 | 23 | ))} 24 | 25 |
26 | 27 | {images.map((image) => ( 28 | 29 |
30 | 36 |
37 |
38 | ))} 39 |
40 |
41 | ); 42 | } 43 | 44 | export default Gallery; -------------------------------------------------------------------------------- /app/(routes)/cart/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | import Container from "@/components/ui/container"; 6 | import useCart from "@/hooks/use-cart"; 7 | 8 | import CartItem from "./components/cart-item"; 9 | import Summary from "./components/summary"; 10 | 11 | export const revalidate = 0; 12 | 13 | const CartPage = () => { 14 | const [isMounted, setIsMounted] = useState(false); 15 | const cart = useCart(); 16 | 17 | useEffect(() => { 18 | setIsMounted(true); 19 | }, []); 20 | 21 | if (!isMounted) { 22 | return null; 23 | } 24 | 25 | return ( 26 |
27 | 28 |
29 |

Shopping Cart

30 |
31 |
32 | {cart.items.length === 0 && ( 33 |

No items added to cart.

34 | )} 35 |
    36 | {cart.items.map((item) => ( 37 | 38 | ))} 39 |
40 |
41 | 42 |
43 |
44 |
45 |
46 | ); 47 | }; 48 | 49 | export default CartPage; 50 | -------------------------------------------------------------------------------- /public/images/graph-paper.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/(routes)/product/[productId]/page.tsx: -------------------------------------------------------------------------------- 1 | import getProduct from "@/actions/get-product"; 2 | import getProducts from "@/actions/get-products"; 3 | import Gallery from "@/components/gallery"; 4 | import Info from "@/components/info"; 5 | import ProductList from "@/components/product-list"; 6 | import Container from "@/components/ui/container"; 7 | 8 | interface ProductPageProps { 9 | params: { 10 | productId: string; 11 | }; 12 | } 13 | 14 | export const revalidate = 0; 15 | 16 | const ProductPage: React.FC = async ({ params }) => { 17 | const product = await getProduct(params.productId); 18 | 19 | const Products = await getProducts({ 20 | categoryId: product.category.id, 21 | }); 22 | 23 | // remove product selected from suggested products, randomize the suggested products and then reduce to 8 24 | const suggestedProducts = await Products.filter( 25 | (item) => item.id !== params.productId 26 | ) 27 | .sort(() => Math.random() - 0.5) 28 | .slice(0, 8); 29 | 30 | if (!product) { 31 | return null; 32 | } 33 | return ( 34 |
35 | 36 |
37 |
38 | 39 |
40 | 41 |
42 |
43 |
44 | 45 |
46 |
47 |
48 | ); 49 | }; 50 | 51 | export default ProductPage; 52 | -------------------------------------------------------------------------------- /components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import Container from "@/components/ui/container"; 2 | import Link from "next/link"; 3 | import MainNav from "@/components/main-nav"; 4 | import getCategories from "@/actions/get-categories"; 5 | import NavbarActions from "@/components/navbar-actions"; 6 | 7 | const Navbar = async () => { 8 | const categories = await getCategories(); 9 | return ( 10 |
11 | 12 |
13 | 18 | 35 | 36 | 37 | 38 |
39 |
40 |
41 | ); 42 | }; 43 | 44 | export default Navbar; 45 | -------------------------------------------------------------------------------- /app/(routes)/cart/components/cart-item.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { toast } from "react-hot-toast"; 3 | import { X } from "lucide-react"; 4 | 5 | import IconButton from "@/components/ui/icon-button"; 6 | import Currency from "@/components/ui/currency"; 7 | import useCart from "@/hooks/use-cart"; 8 | import { Product } from "@/types"; 9 | 10 | interface CartItemProps { 11 | data: Product; 12 | } 13 | 14 | const CartItem: React.FC = ({ data }) => { 15 | const cart = useCart(); 16 | 17 | const onRemove = () => { 18 | cart.removeItem(data.id); 19 | }; 20 | 21 | return ( 22 |
  • 23 |
    24 | 30 |
    31 |
    32 |
    33 | } /> 34 |
    35 |
    36 |
    37 |

    {data.name}

    38 |
    39 | 40 |
    41 |

    {data.color.name}

    42 |

    43 | {data.size.name} 44 |

    45 |
    46 | 47 |
    48 |
    49 |
  • 50 | ); 51 | }; 52 | 53 | export default CartItem; 54 | -------------------------------------------------------------------------------- /app/(routes)/category/[categoryId]/components/mobile-filters.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Button from "@/components/ui/button"; 4 | import IconButton from "@/components/ui/icon-button"; 5 | import { Color, Size } from "@/types"; 6 | import { Dialog } from "@headlessui/react"; 7 | import { Plus, X } from "lucide-react"; 8 | import { useState } from "react"; 9 | import Filter from "./filter"; 10 | 11 | interface MobileFiltersProps { 12 | sizes: Size[]; 13 | colors: Color[]; 14 | } 15 | 16 | const MobileFilters: React.FC = ({ sizes, colors }) => { 17 | const [open, setOpen] = useState(false); 18 | 19 | const onOpen = () => setOpen(true); 20 | const onClose = () => setOpen(false); 21 | 22 | return ( 23 | <> 24 | 28 | 29 | 35 |
    36 | 37 |
    38 | 39 |
    40 | } /> 41 |
    42 | 43 |
    44 | 45 | 46 |
    47 |
    48 |
    49 |
    50 | 51 | ); 52 | }; 53 | 54 | export default MobileFilters; 55 | -------------------------------------------------------------------------------- /components/ui/modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Dialog, Transition } from "@headlessui/react"; 4 | import { Fragment } from "react"; 5 | import IconButton from "./icon-button"; 6 | import { X } from "lucide-react"; 7 | 8 | interface ModalProps { 9 | open: boolean; 10 | onClose: () => void; 11 | children: React.ReactNode; 12 | } 13 | 14 | const Modal: React.FC = ({ open, onClose, children }) => { 15 | return ( 16 | 17 | 18 |
    19 |
    20 |
    21 | 30 | 31 |
    32 |
    33 | } /> 34 |
    35 | {children} 36 |
    37 |
    38 |
    39 |
    40 |
    41 |
    42 |
    43 | ); 44 | }; 45 | 46 | export default Modal; 47 | -------------------------------------------------------------------------------- /components/info.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Product } from "@/types"; 4 | import Currency from "./ui/currency"; 5 | import Button from "./ui/button"; 6 | import { ShoppingCart } from "lucide-react"; 7 | import useCart from "@/hooks/use-cart"; 8 | import { MouseEventHandler } from "react"; 9 | 10 | interface InfoProps { 11 | data: Product; 12 | } 13 | 14 | const Info: React.FC = ({ data }) => { 15 | const cart = useCart(); 16 | 17 | const onAddToCart: MouseEventHandler = (event) => { 18 | event.stopPropagation(); 19 | 20 | cart.addItem(data); 21 | }; 22 | return ( 23 |
    24 |

    25 | {data.name} 26 |

    27 |
    28 |

    29 | 30 |

    31 |
    32 |
    33 |

    {data.description}

    34 |
    35 |
    36 |

    Size:

    37 |
    {data.size.name}
    38 |
    39 |
    40 |

    Color:

    41 |
    45 |
    46 |
    47 |
    48 | 52 |
    53 |
    54 | ); 55 | }; 56 | 57 | export default Info; 58 | -------------------------------------------------------------------------------- /app/(routes)/category/[categoryId]/components/filter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import qs from "query-string"; 4 | import { useRouter, useSearchParams } from "next/navigation"; 5 | 6 | import Button from "@/components/ui/button"; 7 | import { cn } from "@/lib/utils"; 8 | import { Color, Size } from "@/types"; 9 | 10 | interface FilterProps { 11 | data: (Size | Color)[]; 12 | name: string; 13 | valueKey: string; 14 | } 15 | 16 | const Filter: React.FC = ({ data, name, valueKey }) => { 17 | const searchParams = useSearchParams(); 18 | const router = useRouter(); 19 | 20 | const selectedValue = searchParams.get(valueKey); 21 | 22 | const onClick = (id: string) => { 23 | const current = qs.parse(searchParams.toString()); 24 | 25 | const query = { 26 | ...current, 27 | [valueKey]: id, 28 | }; 29 | 30 | if (current[valueKey] === id) { 31 | query[valueKey] = null; 32 | } 33 | 34 | const url = qs.stringifyUrl( 35 | { 36 | url: window.location.href, 37 | query, 38 | }, 39 | { skipNull: true } 40 | ); 41 | 42 | router.push(url); 43 | }; 44 | 45 | return ( 46 |
    47 |

    {name}

    48 |
    49 |
    50 | {data.map((filter) => ( 51 |
    52 | 61 |
    62 | ))} 63 |
    64 |
    65 | ); 66 | }; 67 | 68 | export default Filter; 69 | -------------------------------------------------------------------------------- /app/(routes)/cart/components/summary.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import axios from "axios"; 4 | import { useEffect } from "react"; 5 | import { useSearchParams } from "next/navigation"; 6 | 7 | import Button from "@/components/ui/button"; 8 | import Currency from "@/components/ui/currency"; 9 | import useCart from "@/hooks/use-cart"; 10 | import { toast } from "react-hot-toast"; 11 | 12 | const Summary = () => { 13 | const searchParams = useSearchParams(); 14 | const items = useCart((state) => state.items); 15 | const removeAll = useCart((state) => state.removeAll); 16 | 17 | useEffect(() => { 18 | if (searchParams.get("success")) { 19 | toast.success("Payment completed."); 20 | removeAll(); 21 | } 22 | 23 | if (searchParams.get("canceled")) { 24 | toast.error("Something went wrong."); 25 | } 26 | }, [searchParams, removeAll]); 27 | 28 | const totalPrice = items.reduce((total, item) => { 29 | return total + Number(item.price); 30 | }, 0); 31 | 32 | const onCheckout = async () => { 33 | const response = await axios.post( 34 | `${process.env.NEXT_PUBLIC_API_URL}/checkout`, 35 | { 36 | productIds: items.map((item) => item.id), 37 | } 38 | ); 39 | 40 | window.location = response.data.url; 41 | }; 42 | 43 | return ( 44 |
    45 |

    Order summary

    46 |
    47 |
    48 |
    Order total
    49 | 50 |
    51 |
    52 | 59 |
    60 | ); 61 | }; 62 | 63 | export default Summary; 64 | -------------------------------------------------------------------------------- /app/(routes)/category/[categoryId]/page.tsx: -------------------------------------------------------------------------------- 1 | import getCategory from "@/actions/get-category"; 2 | import getColors from "@/actions/get-colors"; 3 | import getProducts from "@/actions/get-products"; 4 | import getSizes from "@/actions/get-sizes"; 5 | import { Billboard } from "@/components/billboard"; 6 | import Container from "@/components/ui/container"; 7 | import Filter from "./components/filter"; 8 | import NoResults from "@/components/ui/no-results"; 9 | import ProductCard from "@/components/ui/product-card"; 10 | import MobileFilters from "./components/mobile-filters"; 11 | 12 | export const revalidate = 0; 13 | 14 | interface CategoryPageProps { 15 | params: { 16 | categoryId: string; 17 | }; 18 | searchParams: { 19 | colorId: string; 20 | sizeId: string; 21 | }; 22 | } 23 | 24 | const CategoryPage: React.FC = async ({ 25 | params, 26 | searchParams, 27 | }) => { 28 | const products = await getProducts({ 29 | categoryId: params.categoryId, 30 | colorId: searchParams.colorId, 31 | sizeId: searchParams.sizeId, 32 | }); 33 | const sizes = await getSizes(); 34 | const colors = await getColors(); 35 | const category = await getCategory(params.categoryId); 36 | 37 | return ( 38 |
    39 | 40 | 41 |
    42 |
    43 | 44 |
    45 | 46 | 47 |
    48 |
    49 | {products.length === 0 && } 50 |
    51 | {products.map((item) => ( 52 | 53 | ))} 54 |
    55 |
    56 |
    57 |
    58 |
    59 |
    60 | ); 61 | }; 62 | 63 | export default CategoryPage; 64 | -------------------------------------------------------------------------------- /components/ui/product-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { MouseEventHandler } from "react"; 5 | import { Expand, ShoppingCart } from "lucide-react"; 6 | import { useRouter } from "next/navigation"; 7 | 8 | import Currency from "@/components/ui/currency"; 9 | import IconButton from "@/components/ui/icon-button"; 10 | import usePreviewModal from "@/hooks/use-preview-modal"; 11 | import useCart from "@/hooks/use-cart"; 12 | import { Product } from "@/types"; 13 | 14 | interface ProductCard { 15 | data: Product; 16 | } 17 | 18 | const ProductCard: React.FC = ({ data }) => { 19 | const previewModal = usePreviewModal(); 20 | const cart = useCart(); 21 | const router = useRouter(); 22 | 23 | const handleClick = () => { 24 | router.push(`/product/${data?.id}`); 25 | }; 26 | 27 | const onPreview: MouseEventHandler = (event) => { 28 | event.stopPropagation(); 29 | 30 | previewModal.onOpen(data); 31 | }; 32 | 33 | const onAddToCart: MouseEventHandler = (event) => { 34 | event.stopPropagation(); 35 | 36 | cart.addItem(data); 37 | }; 38 | 39 | return ( 40 |
    44 | {/* Image & actions */} 45 |
    46 | 52 |
    53 |
    54 | } 57 | /> 58 | } 61 | /> 62 |
    63 |
    64 |
    65 | {/* Description */} 66 |
    67 |

    {data.name}

    68 |

    {data.category?.name}

    69 |
    70 | {/* Price & Reiew */} 71 |
    72 | 73 |
    74 |
    75 | ); 76 | }; 77 | 78 | export default ProductCard; 79 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/components/footer"; 2 | import "./globals.css"; 3 | import type { Metadata } from "next"; 4 | import localFont from "next/font/local"; 5 | import Navbar from "@/components/navbar"; 6 | import ModalProvider from "@/providers/modal-provider"; 7 | import ToastProvider from "@/providers/toast-provider"; 8 | 9 | 10 | const BRFrima = localFont({ 11 | src: [ 12 | { 13 | path: "../fonts/BRFirma-font/BRFirma-Light.woff2", 14 | weight: "100", 15 | style: "normal", 16 | }, 17 | { 18 | path: "../fonts/BRFirma-font/BRFirma-Regular.woff2", 19 | weight: "200", 20 | style: "normal", 21 | }, 22 | { 23 | path: "../fonts/BRFirma-font/BRFirma-Medium.woff2", 24 | weight: "300", 25 | style: "normal", 26 | }, 27 | { 28 | path: "../fonts/BRFirma-font/BRFirma-SemiBold.woff2", 29 | weight: "400", 30 | style: "normal", 31 | }, 32 | { 33 | path: "../fonts/BRFirma-font/BRFirma-Bold.woff2", 34 | weight: "500", 35 | style: "normal", 36 | }, 37 | ], 38 | }); 39 | 40 | export const metadata: Metadata = { 41 | title: { 42 | default: "Nike. Just Do It. Nike.com", 43 | template: "%s | Nike", 44 | }, 45 | keywords: [ 46 | "Nike", 47 | "Air Force 1", 48 | "Jordans", 49 | "SB Dunks", 50 | "Air Max", 51 | "New arrivals", 52 | "Dunks and Blazers", 53 | "athletics", 54 | "Nike.com", 55 | "Converse", 56 | ], 57 | description: 58 | "Nike delivers innovative products, experiences and services to inspire athletes.", 59 | applicationName: "Nike.com", 60 | authors: [{ name: "Nike" }], 61 | openGraph: { 62 | title: "Nike. Just Do It", 63 | description: 64 | "Nike delivers innovative products, experiences and services to inspire athletes.", 65 | url: "https://nike.com", 66 | siteName: "", 67 | images: [ 68 | { 69 | url: "https://c.static-nike.com/a/images/w_1920,c_limit/bzl2wmsfh7kgdkufrrjq/image.jpg", 70 | }, 71 | ], 72 | locale: "en-US", 73 | type: "website", 74 | }, 75 | twitter: { 76 | card: "summary_large_image", 77 | title: "NIke. Just Do It.", 78 | description: 79 | "Nike delivers innovative products, experiences and services to inspire athletes.", 80 | site: "@nike", 81 | creator: "@nike", 82 | images: [ 83 | "https://c.static-nike.com/a/images/w_1920,c_limit/bzl2wmsfh7kgdkufrrjq/image.jpg", 84 | ], 85 | }, 86 | category: "e-commerce", 87 | }; 88 | 89 | export default function RootLayout({ 90 | children, 91 | }: { 92 | children: React.ReactNode; 93 | }) { 94 | return ( 95 | 96 | 97 | 98 | 99 | 100 | {children} 101 |