├── .gitignore ├── 1.Nextjs-la-gi.md ├── 10.Co-che-rendering.md ├── 11.Client-component.md ├── 12.Server-component.md ├── 13.Nextjs-render-component-cua-ban-nhu-the-nao.md ├── 18.Nguyen-tac-thiet-ke-auth-trong-nextjs.md ├── 2.Moi-truong-code-nextjs.md ├── 6.CSS-trong-nextjs.md ├── README.md ├── client ├── .env ├── .eslintrc.json ├── .gitignore ├── README.md ├── components.json ├── ecosystem.config.js ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── images │ │ └── suffer.png │ ├── next.svg │ └── vercel.svg ├── src │ ├── apiRequests │ │ ├── account.ts │ │ ├── auth.ts │ │ └── product.ts │ ├── app │ │ ├── (auth) │ │ │ ├── layout.tsx │ │ │ ├── login │ │ │ │ ├── login-form.tsx │ │ │ │ └── page.tsx │ │ │ ├── logout │ │ │ │ └── page.tsx │ │ │ └── register │ │ │ │ ├── page.tsx │ │ │ │ └── register-form.tsx │ │ ├── Roboto-Regular.ttf │ │ ├── Roboto-Thin.ttf │ │ ├── api │ │ │ └── auth │ │ │ │ ├── logout │ │ │ │ └── route.ts │ │ │ │ ├── route.ts │ │ │ │ └── slide-session │ │ │ │ └── route.ts │ │ ├── app-provider.tsx │ │ ├── apple-icon.png │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── icon.png │ │ ├── layout.tsx │ │ ├── me │ │ │ ├── page.tsx │ │ │ ├── profile-form.tsx │ │ │ └── profile.tsx │ │ ├── page.tsx │ │ ├── products │ │ │ ├── [id] │ │ │ │ ├── edit │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── _components │ │ │ │ ├── delete-product.tsx │ │ │ │ ├── product-add-button.tsx │ │ │ │ ├── product-add-form.tsx │ │ │ │ └── product-edit-button.tsx │ │ │ ├── add │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── robots.ts │ │ └── shared-metadata.ts │ ├── components │ │ ├── button-logout.tsx │ │ ├── header.tsx │ │ ├── mode-toggle.tsx │ │ ├── slide-session.tsx │ │ ├── theme-provider.tsx │ │ └── ui │ │ │ ├── alert-dialog.tsx │ │ │ ├── button.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ └── use-toast.ts │ ├── config.ts │ ├── lib │ │ ├── data.ts │ │ ├── http.ts │ │ └── utils.ts │ ├── middleware.ts │ └── schemaValidations │ │ ├── account.schema.ts │ │ ├── auth.schema.ts │ │ ├── common.schema.ts │ │ └── product.schema.ts ├── tailwind.config.ts └── tsconfig.json ├── note.md └── server ├── .editorconfig ├── .env ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierignore ├── .prettierrc ├── NextJs-Free-API.postman_collection.json ├── Readme.md ├── ecosystem.config.js ├── nodemon.json ├── package-lock.json ├── package.json ├── prisma ├── dev.db └── schema.prisma ├── src ├── config.ts ├── constants │ ├── error-reference.ts │ └── type.ts ├── controllers │ ├── account.controller.ts │ ├── auth.controller.ts │ ├── media.controller.ts │ └── product.controller.ts ├── database │ └── index.ts ├── hooks │ └── auth.hooks.ts ├── index.ts ├── plugins │ ├── auth.plugins.ts │ ├── errorHandler.plugins.ts │ └── validatorCompiler.plugins.ts ├── routes │ ├── account.route.ts │ ├── auth.route.ts │ ├── media.route.ts │ ├── product.route.ts │ ├── static.route.ts │ └── test.route.ts ├── schemaValidations │ ├── account.schema.ts │ ├── auth.schema.ts │ ├── common.schema.ts │ └── product.schema.ts ├── type.d.ts ├── types │ └── jwt.types.ts └── utils │ ├── crypto.ts │ ├── errors.ts │ ├── helpers.ts │ └── jwt.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | on-tap/ 2 | .DS_Store 3 | **/.DS_Store -------------------------------------------------------------------------------- /1.Nextjs-la-gi.md: -------------------------------------------------------------------------------- 1 | # Giới thiệu về Next.js 2 | 3 | ## 1. Next.js là gì? 4 | 5 | - Next.js là fullstack framework cho React.js được tạo ra bởi Vercel (trước đây là ZEIT). 6 | - Next có thể làm server như Express.js bên Node.js và có thể làm client như React.js 7 | 8 | ## 2. Next.js giải quyết vấn đề gì? 9 | 10 | ### Đầu tiên là render website ở Server nên thân thiện với SEO 11 | 12 | React.js thuần chỉ là client side rendering, nhanh thì cũng có nhanh nhưng không tốt cho SEO. Ai nói với bạn rằng sài React.js thuần vẫn lên được top google ở nhiều thì đó là lừa đảo (hoặc họ chỉ đang nói 1 nữa sự thật) 13 | 14 | Next.js hỗ trợ server side rendering, nghĩa là khi người dùng request lên server thì server sẽ render ra html rồi trả về cho người dùng. Điều này giúp cho SEO tốt hơn. 15 | 16 | ### Tích hợp nhiều tool mà React.js thuần không có 17 | 18 | - Tối ưu image, font, script 19 | - CSS module 20 | - Routing 21 | - Middleware 22 | - Server Action 23 | - SEO ... 24 | 25 | ### Thống nhất về cách viết code 26 | 27 | Ở React.js, có quá nhiều cách viết code và không có quy chuẩn. 28 | 29 | Ví dụ: 30 | 31 | - Routing có thể dùng React Router Dom hoặc TanStack Router. 32 | - Nhiều cách bố trí thư mục khác nhau 33 | 34 | Dẫn đến sự không đồng đều khi làm việc nhóm và khó bảo trì. 35 | 36 | Next.js giúp bạn thống nhất về cách viết code theo chuẩn của họ => giải quyết phần nào đó các vấn đề trên 37 | 38 | ### Đem tiền về cho Vercel 🙃 39 | 40 | Ngày xưa các website thường đi theo hướng Server Side Rendering kiểu Multi Page Application (MPA) như PHP, Ruby on Rails, Django, Express.js ... Ưu điểm là web load nhanh và SEO tốt, nhưng nhược điểm là UX hay bị chớp chớp khi chuyển trang và khó làm các logic phức tạp bên client. 41 | 42 | Sau đó React.js, Angular, Vue ra đời, đi theo hướng Single Page Application (SPA) giải quyết được nhược điểm của MPA, nhưng lại tạo ra nhược điểm mới là SEO kém và load chậm ở lần đầu. 43 | 44 | Vercel là công ty cung cấp các dịch vụ phía Server như hosting website, serverless function, database, ...và họ cũng là công ty đầu tiên khởi xướng trào lưu "quay trở về Server Side Rendering" . 45 | 46 | Vì thế họ tạo ra Next.js, vừa để khắc phục nhược điểm của SPA truyền thống, vừa gián tiếp bán các sản phẩm dịch vụ của họ. Ví dụ Next.js chạy trên dịch vụ Edge Runtime của họ sẽ có độ trễ thấp hơn so với chạy trên Node.js 47 | 48 | ## 3. Yêu cầu khi học Next.js 49 | 50 | - Cần biết HTML, CSS, JavaScript 51 | - Cần biết React.js cơ bản (Thao khảo khóa học [React.js Super](https://duthanhduoc.com/courses/react)) 52 | - Cần biết Node.js cơ bản(Thao khảo khóa học [Node.js Super](https://duthanhduoc.com/courses/nodejs-super)) 53 | 54 | ## FAQ 55 | 56 | 1. Có nên dùng Next.js làm Backend luôn không? 57 | 58 | Nếu bạn cần làm 1 dự án nhỏ cỡ 1-5 người làm, thời gian triển khai nhanh, không yêu cầu nhiều nghiệp vụ phức tạp thì có thể dùng Next.js làm fullstack framework luôn 59 | 60 | Còn lại thì chỉ nên dùng Next.js làm Front-End thôi. Vì backend Next.js sẽ thiếu nhiều tính năng hơn khi so sánh với các framework chuyên backend khác. Chưa hết, dùng Next.js làm backend bạn sẽ dính vào hệ sinh thái Node.js 61 | 62 | 2. Làm website quản lý không cần SEO thì có nên dùng Next.js không? 63 | 64 | Không cần thiết, có thể dùng React.js Vite truyền thống. 65 | 66 | Nếu bạn sợ trong tương lai có làm mấy cái landing page hay trang public ra ngoài thì chọn Next.js là lựa chọn an toàn. 67 | 68 | 3. Next.js có phù hợp với dự án lớn không? 69 | 70 | Có. Rất nhiều dự án lớn dùng Next.js như Tiktok, Netflix, Uber, ... 71 | 72 | 4. Next.js deploy ở đâu? 73 | 74 | Nên deploy trên VPS (tức là máy chủ ảo) 75 | 76 | Ngoài ra có thể deploy trên Vercel, Netlify. Nếu free thì chậm (phù hợp demo), còn trả phí thì khá là đắt. 77 | 78 | 5. Khóa học này dạy App Router hay Page Router? 79 | 80 | App Router, vì nó đã ra đời hơn 1 năm rồi và ổn định. Nó là tương lai của Next.js 81 | -------------------------------------------------------------------------------- /10.Co-che-rendering.md: -------------------------------------------------------------------------------- 1 | # Cơ chế rendering 2 | 3 | Có 2 môi trường mà web chúng ta có thể render 4 | 5 | Client: đại diện trình duyệt người dùng 6 | Server: đại diện cho máy chủ nơi chứa data và trả về response 7 | 8 | Client và Server là 2 môi trường tách biệt với nhau. Đây gọi là **Network Boundary** 9 | 10 | Vì next.js có khả năng render code React ở server và client nên đôi khi dev hiểu nhầm rằng 2 môi trường là một. 11 | 12 | Với Next.js, code lúc nào cũng phải phân biệt rõ ràng giữa 2 môi trường này bằng từ khóa `'use client'` hoặc `'use server'` 13 | 14 | Ví dụ đang ở môi trường client, muốn truy cập data ở server thì cần phải gửi 1 request mới đến server mới lấy được. 15 | -------------------------------------------------------------------------------- /11.Client-component.md: -------------------------------------------------------------------------------- 1 | # Client component 2 | 3 | ## React SPA truyền thống (React Vite, CRA, ...) là 1 client component khổng lồ 4 | 5 | Khi lần đầu vào 1 trang web 6 | 7 | 1. Trình duyệt **request** đến server và trả về file `index.html` cơ bản (hầu như không chứa html gì nhiều) 8 | 2. Trình duyệt nhận thấy trong file html có link đến file js, css nên là **request lần nữa** đến server để lấy file js, css 9 | 3. Trình duyệt tiến hành chạy code JS để render ra HTML và gắn sự kiện vào HTML đó 10 | 4. Người dùng thấy và tương tác được với trang web 11 | 12 | Trong quá trình này, web sẽ trắng xóa cho đến khi bước thứ 3 được hoàn thành. 13 | 14 | Vậy nên mới nói lần đầu tiên khi truy cập vào các SPA truyền thống khá lâu, nhưng sau đó thì thao tác hay chuyển trang sẽ rất nhanh vì js bundle cả app đã có ở client rồi, nếu cần data thì mới request đến server lấy data thôi. 15 | 16 | Các bạn để ý cái bước thứ 3, lúc nào HTML cũng được JavaScript trình duyệt render ra khi chúng ta truy cập vào web. Cái này gọi là **Dynamic Rendering** 17 | 18 | Với Dynamic Rendering, HTML được render ra khi chúng ta request, có thể được render ở client hoặc server đều được. 19 | 20 | ## Client Component Next.js 21 | 22 | Dùng client component khi: 23 | 24 | - Cần tương tác: dùng hook, useState, useEffect, event listener (onClick, onSubmit, onChange,...), ... 25 | - Cần dùng các API từ trình duyệt 26 | 27 | Trong Next.js, mặc định tất cả các component đều được render ra HTML sẵn khi có thể lúc Nextjs build (Static Rendering). Kể cả Server component và Client component. 28 | 29 | Vậy nên khi bạn truy cập vào 1 trang web Next.js, bạn sẽ thấy UI ngay lập tức do Server Next.js trả về HTML đã render sẵn. Sau đó trình duyệt sẽ render lại CLient Component 1 lần nữa để đồng bộ DOM, sự kiện, state, effect. 30 | 31 | Rút ra được điều gì từ đây? 32 | 33 | - Client Component bị render tối thiểu 2 lần: 1 lần khi build, 1+ lần ở client 34 | - Vì trả về HTML sẵn nên người dùng có thể thấy content ngay lập tức (Tăng UX) 35 | - Dù thấy content ngay lập tức nhưng vẫn không thể tương tác ngay được vì cần phải chờ trình duyệt đồng bộ lại client component (render, gắn sự kiện, state, effect...) 36 | 37 | Ưu điểm của Client Component: 38 | 39 | - Giảm gánh nặng cho server khi component nặng và phức tạp về logic => Server yếu thì nên dùng 40 | 41 | Nhược điểm của Client Component: 42 | 43 | - SEO không tốt 44 | - Thiết bị client yếu thì chạy không nổi 45 | - Tăng bundle size javascript 46 | 47 | Lời khuyên từ cá nhân Được: 48 | 49 | Dùng Server Component khi có thể, Được không đặt nặng vấn đề về cấu hình Server, vì dùng cho production thì server phải tốt. Quan trọng là trải nghiệm người dùng 50 | -------------------------------------------------------------------------------- /12.Server-component.md: -------------------------------------------------------------------------------- 1 | # Server Component 2 | 3 | Đây là chế độ mặc định của component trong Next.js 4 | 5 | Ưu điểm: 6 | 7 | - Fetch data ở server => Nơi gần data center nên là sẽ nhanh hơn là fetch ở client => Giảm thiểu thời gian rendering, tăng UX 8 | - Bảo mật: Server cho phép giữ các data nhạy cảm, logic đặc biệt không muốn public ở client 9 | - Caching: Vì được render ở server nên có thể lưu giữ cache cho nhiều người dùng khác nhau => Không cần render trên mỗi request 10 | - Bundle Size: Giảm thiểu JS bundle size vì client không cần tải về phần JS logic để render HTML 11 | - Load trang lần đầu nhanh và chỉ số FCP (First Contentful Paint) thấp do người dùng sẽ thấy content ngay lập tức 12 | - Search Engine Optimization and Social Network Shareability 13 | - Streaming 14 | 15 | => Ưu tiên dùng Server Component khi có thể 16 | -------------------------------------------------------------------------------- /13.Nextjs-render-component-cua-ban-nhu-the-nao.md: -------------------------------------------------------------------------------- 1 | # Next.js render component của bạn như thế nào? 2 | 3 | Component ở đây bao gồm Server Component và Client Component 4 | 5 | ## Khi chúng ta build 6 | 7 | Mọi component dù là Server Component hay Client Component khi build đều sẽ có 8 | 9 | - Static HTML 10 | - JS Bundle 11 | - Ngoài ra còn có CSS Bundle, Image, Font,... 12 | 13 | ## Khi request lần đầu tiên (full page load) 14 | 15 | 1. Server Next.Js render server component và kết hợp với Client Component để tạo ra HTML để gửi về client 16 | 17 | 2. Client ngay lập tức thấy được website nhưng chưa tương tác được với nó (ví dụ chưa click, hover,...) 18 | 19 | 3. Trong đống JS Bundle download về có chứa **React Server Component Payload (RSC Payload)**, cái này dùng để để render lại client component ở client, cập nhật DOM 20 | 21 | 4. Cuối cùng là sẽ thêm các sự kiện vào các client component để tương tác với người dùng => Bước này gọi là Hydration, sau bước này thì có thể tương tác với website 22 | 23 | > React Server Component Payload là 1 data đặc biệt được render ở phía Server phục vụ cho việc đồng bộ, cập nhật DOM giữa Client Component và Server Component 24 | 25 | ## Khi request lần thứ 2 (Subsequent Navigations) 26 | 27 | Ví dụ chúng ta navigate từ `/home` sang `/about` 28 | 29 | Thì server Next.js sẽ không trả HTML về cho chúng ta nữa mà trả React Server Component Payload (RSC Payload) và các bundle JS, CSS cần thiết. 30 | 31 | Client sẽ tự render ra HTML 32 | 33 | Điều này sẽ giúp việc navigation nhanh hơn, nhưng vẫn đảm bảo về SEO 34 | -------------------------------------------------------------------------------- /18.Nguyen-tac-thiet-ke-auth-trong-nextjs.md: -------------------------------------------------------------------------------- 1 | # Quản lý Auth trong Next.js 2 | 3 | Để xác thực một request thì backend thường sẽ xác thực qua 2 cách: 4 | 5 | 1. FE gửi token qua header của request như `Authorization: Bearer ` (token thường được lưu trong localStorage của trình duyệt) 6 | 2. FE gửi token qua cookie của request (sự thật là cookie cũng nằm trong header của request) 7 | 8 | Cách dùng Cookie có ưu điểm là an toàn hơn 1 chút so với cách dùng localStorage, nhưng đòi hỏi setup giữa Backend và FrontEnd phức tạp hơn. 9 | 10 | Next.js chúng ta có thể dùng 2 cách trên, nhưng nó phức tạp hơn so với React.Js Client Side Rendering (CSR) truyền thống vì Next.js có cả Server và Client 11 | 12 | ## Cách 1: Dùng localStorage 13 | 14 | Cách này chỉ áp dụng cho server check authentication dựa vào header `Authorization` của request. 15 | 16 | - Tại trang login, chúng ta gọi api `/api/login` để đăng nhập. Nếu đăng nhập thành công, server sẽ trả về token, chúng ta lưu token vào localStorage. Việc này chúng ta sẽ làm ở phía client hoàn toàn. 17 | 18 | - Tại những trang không cần authenticated, chúng ta có thể gọi api ở cả server và client của next.js mà không cần phải làm gì thêm. 19 | 20 | Vấn đề sẽ nằm ở những trang cần authenticated. Làm sao để Next.js biết được user đã authenticated hay chưa? Để giải quyết vấn đề này chúng ta cần thiết kế một middleware 21 | 22 | ### Middleware ở Next.js 23 | 24 | Middleware ở Next.js thì có 2 loại: 25 | 26 | 1. Middleware hoạt động ở client next (giống như những gì chúng ta đã làm trước đây ở React.js truyền thống) 27 | 2. Middleware hoạt động ở server next 28 | 29 | #### Middleware ở client next 30 | 31 | Nếu dùng middleware client thì chỉ cần tạo 1 `use client` `AuthenticatedComponent` và wrap nó ở những trang cần authenticated. 32 | 33 | ```tsx 34 | 'use client' 35 | export default function AuthenticatedComponent({ children }) { 36 | const token = localStorage.getItem('token') 37 | if (!token) return
Chưa đăng nhập
38 | return children 39 | } 40 | ``` 41 | 42 | Cách dùng middleware này là server next.js sẽ không biết được user đã authenticated hay chưa. Ví dụ bạn truy cập vào trang `/profile` (cần authenticated) thì đây là flow diễn ra 43 | 44 | Bạn enter url `/profile` 45 | => Trình duyệt gửi request đến server Next.js (request này sẽ gửi kèm cookie nếu có) 46 | => Server Next.js sẽ render trang `/profile` vì không biết được user đã authenticated hay chưa và trả về trình duyệt 47 | => Trình duyệt nhận được trang `/profile` và chạy `use client` `AuthenticatedComponent` 48 | => `AuthenticatedComponent` sẽ kiểm tra xem có token trong localStorage không, nếu có thì render trang `/profile` ra, nếu không thì render ra `Chưa đăng nhập` 49 | 50 | Kết quả vẫn đúng, người dùng vẫn thấy trang `/profile` nếu đã authenticated nhưng cách này có một số khuyết điểm 51 | 52 | - Profile Component phải là một client nếu chúng ta cần fetch các api cần authenticated, vì chỉ có client mới có thể truy cập được vào localStorage 53 | 54 | - Không đồng nhất giữa server và client, điều này không tốt. 55 | 56 | Cách giải quyết là dùng middleware ở server next.js 57 | 58 | #### Middleware ở server next 59 | 60 | Next.js cung cấp 1 cách để chúng ta có thể dùng middleware ở server next.js, có thể xem [tại đây](https://nextjs.org/docs/app/building-your-application/routing/middleware) 61 | 62 | Middleware này sẽ chạy ngay khi có request gửi đến server Next.js, trước khi trang được render ở server. 63 | 64 | Nhưng chúng ta cần 1 thứ gì đó để Next.js biết được user đã authenticated hay chưa, và thứ đó là chỉ có thể là cookie từ trình duyệt gửi lên. Vì khi bạn enter url `/profile` thì chỉ có cookie là được gửi kèm theo request đến server Next.js. 65 | 66 | Nãy giờ chưa setup cookie gì cả, bây giờ chúng ta sẽ setup logic cookie. Đó là khi chúng ta login thành công thì chúng ta sẽ set cookie là `isLogged=true` vào trình duyệt ở client luôn. Cookie này có thời hạn tương tự với token, và cookie `isLogged` có thể dùng JavaScript can thiệp được. Như vậy thì khi request đến server Next.js thì server sẽ biết được user đã authenticated hay chưa dựa vào cookie `isLogged`. Client next.js cũng sẽ biết được user đã authenticated hay chưa dựa vào cookie `isLogged` (hoặc giá trị lưu trong localStorage tùy thích, nhưng khuyến khích dùng `isLogged` từ cookie cho đồng bộ). 67 | 68 | Và đây là middleware ở server next.js 69 | 70 | ```tsx 71 | export const config = { 72 | matcher: ['/profile'] 73 | } 74 | export function middleware(request: NextRequest) { 75 | const isLogged = 76 | (request.cookies.get('isLogged')?.value as string | undefined) === 'true' 77 | if (!isLogged) return new Response('Chưa đăng nhập', { status: 401 }) 78 | } 79 | ``` 80 | 81 | Ưu điểm cách này là đồng bộ được giữa server và client. 82 | 83 | ### Gọi api trong next.js 84 | 85 | Xong phần middleware cho localStorage, giờ chúng ta sẽ tìm hiểu cách gọi api trong next.js 86 | 87 | Gọi API thì cũng có 2 cách là gọi ở client và gọi ở server. Ở đây mình chỉ bàn về việc gọi các API cần authenticated, vì những API không cần authenticated thì gọi ở cả client và server đều được. 88 | 89 | Nếu gọi API cần authenticated như GET `/api/profile` thì chúng ta chỉ cần gán token vào header `Authorization` là xong. Y hệt như gọi API ở React.js truyền thống. 90 | 91 | Còn gọi API cần authenticated ở server next.js thì làm thế nào để gán được token vào header `Authorization`, vì ở server Next.js, bạn không thể truy cập vào được localStorage của trình duyệt. 92 | 93 | Thực sự đây chính là khuyết điểm của việc dùng localStorage để Authentication với Next.js. 94 | 95 | Dù sao thì các route cần authenticated cũng không cần SEO nên không cần gọi ở server để SEO làm gì cả. Bạn hoàn toàn có thể gọi api ở client, nếu bạn chấp nhận điều này thì không sao cả. 96 | 97 | Nhưng với cá nhân mình là người cầu toàn thì không thích khuyết điểm này lắm, chưa kể là Next.js với tôn chỉ là ưu tiên mọi thứ ở server. 98 | 99 | Để giải quyết điều này thì chúng ta không nên dùng LocalSoage mà nên dùng Cookie để lưu token nhé. Đi đến cách 2 nào. 100 | 101 | ## Cách 2: Dùng Cookie 102 | 103 | Cách này áp dụng cho Server check token dựa vào cookie hay header `Authorization` đều được. 104 | 105 | Tại trang login chúng ta gọi api là `/app/login` từ Server Action để đăng nhập. Chúng ta dùng Server Action để làm proxy, trong server action, khi login thành công, chúng ta sẽ set cookie `token` vào trình duyệt và trả về token cho client để client set vào Context API hoặc caching react tùy thích (phục vụ nếu cần gọi api ở client). 106 | -------------------------------------------------------------------------------- /2.Moi-truong-code-nextjs.md: -------------------------------------------------------------------------------- 1 | # Setup môi trường 2 | 3 | ## Bắt buộc 4 | 5 | - Cài Node.js (ưu tiên dùng NVM để dễ dàng chuyển đổi version): > 18.17 6 | - Hệ điều hành: Windows, MacOS, Linux đều được 7 | - Cài Git để quản lý source code 8 | 9 | 2 cái trên thì ai học React.js hay Node.js cũng có rồi, mình chỉ nhắc lại 10 | 11 | ## Tùy chọn 12 | 13 | Đây là setup máy mình, bạn có thể tham khảo và tùy chỉnh theo ý 14 | 15 | - Trình duyệt Chrome 16 | - IDE: Visual Studio Code với theme Dracular và font Cascadia Code tích hợp ligature, mua gói Copilot nếu có điều kiện 17 | - Setup VS Code: [Cách mình setup VS Code | Extensions, Themes, Setting, Tips và Tricks](https://duthanhduoc.com/blog/cach-minh-setup-vs-code) 18 | - Setup Macbook: [Cách mình setup Macbook để code](https://duthanhduoc.com/blog/cach-minh-setup-macbook-de-code) 19 | -------------------------------------------------------------------------------- /6.CSS-trong-nextjs.md: -------------------------------------------------------------------------------- 1 | # 6 | CSS trong Next.js 2 | 3 | ## Global style 4 | 5 | Khi cần thêm CSS cho cả app: Ví dụ các thẻ cơ bản `body, html, a, p, h1, h2, h3, h4, h5, h6, ...` hay `*`, hoặc đôi khi cần thêm một số class để dùng toàn app thì cũng có thể thêm ở đây 6 | 7 | - CSS ở file `src/app/globals.css` 8 | - Nếu dùng tailwind thì nên dùng `@layer` để đảm bảo về tính dễ đọc cũng như là độ ưu tiên css khi build 9 | 10 | > Lưu ý rằng file này chỉ import 1 lần duy nhất trong toàn app 11 | 12 | ## Tạo 1 class css phức tạp mà tailwind không hỗ trợ hoặc override 1 class thư viện nào đó 13 | 14 | - Dùng CSS Module để đảm bảo không bị xung đột với class css khác 15 | 16 | ## Khi cần toggle class hoặc css động 17 | 18 | - Dùng `clsx` 19 | 20 | ## Khác 21 | 22 | Ngoài ra còn 1 số giải pháp khác như styled component, emotion, styled-jsx,... Nhưng ở trên là đủ dùng và best practice cho 1 app Next.js thông thường 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nguồn học Next.js 14 miễn phí 2 | 3 | > Tặng các bạn Voucher giảm giá 100k khi mua khóa học tại edu.duthanhduoc.com: `YOUTUBE` 4 | 5 | Đây là khóa học miễn phí, mình còn có một khóa có phí nữa, nếu các bạn thấy cách dạy của mình hay và phù hợp thì có thể mua khóa Next.Js nâng cao của mình. 6 | 7 | Hoặc đơn giản là muốn ủng hộ mình thì có thể mua cũng được 😂 8 | 9 | Để nhận link github và thông báo mới khi mình có khóa Next.js trả phí submit github tại [đây](https://duthanhduoc.com/courses/nextjs-super) 10 | 11 | ## Giới thiệu 12 | 13 | - Dự án: Shop bán hàng đơn giản 14 | - Công nghệ: Backend Fastify và FrontEnd Next.js 15 | 16 | Chức năng: 17 | 18 | - Đăng ký, đăng nhập 19 | - Thêm sửa xóa sản phẩm 20 | - Xem sản phẩm 21 | - SEO cơ bản 22 | - Quản lý authentication 23 | 24 | ## Tại sao chức năng lại đơn giản vậy? 25 | 26 | Chức năng phức tạp thì nó cũng chỉ xoay quanh React.Js thôi. Còn Next.js cũng chỉ quanh đi quẩn lại React.Js nên mình sẽ xoay quanh cái framework này chứ không phải xoay quanh chức năng business. 27 | -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_ENDPOINT = http://localhost:4000 2 | NEXT_PUBLIC_URL = https://productic.com -------------------------------------------------------------------------------- /client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /client/.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 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /client/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /client/ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'NextJs', 5 | script: 'PORT=3002 npm run start' 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /client/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: 'http', 7 | hostname: 'localhost', 8 | port: '4000' 9 | // pathname: '/photos/**' 10 | } 11 | ] 12 | }, 13 | logging: { 14 | fetches: { 15 | fullUrl: true 16 | } 17 | } 18 | } 19 | 20 | export default nextConfig 21 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@hookform/resolvers": "^3.9.1", 13 | "@radix-ui/react-alert-dialog": "^1.1.2", 14 | "@radix-ui/react-dropdown-menu": "^2.1.2", 15 | "@radix-ui/react-icons": "^1.3.2", 16 | "@radix-ui/react-label": "^2.1.0", 17 | "@radix-ui/react-slot": "^1.1.0", 18 | "@radix-ui/react-toast": "^1.2.2", 19 | "@types/jsonwebtoken": "^9.0.7", 20 | "class-variance-authority": "^0.7.0", 21 | "clsx": "^2.1.1", 22 | "date-fns": "^4.1.0", 23 | "jsonwebtoken": "^9.0.2", 24 | "lucide-react": "^0.460.0", 25 | "next": "15.0.3", 26 | "next-themes": "^0.4.3", 27 | "react": "19.0.0-rc-66855b96-20241106", 28 | "react-dom": "19.0.0-rc-66855b96-20241106", 29 | "react-hook-form": "^7.53.2", 30 | "sass": "^1.81.0", 31 | "tailwind-merge": "^2.5.4", 32 | "tailwindcss-animate": "^1.0.7", 33 | "zod": "^3.23.8" 34 | }, 35 | "devDependencies": { 36 | "@types/node": "^20", 37 | "@types/react": "^18", 38 | "@types/react-dom": "^18", 39 | "autoprefixer": "^10.4.20", 40 | "eslint": "^8", 41 | "eslint-config-next": "15.0.3", 42 | "postcss": "^8", 43 | "tailwindcss": "^3.4.15", 44 | "typescript": "^5" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /client/public/images/suffer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duthanhduoc/nextjs-free/5309be5d166f5be4d918e8b99316723c160def26/client/public/images/suffer.png -------------------------------------------------------------------------------- /client/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/apiRequests/account.ts: -------------------------------------------------------------------------------- 1 | import http from '@/lib/http' 2 | import { 3 | AccountResType, 4 | UpdateMeBodyType 5 | } from '@/schemaValidations/account.schema' 6 | 7 | const accountApiRequest = { 8 | me: (sessionToken: string) => 9 | http.get('account/me', { 10 | headers: { 11 | Authorization: `Bearer ${sessionToken}` 12 | } 13 | }), 14 | meClient: () => http.get('account/me'), 15 | updateMe: (body: UpdateMeBodyType) => 16 | http.put('account/me', body) 17 | } 18 | 19 | export default accountApiRequest 20 | -------------------------------------------------------------------------------- /client/src/apiRequests/auth.ts: -------------------------------------------------------------------------------- 1 | import http from '@/lib/http' 2 | import { 3 | LoginBodyType, 4 | LoginResType, 5 | RegisterBodyType, 6 | RegisterResType, 7 | SlideSessionResType 8 | } from '@/schemaValidations/auth.schema' 9 | import { MessageResType } from '@/schemaValidations/common.schema' 10 | 11 | const authApiRequest = { 12 | login: (body: LoginBodyType) => http.post('/auth/login', body), 13 | register: (body: RegisterBodyType) => 14 | http.post('/auth/register', body), 15 | auth: (body: { sessionToken: string; expiresAt: string }) => 16 | http.post('/api/auth', body, { 17 | baseUrl: '' 18 | }), 19 | logoutFromNextServerToServer: (sessionToken: string) => 20 | http.post( 21 | '/auth/logout', 22 | {}, 23 | { 24 | headers: { 25 | Authorization: `Bearer ${sessionToken}` 26 | } 27 | } 28 | ), 29 | logoutFromNextClientToNextServer: ( 30 | force?: boolean | undefined, 31 | signal?: AbortSignal | undefined 32 | ) => 33 | http.post( 34 | '/api/auth/logout', 35 | { 36 | force 37 | }, 38 | { 39 | baseUrl: '', 40 | signal 41 | } 42 | ), 43 | slideSessionFromNextServerToServer: (sessionToken: string) => 44 | http.post( 45 | '/auth/slide-session', 46 | {}, 47 | { 48 | headers: { 49 | Authorization: `Bearer ${sessionToken}` 50 | } 51 | } 52 | ), 53 | slideSessionFromNextClientToNextServer: () => 54 | http.post( 55 | '/api/auth/slide-session', 56 | {}, 57 | { baseUrl: '' } 58 | ) 59 | } 60 | 61 | export default authApiRequest 62 | -------------------------------------------------------------------------------- /client/src/apiRequests/product.ts: -------------------------------------------------------------------------------- 1 | import http from '@/lib/http' 2 | import { MessageResType } from '@/schemaValidations/common.schema' 3 | import { 4 | CreateProductBodyType, 5 | ProductListResType, 6 | ProductResType, 7 | UpdateProductBodyType 8 | } from '@/schemaValidations/product.schema' 9 | 10 | const productApiRequest = { 11 | getList: () => http.get('/products'), 12 | getDetail: (id: number) => http.get(`/products/${id}`), 13 | create: (body: CreateProductBodyType) => 14 | http.post('/products', body), 15 | update: (id: number, body: UpdateProductBodyType) => 16 | http.put(`/products/${id}`, body), 17 | uploadImage: (body: FormData) => 18 | http.post<{ 19 | message: string 20 | data: string 21 | }>('/media/upload', body), 22 | delete: (id: number) => http.delete(`/products/${id}`) 23 | } 24 | 25 | export default productApiRequest 26 | -------------------------------------------------------------------------------- /client/src/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function AuthLayout({ 2 | children 3 | }: Readonly<{ 4 | children: React.ReactNode 5 | }>) { 6 | return children 7 | } 8 | -------------------------------------------------------------------------------- /client/src/app/(auth)/login/login-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import { useForm } from 'react-hook-form' 5 | import { Button } from '@/components/ui/button' 6 | import { 7 | Form, 8 | FormControl, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage 13 | } from '@/components/ui/form' 14 | import { Input } from '@/components/ui/input' 15 | import { LoginBody, LoginBodyType } from '@/schemaValidations/auth.schema' 16 | import { useToast } from '@/components/ui/use-toast' 17 | import authApiRequest from '@/apiRequests/auth' 18 | import { useRouter } from 'next/navigation' 19 | import { handleErrorApi } from '@/lib/utils' 20 | import { useState } from 'react' 21 | import { useAppContext } from '@/app/app-provider' 22 | 23 | const LoginForm = () => { 24 | const [loading, setLoading] = useState(false) 25 | const { setUser } = useAppContext() 26 | const { toast } = useToast() 27 | const router = useRouter() 28 | const form = useForm({ 29 | resolver: zodResolver(LoginBody), 30 | defaultValues: { 31 | email: '', 32 | password: '' 33 | } 34 | }) 35 | 36 | // 2. Define a submit handler. 37 | async function onSubmit(values: LoginBodyType) { 38 | if (loading) return 39 | setLoading(true) 40 | try { 41 | const result = await authApiRequest.login(values) 42 | 43 | await authApiRequest.auth({ 44 | sessionToken: result.payload.data.token, 45 | expiresAt: result.payload.data.expiresAt 46 | }) 47 | toast({ 48 | description: result.payload.message 49 | }) 50 | setUser(result.payload.data.account) 51 | router.push('/') 52 | router.refresh() 53 | } catch (error: any) { 54 | handleErrorApi({ 55 | error, 56 | setError: form.setError 57 | }) 58 | } finally { 59 | setLoading(false) 60 | } 61 | } 62 | return ( 63 |
64 | 69 | ( 73 | 74 | Email 75 | 76 | 77 | 78 | 79 | 80 | )} 81 | /> 82 | ( 86 | 87 | Mật khẩu 88 | 89 | 90 | 91 | 92 | 93 | )} 94 | /> 95 | 96 | 99 | 100 | 101 | ) 102 | } 103 | 104 | export default LoginForm 105 | -------------------------------------------------------------------------------- /client/src/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import LoginForm from '@/app/(auth)/login/login-form' 2 | 3 | export default function LoginPage() { 4 | return ( 5 |
6 |

Đăng nhập

7 |
8 | 9 |
10 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /client/src/app/(auth)/logout/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import authApiRequest from '@/apiRequests/auth' 4 | import { useAppContext } from '@/app/app-provider' 5 | import { usePathname, useRouter, useSearchParams } from 'next/navigation' 6 | import { Suspense, useEffect } from 'react' 7 | 8 | function LogoutLogic() { 9 | const router = useRouter() 10 | const pathname = usePathname() 11 | const { setUser } = useAppContext() 12 | 13 | const searchParams = useSearchParams() 14 | const sessionToken = searchParams.get('sessionToken') 15 | useEffect(() => { 16 | const controller = new AbortController() 17 | const signal = controller.signal 18 | if (sessionToken === localStorage.getItem('sessionToken')) { 19 | authApiRequest 20 | .logoutFromNextClientToNextServer(true, signal) 21 | .then((res) => { 22 | setUser(null) 23 | router.push(`/login?redirectFrom=${pathname}`) 24 | }) 25 | } 26 | return () => { 27 | controller.abort() 28 | } 29 | }, [sessionToken, router, pathname, setUser]) 30 | return
page
31 | } 32 | 33 | export default function LogoutPage() { 34 | return ( 35 | 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /client/src/app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import RegisterForm from '@/app/(auth)/register/register-form' 4 | 5 | const RegisterPage = () => { 6 | return ( 7 |
8 |

Đăng ký

9 |
10 | 11 |
12 |
13 | ) 14 | } 15 | 16 | export default RegisterPage 17 | -------------------------------------------------------------------------------- /client/src/app/(auth)/register/register-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import { useForm } from 'react-hook-form' 5 | import { Button } from '@/components/ui/button' 6 | import { 7 | Form, 8 | FormControl, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage 13 | } from '@/components/ui/form' 14 | import { Input } from '@/components/ui/input' 15 | import { RegisterBody, RegisterBodyType } from '@/schemaValidations/auth.schema' 16 | import authApiRequest from '@/apiRequests/auth' 17 | import { useToast } from '@/components/ui/use-toast' 18 | import { useRouter } from 'next/navigation' 19 | import { handleErrorApi } from '@/lib/utils' 20 | import { useState } from 'react' 21 | import { useAppContext } from '@/app/app-provider' 22 | 23 | const RegisterForm = () => { 24 | const [loading, setLoading] = useState(false) 25 | const { setUser } = useAppContext() 26 | const { toast } = useToast() 27 | const router = useRouter() 28 | 29 | const form = useForm({ 30 | resolver: zodResolver(RegisterBody), 31 | defaultValues: { 32 | email: '', 33 | name: '', 34 | password: '', 35 | confirmPassword: '' 36 | } 37 | }) 38 | 39 | // 2. Define a submit handler. 40 | async function onSubmit(values: RegisterBodyType) { 41 | if (loading) return 42 | setLoading(true) 43 | try { 44 | const result = await authApiRequest.register(values) 45 | 46 | await authApiRequest.auth({ 47 | sessionToken: result.payload.data.token, 48 | expiresAt: result.payload.data.expiresAt 49 | }) 50 | toast({ 51 | description: result.payload.message 52 | }) 53 | setUser(result.payload.data.account) 54 | 55 | router.push('/me') 56 | } catch (error: any) { 57 | handleErrorApi({ 58 | error, 59 | setError: form.setError 60 | }) 61 | } finally { 62 | setLoading(false) 63 | } 64 | } 65 | return ( 66 |
67 | 72 | ( 76 | 77 | Tên 78 | 79 | 80 | 81 | 82 | 83 | )} 84 | /> 85 | ( 89 | 90 | Email 91 | 92 | 93 | 94 | 95 | 96 | )} 97 | /> 98 | ( 102 | 103 | Mật khẩu 104 | 105 | 106 | 107 | 108 | 109 | )} 110 | /> 111 | ( 115 | 116 | Nhập lại mật khẩu 117 | 118 | 119 | 120 | 121 | 122 | )} 123 | /> 124 | 127 | 128 | 129 | ) 130 | } 131 | 132 | export default RegisterForm 133 | -------------------------------------------------------------------------------- /client/src/app/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duthanhduoc/nextjs-free/5309be5d166f5be4d918e8b99316723c160def26/client/src/app/Roboto-Regular.ttf -------------------------------------------------------------------------------- /client/src/app/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duthanhduoc/nextjs-free/5309be5d166f5be4d918e8b99316723c160def26/client/src/app/Roboto-Thin.ttf -------------------------------------------------------------------------------- /client/src/app/api/auth/logout/route.ts: -------------------------------------------------------------------------------- 1 | import authApiRequest from '@/apiRequests/auth' 2 | import { HttpError } from '@/lib/http' 3 | import { cookies } from 'next/headers' 4 | 5 | export async function POST(request: Request) { 6 | const res = await request.json() 7 | const force = res.force as boolean | undefined 8 | if (force) { 9 | return Response.json( 10 | { 11 | message: 'Buộc đăng xuất thành công' 12 | }, 13 | { 14 | status: 200, 15 | headers: { 16 | // Xóa cookie sessionToken 17 | 'Set-Cookie': `sessionToken=; Path=/; HttpOnly; Max-Age=0` 18 | } 19 | } 20 | ) 21 | } 22 | const cookieStore = await cookies() 23 | const sessionToken = cookieStore.get('sessionToken') 24 | if (!sessionToken) { 25 | return Response.json( 26 | { message: 'Không nhận được session token' }, 27 | { 28 | status: 401 29 | } 30 | ) 31 | } 32 | try { 33 | const result = await authApiRequest.logoutFromNextServerToServer( 34 | sessionToken.value 35 | ) 36 | return Response.json(result.payload, { 37 | status: 200, 38 | headers: { 39 | // Xóa cookie sessionToken 40 | 'Set-Cookie': `sessionToken=; Path=/; HttpOnly; Max-Age=0` 41 | } 42 | }) 43 | } catch (error) { 44 | if (error instanceof HttpError) { 45 | return Response.json(error.payload, { 46 | status: error.status 47 | }) 48 | } else { 49 | return Response.json( 50 | { 51 | message: 'Lỗi không xác định' 52 | }, 53 | { 54 | status: 500 55 | } 56 | ) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /client/src/app/api/auth/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST(request: Request) { 2 | const body = await request.json() 3 | const sessionToken = body.sessionToken as string 4 | const expiresAt = body.expiresAt as string 5 | if (!sessionToken) { 6 | return Response.json( 7 | { message: 'Không nhận được session token' }, 8 | { 9 | status: 400 10 | } 11 | ) 12 | } 13 | const expiresDate = new Date(expiresAt).toUTCString() 14 | return Response.json(body, { 15 | status: 200, 16 | headers: { 17 | 'Set-Cookie': `sessionToken=${sessionToken}; Path=/; HttpOnly; Expires=${expiresDate}; SameSite=Lax; Secure` 18 | } 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /client/src/app/api/auth/slide-session/route.ts: -------------------------------------------------------------------------------- 1 | import authApiRequest from '@/apiRequests/auth' 2 | import { HttpError } from '@/lib/http' 3 | import { cookies } from 'next/headers' 4 | 5 | export async function POST(request: Request) { 6 | const cookieStore = await cookies() 7 | const sessionToken = cookieStore.get('sessionToken') 8 | if (!sessionToken) { 9 | return Response.json( 10 | { message: 'Không nhận được session token' }, 11 | { 12 | status: 401 13 | } 14 | ) 15 | } 16 | try { 17 | const res = await authApiRequest.slideSessionFromNextServerToServer( 18 | sessionToken.value 19 | ) 20 | const newExpiresDate = new Date(res.payload.data.expiresAt).toUTCString() 21 | return Response.json(res.payload, { 22 | status: 200, 23 | headers: { 24 | 'Set-Cookie': `sessionToken=${sessionToken.value}; Path=/; HttpOnly; Expires=${newExpiresDate}; SameSite=Lax; Secure` 25 | } 26 | }) 27 | } catch (error) { 28 | if (error instanceof HttpError) { 29 | return Response.json(error.payload, { 30 | status: error.status 31 | }) 32 | } else { 33 | return Response.json( 34 | { 35 | message: 'Lỗi không xác định' 36 | }, 37 | { 38 | status: 500 39 | } 40 | ) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/src/app/app-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { isClient } from '@/lib/http' 3 | import { AccountResType } from '@/schemaValidations/account.schema' 4 | import { 5 | createContext, 6 | useCallback, 7 | useContext, 8 | useEffect, 9 | useState 10 | } from 'react' 11 | 12 | type User = AccountResType['data'] 13 | 14 | const AppContext = createContext<{ 15 | user: User | null 16 | setUser: (user: User | null) => void 17 | isAuthenticated: boolean 18 | }>({ 19 | user: null, 20 | setUser: () => {}, 21 | isAuthenticated: false 22 | }) 23 | export const useAppContext = () => { 24 | const context = useContext(AppContext) 25 | return context 26 | } 27 | export default function AppProvider({ 28 | children 29 | }: { 30 | children: React.ReactNode 31 | }) { 32 | const [user, setUserState] = useState(() => { 33 | // if (isClient()) { 34 | // const _user = localStorage.getItem('user') 35 | // return _user ? JSON.parse(_user) : null 36 | // } 37 | return null 38 | }) 39 | const isAuthenticated = Boolean(user) 40 | const setUser = useCallback( 41 | (user: User | null) => { 42 | setUserState(user) 43 | localStorage.setItem('user', JSON.stringify(user)) 44 | }, 45 | [setUserState] 46 | ) 47 | 48 | useEffect(() => { 49 | const _user = localStorage.getItem('user') 50 | setUserState(_user ? JSON.parse(_user) : null) 51 | }, [setUserState]) 52 | 53 | return ( 54 | 61 | {children} 62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /client/src/app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duthanhduoc/nextjs-free/5309be5d166f5be4d918e8b99316723c160def26/client/src/app/apple-icon.png -------------------------------------------------------------------------------- /client/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duthanhduoc/nextjs-free/5309be5d166f5be4d918e8b99316723c160def26/client/src/app/favicon.ico -------------------------------------------------------------------------------- /client/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /client/src/app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duthanhduoc/nextjs-free/5309be5d166f5be4d918e8b99316723c160def26/client/src/app/icon.png -------------------------------------------------------------------------------- /client/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Inter } from 'next/font/google' 3 | import { ThemeProvider } from '@/components/theme-provider' 4 | import './globals.css' 5 | import { Toaster } from '@/components/ui/toaster' 6 | import AppProvider from '@/app/app-provider' 7 | import SlideSession from '@/components/slide-session' 8 | import { baseOpenGraph } from '@/app/shared-metadata' 9 | // import dynamic from 'next/dynamic' 10 | import Header from '@/components/header' 11 | // const Header = dynamic(() => import('@/components/header'), { ssr: false }) 12 | const inter = Inter({ subsets: ['vietnamese'] }) 13 | 14 | export const metadata: Metadata = { 15 | title: { 16 | template: '%s | Productic', 17 | default: 'Productic' 18 | }, 19 | description: 'Được tạo bởi Được dev', 20 | openGraph: baseOpenGraph 21 | } 22 | 23 | export default async function RootLayout({ 24 | children 25 | }: Readonly<{ 26 | children: React.ReactNode 27 | }>) { 28 | return ( 29 | 30 | 31 | 32 | 38 | 39 |
40 | {children} 41 | 42 | 43 | 44 | 45 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /client/src/app/me/page.tsx: -------------------------------------------------------------------------------- 1 | import accountApiRequest from '@/apiRequests/account' 2 | import ProfileForm from '@/app/me/profile-form' 3 | import { cookies } from 'next/headers' 4 | import type { Metadata } from 'next' 5 | 6 | export const metadata: Metadata = { 7 | title: 'Hồ sơ người dùng' 8 | } 9 | 10 | export default async function MeProfile() { 11 | const cookieStore = await cookies() 12 | const sessionToken = cookieStore.get('sessionToken') 13 | // Vì dùng cookie nên api này không được cached trên server 14 | // https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching 15 | 16 | const result = await accountApiRequest.me(sessionToken?.value ?? '') 17 | return ( 18 |
19 |

Profile

20 | 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /client/src/app/me/profile-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import { useForm } from 'react-hook-form' 5 | import { Button } from '@/components/ui/button' 6 | import { 7 | Form, 8 | FormControl, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage 13 | } from '@/components/ui/form' 14 | import { Input } from '@/components/ui/input' 15 | import { useToast } from '@/components/ui/use-toast' 16 | import { useRouter } from 'next/navigation' 17 | import { handleErrorApi } from '@/lib/utils' 18 | import { useState } from 'react' 19 | import { 20 | AccountResType, 21 | UpdateMeBody, 22 | UpdateMeBodyType 23 | } from '@/schemaValidations/account.schema' 24 | import accountApiRequest from '@/apiRequests/account' 25 | 26 | type Profile = AccountResType['data'] 27 | 28 | const ProfileForm = ({ profile }: { profile: Profile }) => { 29 | const [loading, setLoading] = useState(false) 30 | const { toast } = useToast() 31 | const router = useRouter() 32 | const form = useForm({ 33 | resolver: zodResolver(UpdateMeBody), 34 | defaultValues: { 35 | name: profile.name 36 | } 37 | }) 38 | 39 | // 2. Define a submit handler. 40 | async function onSubmit(values: UpdateMeBodyType) { 41 | if (loading) return 42 | setLoading(true) 43 | try { 44 | const result = await accountApiRequest.updateMe(values) 45 | toast({ 46 | description: result.payload.message 47 | }) 48 | router.refresh() 49 | } catch (error: any) { 50 | handleErrorApi({ 51 | error, 52 | setError: form.setError 53 | }) 54 | } finally { 55 | setLoading(false) 56 | } 57 | } 58 | return ( 59 |
60 | 65 | Email 66 | 67 | 73 | 74 | 75 | 76 | ( 80 | 81 | Tên 82 | 83 | 84 | 85 | 86 | 87 | )} 88 | /> 89 | 90 | 93 | 94 | 95 | ) 96 | } 97 | 98 | export default ProfileForm 99 | -------------------------------------------------------------------------------- /client/src/app/me/profile.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import accountApiRequest from '@/apiRequests/account' 4 | import { handleErrorApi } from '@/lib/utils' 5 | import { useEffect } from 'react' 6 | 7 | export default function Profile() { 8 | useEffect(() => { 9 | const fetchRequest = async () => { 10 | try { 11 | const result = await accountApiRequest.meClient() 12 | console.log(result) 13 | } catch (error) { 14 | handleErrorApi({ 15 | error 16 | }) 17 | } 18 | } 19 | fetchRequest() 20 | }, []) 21 | return
profile
22 | } 23 | -------------------------------------------------------------------------------- /client/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next' 2 | 3 | export const metadata: Metadata = { 4 | title: 'Trang chủ', 5 | description: 'Trang chủ của Productic, được tạo bởi Được dev' 6 | } 7 | 8 | export default function Home() { 9 | return
Xin chào
10 | } 11 | -------------------------------------------------------------------------------- /client/src/app/products/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import productApiRequest from '@/apiRequests/product' 2 | import ProductAddForm from '@/app/products/_components/product-add-form' 3 | import { Metadata, ResolvingMetadata } from 'next' 4 | import { cache } from 'react' 5 | 6 | const getDetail = cache(productApiRequest.getDetail) 7 | 8 | type Props = { 9 | params: Promise<{ id: string }> 10 | searchParams: Promise<{ [key: string]: string | string[] | undefined }> 11 | } 12 | 13 | export async function generateMetadata(props: Props, parent: ResolvingMetadata): Promise { 14 | const params = await props.params; 15 | const { payload } = await getDetail(Number(params.id)) 16 | const product = payload.data 17 | return { 18 | title: 'Edit sản phẩm: ' + product.name, 19 | description: product.description 20 | } 21 | } 22 | 23 | export default async function ProductEdit(props: Props) { 24 | const params = await props.params; 25 | let product = null 26 | try { 27 | const { payload } = await getDetail(Number(params.id)) 28 | product = payload.data 29 | } catch (error) {} 30 | 31 | return ( 32 |
33 | {!product &&
Không tìm thấy sản phẩm
} 34 | {product && } 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /client/src/app/products/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import productApiRequest from '@/apiRequests/product' 2 | import Image from 'next/image' 3 | import { Metadata, ResolvingMetadata } from 'next' 4 | import { cache } from 'react' 5 | import envConfig from '@/config' 6 | import { baseOpenGraph } from '@/app/shared-metadata' 7 | 8 | const getDetail = cache(productApiRequest.getDetail) 9 | 10 | type Props = { 11 | params: Promise<{ id: string }> 12 | searchParams: Promise<{ [key: string]: string | string[] | undefined }> 13 | } 14 | 15 | export async function generateMetadata(props: Props, parent: ResolvingMetadata): Promise { 16 | const params = await props.params; 17 | const { payload } = await getDetail(Number(params.id)) 18 | const product = payload.data 19 | const url = envConfig.NEXT_PUBLIC_URL + '/products/' + product.id 20 | return { 21 | title: product.name, 22 | description: product.description, 23 | openGraph: { 24 | ...baseOpenGraph, 25 | title: product.name, 26 | description: product.description, 27 | url, 28 | images: [ 29 | { 30 | url: product.image 31 | } 32 | ] 33 | }, 34 | alternates: { 35 | canonical: url 36 | } 37 | } 38 | } 39 | 40 | export default async function ProductDetail(props: Props) { 41 | const params = await props.params; 42 | let product = null 43 | try { 44 | const { payload } = await getDetail(Number(params.id)) 45 | product = payload.data 46 | } catch (error) {} 47 | 48 | return ( 49 |
50 | {!product &&
Không tìm thấy sản phẩm
} 51 | {product && ( 52 |
53 | {product.name} 60 | 61 |

{product.name}

62 |
{product.price}
63 |
64 | )} 65 |
66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /client/src/app/products/_components/delete-product.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '@/components/ui/button' 4 | import { ProductResType } from '@/schemaValidations/product.schema' 5 | import { 6 | AlertDialog, 7 | AlertDialogAction, 8 | AlertDialogCancel, 9 | AlertDialogContent, 10 | AlertDialogDescription, 11 | AlertDialogFooter, 12 | AlertDialogHeader, 13 | AlertDialogTitle, 14 | AlertDialogTrigger 15 | } from '@/components/ui/alert-dialog' 16 | import productApiRequest from '@/apiRequests/product' 17 | import { handleErrorApi } from '@/lib/utils' 18 | import { useToast } from '@/components/ui/use-toast' 19 | import { useRouter } from 'next/navigation' 20 | 21 | export default function DeleteProduct({ 22 | product 23 | }: { 24 | product: ProductResType['data'] 25 | }) { 26 | const { toast } = useToast() 27 | const router = useRouter() 28 | const deleteProduct = async () => { 29 | try { 30 | const result = await productApiRequest.delete(product.id) 31 | toast({ 32 | description: result.payload.message 33 | }) 34 | router.refresh() 35 | } catch (error) { 36 | handleErrorApi({ error }) 37 | } 38 | } 39 | 40 | return ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | Bạn có muốn xóa sản phẩm không? 48 | 49 | Sản phẩm ”{product.name}” sẽ bị xóa vĩnh viễn! 50 | 51 | 52 | 53 | Cancel 54 | 55 | Continue 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /client/src/app/products/_components/product-add-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { Button } from '@/components/ui/button' 3 | import Link from 'next/link' 4 | import { useEffect, useState } from 'react' 5 | 6 | export default function ProductAddButton() { 7 | const [isAuthenticated, setIsAuthenticated] = useState(false) 8 | useEffect(() => { 9 | setIsAuthenticated(Boolean(localStorage.getItem('sessionToken'))) 10 | }, []) 11 | 12 | if (!isAuthenticated) return null 13 | return ( 14 | 15 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /client/src/app/products/_components/product-add-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import { useForm } from 'react-hook-form' 5 | import { Button } from '@/components/ui/button' 6 | import { 7 | Form, 8 | FormControl, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage 13 | } from '@/components/ui/form' 14 | import { Input } from '@/components/ui/input' 15 | import { useToast } from '@/components/ui/use-toast' 16 | import { useRouter } from 'next/navigation' 17 | import { handleErrorApi } from '@/lib/utils' 18 | import { useRef, useState } from 'react' 19 | import { 20 | CreateProductBody, 21 | CreateProductBodyType, 22 | ProductResType, 23 | UpdateProductBodyType 24 | } from '@/schemaValidations/product.schema' 25 | import productApiRequest from '@/apiRequests/product' 26 | import { Textarea } from '@/components/ui/textarea' 27 | import Image from 'next/image' 28 | type Product = ProductResType['data'] 29 | const ProductAddForm = ({ product }: { product?: Product }) => { 30 | const [file, setFile] = useState(null) 31 | const inputRef = useRef(null) 32 | const [loading, setLoading] = useState(false) 33 | const { toast } = useToast() 34 | const router = useRouter() 35 | const form = useForm({ 36 | resolver: zodResolver(CreateProductBody), 37 | defaultValues: { 38 | name: product?.name ?? '', 39 | price: product?.price ?? 0, 40 | description: product?.description ?? '', 41 | image: product?.image ?? '' 42 | } 43 | }) 44 | const image = form.watch('image') 45 | const createProduct = async (values: CreateProductBodyType) => { 46 | setLoading(true) 47 | try { 48 | const formData = new FormData() 49 | formData.append('file', file as Blob) 50 | const uploadImageResult = await productApiRequest.uploadImage(formData) 51 | const imageUrl = uploadImageResult.payload.data 52 | const result = await productApiRequest.create({ 53 | ...values, 54 | image: imageUrl 55 | }) 56 | 57 | toast({ 58 | description: result.payload.message 59 | }) 60 | router.push('/products') 61 | router.refresh() 62 | } catch (error: any) { 63 | handleErrorApi({ 64 | error, 65 | setError: form.setError 66 | }) 67 | } finally { 68 | setLoading(false) 69 | } 70 | } 71 | 72 | const updateProduct = async (_values: UpdateProductBodyType) => { 73 | if (!product) return 74 | setLoading(true) 75 | let values = _values 76 | try { 77 | if (file) { 78 | const formData = new FormData() 79 | formData.append('file', file as Blob) 80 | const uploadImageResult = await productApiRequest.uploadImage(formData) 81 | const imageUrl = uploadImageResult.payload.data 82 | values = { 83 | ...values, 84 | image: imageUrl 85 | } 86 | } 87 | 88 | const result = await productApiRequest.update(product.id, values) 89 | 90 | toast({ 91 | description: result.payload.message 92 | }) 93 | router.refresh() 94 | } catch (error: any) { 95 | handleErrorApi({ 96 | error, 97 | setError: form.setError 98 | }) 99 | } finally { 100 | setLoading(false) 101 | } 102 | } 103 | async function onSubmit(values: CreateProductBodyType) { 104 | if (loading) return 105 | if (product) { 106 | await updateProduct(values) 107 | } else { 108 | await createProduct(values) 109 | } 110 | } 111 | return ( 112 |
113 | { 115 | console.log(error) 116 | console.log(form.getValues('image')) 117 | })} 118 | className='space-y-2 max-w-[600px] flex-shrink-0 w-full' 119 | noValidate 120 | > 121 | ( 125 | 126 | Tên 127 | 128 | 129 | 130 | 131 | 132 | )} 133 | /> 134 | ( 138 | 139 | Giá 140 | 141 | 142 | 143 | 144 | 145 | )} 146 | /> 147 | ( 151 | 152 | Mô tả 153 | 154 |