├── .env ├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── api │ └── sepet │ │ └── route.tsx ├── arama │ ├── [collection] │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx ├── favicon.ico ├── globals.css ├── hooks │ ├── useMediaQuery.ts │ └── useOutsideClick.ts ├── kampanyalar │ └── [slug] │ │ ├── error.tsx │ │ └── page.tsx ├── layout.tsx ├── lib │ ├── actions.ts │ ├── data.ts │ ├── definitions.ts │ └── placeholder-data.js ├── not-found.tsx ├── page.tsx ├── sepet │ ├── checkout │ │ └── page.tsx │ ├── kargo │ │ └── page.tsx │ ├── layout.tsx │ ├── odeme │ │ └── page.tsx │ └── page.tsx ├── siparis-sonuc │ └── page.tsx ├── store │ └── index.tsx ├── ui │ ├── basketContent │ │ ├── basket-element.tsx │ │ ├── empty-basket.tsx │ │ └── index.tsx │ ├── campaign │ │ └── campaign-detail.tsx │ ├── checkout │ │ ├── information │ │ │ ├── basket-form.tsx │ │ │ └── index.tsx │ │ ├── layout │ │ │ └── index.tsx │ │ ├── payment │ │ │ ├── index.tsx │ │ │ └── payment-summary.tsx │ │ └── shipping │ │ │ ├── index.tsx │ │ │ ├── shipping-info.tsx │ │ │ └── shipping-method.tsx │ ├── elements │ │ ├── button.tsx │ │ ├── input.tsx │ │ └── radio-card.tsx │ ├── fonts.ts │ ├── home │ │ ├── campaign-banner.tsx │ │ ├── campaign-list.tsx │ │ ├── category-cart.tsx │ │ ├── category-list.tsx │ │ ├── collections.tsx │ │ └── product-cart.tsx │ ├── layout │ │ ├── basket-modal.tsx │ │ ├── basket.tsx │ │ ├── breadcrumbs.tsx │ │ ├── footer.tsx │ │ ├── header.tsx │ │ ├── mobile-menu-container.tsx │ │ ├── mobile-menu.tsx │ │ ├── open-basket.tsx │ │ └── search.tsx │ ├── productCard │ │ └── index.tsx │ ├── productDetail │ │ ├── index.tsx │ │ └── product-content.tsx │ ├── search │ │ ├── categories.tsx │ │ ├── category-wrapper.tsx │ │ ├── productList.tsx │ │ └── products.tsx │ ├── searchResult │ │ └── index.tsx │ ├── section-title.tsx │ └── skeletons.tsx ├── urun │ └── [name] │ │ └── page.tsx └── utils │ ├── addToCart.ts │ ├── completePayment.ts │ ├── createUrl.ts │ └── formatDate.ts ├── bun.lockb ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── campaigns │ ├── campaign-1.webp │ ├── campaign-2.webp │ └── campaign-3.webp ├── categories │ ├── electronics.png │ ├── kitchen.png │ ├── machine.png │ ├── overcoat.png │ ├── pants.png │ ├── phone-category.png │ ├── shoes-category.png │ ├── sportswear.png │ └── sweat-category.png ├── error.jpg ├── logo.png ├── logoipsum.svg ├── next.svg ├── not-found.jpg ├── products │ ├── 94.jpg │ ├── black-shoes.jpg │ ├── black_hoodie_mockup.png │ ├── cargo-pant.png │ ├── fridge.png │ ├── headphone.png │ ├── jean.png │ ├── laptop.png │ ├── pan.png │ ├── phone-two.jpg │ ├── phone.jpg │ ├── shoes.png │ ├── tracksuit.png │ └── white_hoodie.png └── vercel.svg ├── scripts └── seed.js ├── tailwind.config.ts └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | POSTGRES_URL="postgres://default:tr7TEsiCA8pu@ep-ancient-limit-95939713-pooler.us-east-1.postgres.vercel-storage.com:5432/verceldb" 2 | POSTGRES_PRISMA_URL="postgres://default:tr7TEsiCA8pu@ep-ancient-limit-95939713-pooler.us-east-1.postgres.vercel-storage.com:5432/verceldb?pgbouncer=true&connect_timeout=15" 3 | POSTGRES_URL_NON_POOLING="postgres://default:tr7TEsiCA8pu@ep-ancient-limit-95939713.us-east-1.postgres.vercel-storage.com:5432/verceldb" 4 | POSTGRES_USER="default" 5 | POSTGRES_HOST="ep-ancient-limit-95939713-pooler.us-east-1.postgres.vercel-storage.com" 6 | POSTGRES_PASSWORD="tr7TEsiCA8pu" 7 | POSTGRES_DATABASE="verceldb" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js Commerce 2 | 3 | A Next.js 14 and App Router ecommerce application 4 | 5 | ## 🔥 Featuring 6 | 7 | - Next.js App Router 8 | - Typescript 9 | - Optimized for SEO using Next.js's Metadata 10 | - Optimized for Core Web Vitals 11 | - Styling with Tailwind CSS 12 | - Optimized for Next.js + Tailwind CSS (cssnano + postcss-import) 13 | - React Server Components (RSCs) and Suspense 14 | - Server Actions for mutations 15 | - New fetching and caching paradigms 16 | - Zustand for Context Management 17 | 18 | ## 🛣️ Routing 19 | 20 | The Next.js App Router introduces a new model for building applications using React's latest features such as Server Components, Streaming with Suspense, and Server Actions. 21 | 22 | ## 📁 Project Organization and File Colocation 23 | 24 | Each folder represents a route segment that is mapped to a corresponding segment in a URL path. 25 | 26 | ``` 27 | app 28 | ├── api 29 | │ └── sepet 30 | │ └── route.tsx 31 | ├── arama 32 | │ ├── [collection] 33 | │ │ └── page.tsx 34 | │ ├── layout.tsx 35 | │ └── page.tsx 36 | ├── kampanyalar 37 | │ └── [slug] 38 | │ ├── error.tsx 39 | │ └── page.tsx 40 | ├── layout.tsx 41 | ├── not-found.tsx 42 | ├── page.tsx 43 | ├── sepet 44 | │ ├── checkout 45 | │ │ └── page.tsx 46 | │ ├── kargo 47 | │ │ └── page.tsx 48 | │ ├── layout.tsx 49 | │ ├── odeme 50 | │ │ └── page.tsx 51 | │ └── page.tsx 52 | ├── siparis-sonuc 53 | │ └── page.tsx 54 | ├── urun 55 | │ └── [name] 56 | │ └── page.tsx 57 | ``` 58 | 59 | ## ⬆️ Metadata 60 | Next.js has a Metadata API that can be used to define your application metadata for improved SEO and web shareability. 61 | #### Two ways to add metadata were used in the application: 62 | 1- Config-based Metadata: 63 | - Export a static metadata object: 64 | ```js 65 | export const metadata: Metadata = { 66 | title: "Sepet", 67 | description: "Siparişinizi hemen tamamlayın hemen teslim edelim!", 68 | }; 69 | ``` 70 | - Dynamic generateMetadata function 71 | ```js 72 | export async function generateMetadata( 73 | { params, searchParams }: Props, 74 | parent: ResolvingMetadata, 75 | ): Promise { 76 | const category = collectionsMetaData.find( 77 | (element) => element.link === params.collection, 78 | ); 79 | 80 | return { 81 | title: category?.title, 82 | description: category?.description, 83 | openGraph: { 84 | images: category?.openGraphImage, 85 | }, 86 | }; 87 | } 88 | ``` 89 | 2- File-based Metadata: 90 | - favicon file added to app directory 91 | 92 | ## ✈️ Core Web Vitals 93 | Core Web Vitals (LCP, FID, CLS) are key metrics assessing webpage loading speed, interactivity, and visual stability, crucial for user satisfaction and search rankings. Optimizing them enhances website performance and visibility. 94 | 95 | **What was done in application?** 96 | - Image Optimization for LCP 97 | - Defer offscreen images for Speed Index 98 | - Used Skeletons of components for CLS 99 | - Cssnano used for optimization Tailwind + Next.js CSS 100 | - ... 101 | 102 | **For example Lighthouse report:** 103 | 104 | report one 105 | report two 106 | 107 | ## 🚚 Server Actions and Mutations 108 | The part of the application that excited me the most was experiencing server actions. 109 | 110 | Server actions were used in the `actions.ts` file. Here, actions such as form validations with zod, writing to cookies and fetching. For example, 111 | **when form like this:** 112 | ```js 113 |
...
114 | ``` 115 | **action like this** 116 | ```js 117 | export async function basketInformationFnc( 118 | prevSate: Awaited, 119 | data: FormData, 120 | ) { 121 | const inputObject = { 122 | ...inputs, 123 | }; 124 | noStore(); 125 | // Validate form using Zod 126 | const validatedFields = basketValidation.safeParse(inputObject); 127 | 128 | if (!validatedFields.success) { 129 | return { 130 | errors: validatedFields.error.flatten().fieldErrors, 131 | message: "Missing Fields. Failed to Submit Form.", 132 | }; 133 | } 134 | 135 | try { 136 | const cookieStore = cookies(); 137 | cookieStore.set("basket_information", JSON.stringify(inputObject)); 138 | } catch (error) { 139 | return { 140 | message: "Error: Something went wrong.", 141 | }; 142 | } 143 | redirect("/sepet/kargo"); 144 | } 145 | ``` 146 | ### Data Mutations 147 | The fetching process was performed by writing SQL queries with vercel/postgres in the data.ts file. 148 | ```js 149 | export async function fetchCampaignBanners() { 150 | try { 151 | const campaigns = await sql` 152 | SELECT campaign_name, campaign_link, campaign_id, campaign_desktop_image, campaign_mobile_image 153 | FROM campaigns; 154 | `; 155 | return campaigns.rows; 156 | } catch (error) { 157 | console.error("Database Error", error); 158 | throw new Error("Failed to fetch campaigns"); 159 | } 160 | } 161 | ``` 162 | ## 🪢 Fetching in a Server Component Using a Server Action 163 | This paradigm leverages the server-side capabilities of Next.js to fetch data. Also you can see async components: 164 | 165 | ```js 166 | export default async function ProductDetail({ name }: { name: string }) { 167 | const data = await fetchProductDetail(name); 168 | 169 | return ( 170 |
171 | 172 |
173 | ); 174 | } 175 | ``` 176 | ## 📞 Get Feedback 177 | 178 | You can contact me via enesergun1515@gmail.com or through LinkedInd to provide feedback on the project. 179 | -------------------------------------------------------------------------------- /app/api/sepet/route.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers"; 2 | import { redirect } from "next/navigation"; 3 | export async function POST() { 4 | const cookieStore = cookies(); 5 | cookieStore.delete("basket_information"); 6 | cookieStore.delete("shipping_method"); 7 | 8 | return Response.json({ status: true }); 9 | } 10 | -------------------------------------------------------------------------------- /app/arama/[collection]/page.tsx: -------------------------------------------------------------------------------- 1 | import Products from "@/app/ui/search/products"; 2 | import { Metadata, ResolvingMetadata } from "next"; 3 | import { collectionsMetaData } from "@/app/lib/placeholder-data"; 4 | import { Suspense } from "react"; 5 | import { ProductsSkeleton } from "@/app/ui/skeletons"; 6 | type Props = { 7 | params: { collection: string }; 8 | searchParams: { [key: string]: string | string[] | undefined }; 9 | }; 10 | export async function generateMetadata( 11 | { params, searchParams }: Props, 12 | parent: ResolvingMetadata, 13 | ): Promise { 14 | const category = collectionsMetaData.find( 15 | (element) => element.link === params.collection, 16 | ); 17 | 18 | return { 19 | title: category?.title, 20 | description: category?.description, 21 | openGraph: { 22 | images: category?.openGraphImage, 23 | }, 24 | }; 25 | } 26 | export default function Page({ params }: { params: { collection: string } }) { 27 | return ( 28 | }> 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/arama/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Categories from "@/app/ui/search/category-wrapper"; 3 | export default function Layout({ 4 | params, 5 | children, 6 | }: { 7 | params: { slug: string }; 8 | children: React.ReactNode; 9 | }) { 10 | return ( 11 | <> 12 | 13 | {children} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/arama/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import SearchResult from "@/app/ui/searchResult"; 3 | import { SearchParamsProps } from "@/app/lib/definitions"; 4 | export const metadata: Metadata = { 5 | title: "Arama Sonuçları", 6 | description: "Arama Sonuçlarını burada bulabilirsiniz.", 7 | }; 8 | export default function Page({ searchParams }: Readonly) { 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enesergun/nextjs-commerce-app-router/5da4bbb6db48c0c7fc38bb060948947b661a98e7/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | * { 20 | padding: 0; 21 | margin: 0; 22 | } 23 | body { 24 | position: relative; 25 | } 26 | 27 | @layer utilities { 28 | /* Hide scrollbar for Chrome, Safari and Opera */ 29 | .no-scrollbar::-webkit-scrollbar { 30 | display: none; 31 | } 32 | /* Hide scrollbar for IE, Edge and Firefox */ 33 | .no-scrollbar { 34 | -ms-overflow-style: none; /* IE and Edge */ 35 | scrollbar-width: none; /* Firefox */ 36 | } 37 | } 38 | 39 | .swiper { 40 | width: 100%; 41 | height: 100%; 42 | } 43 | 44 | .swiper-slide { 45 | text-align: center; 46 | font-size: 18px; 47 | background: #fff; 48 | 49 | /* Center slide text vertically */ 50 | display: flex; 51 | justify-content: center; 52 | align-items: center; 53 | } 54 | 55 | .swiper-slide img { 56 | /* display: block; */ 57 | width: 100%; 58 | height: 100%; 59 | object-fit: cover; 60 | } 61 | .swiper-button-prev, 62 | .swiper-button-next { 63 | display: none !important; 64 | @media (min-width: 640px) { 65 | display: flex !important; 66 | } 67 | } 68 | .swiper-button-prev, 69 | .swiper-button-next { 70 | color: black !important; 71 | background: white; 72 | padding: 20px; 73 | } 74 | .swiper-button-next::after { 75 | content: ">" !important; 76 | } 77 | .swiper-button-prev::after { 78 | content: "<" !important; 79 | } 80 | .swiper-button-prev::after, 81 | .swiper-button-next::after { 82 | font-size: 25px !important; 83 | } 84 | .swiper-button-prev { 85 | left: 25px !important; 86 | } 87 | .swiper-button-next { 88 | right: 25px !important; 89 | } 90 | -------------------------------------------------------------------------------- /app/hooks/useMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export function useMediaQuery(query: string) { 4 | const [matches, setMatches] = useState(false); 5 | 6 | useEffect(() => { 7 | const media = window.matchMedia(query); 8 | if (media.matches !== matches) { 9 | setMatches(media.matches); 10 | } 11 | const listener = () => { 12 | setMatches(media.matches); 13 | }; 14 | media.addEventListener("change", listener); 15 | return () => media.removeEventListener("change", listener); 16 | }, [matches, query]); 17 | 18 | return matches; 19 | } 20 | -------------------------------------------------------------------------------- /app/hooks/useOutsideClick.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export const useOutsideClick = (callback: () => void) => { 4 | const ref = useRef(null); 5 | 6 | useEffect(() => { 7 | const handleClickOutside = (event: MouseEvent) => { 8 | if (ref.current && !ref.current.contains(event.target as Node)) { 9 | callback(); 10 | } 11 | }; 12 | 13 | document.addEventListener("mousedown", handleClickOutside); 14 | 15 | return () => { 16 | document.removeEventListener("mousedown", handleClickOutside); 17 | }; 18 | }, [callback]); 19 | 20 | return ref; 21 | }; 22 | -------------------------------------------------------------------------------- /app/kampanyalar/[slug]/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; // Error components must be Client Components 2 | 3 | import Button from "@/app/ui/elements/button"; 4 | import Image from "next/image"; 5 | import { useEffect } from "react"; 6 | 7 | export default function Error({ 8 | error, 9 | reset, 10 | }: { 11 | error: Error & { digest?: string }; 12 | reset: () => void; 13 | }) { 14 | useEffect(() => { 15 | // Log the error to an error reporting service 16 | console.error(error); 17 | }, [error]); 18 | 19 | return ( 20 |
21 | Error Image 22 |

23 | Birtakım hatalar! 24 |

25 |
26 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/kampanyalar/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import CampaignDetail from "@/app/ui/campaign/campaign-detail"; 2 | import type { Metadata } from "next"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Kampanya Detay", 6 | description: "Kampanya detaylarına ulaşmak için linke tıkla!", 7 | }; 8 | export default function Page({ params }: { params: { slug: string } }) { 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { lato } from "@/app/ui/fonts"; 3 | import "./globals.css"; 4 | import Header from "@/app/ui/layout/header"; 5 | import Footer from "@/app/ui/layout/footer"; 6 | import clsx from "clsx"; 7 | 8 | export default function RootLayout({ 9 | children, 10 | }: { 11 | children: React.ReactNode; 12 | }) { 13 | return ( 14 | 15 | 16 |
17 |
{children}
18 |