├── .yarnrc.yml ├── .eslintrc.json ├── public ├── favicon.png ├── tidb-cloud-logo.png ├── prisma.svg ├── vercel.svg └── index.html ├── .yarn └── install-state.gz ├── postcss.config.js ├── next.config.js ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20241211035943_init │ │ └── migration.sql └── schema.prisma ├── next-env.d.ts ├── pages ├── api │ ├── index.ts │ ├── books │ │ ├── types.ts │ │ ├── [id] │ │ │ ├── index.ts │ │ │ ├── buy.ts │ │ │ └── ratings.ts │ │ └── index.ts │ ├── stats │ │ ├── book-orders-total.ts │ │ └── book-orders-mom.ts │ └── orders │ │ ├── [id].ts │ │ └── index.ts ├── cart.tsx ├── _app.tsx ├── book │ └── [id].tsx └── index.tsx ├── styles └── globals.css ├── .gitignore ├── scripts ├── env.mjs └── setup.mjs ├── tsconfig.json ├── .github └── renovate.json ├── components └── v2 │ ├── Pagination │ └── index.tsx │ ├── Layout │ ├── index.tsx │ ├── Header.tsx │ └── BookTypeMenu.tsx │ ├── Chips │ └── FilteredChips.tsx │ ├── List │ ├── ShoppingCartList.tsx │ └── ShoppingCartListItem.tsx │ ├── Cards │ ├── ShoppingItemCardList.tsx │ └── ShoppingItemCard.tsx │ ├── Rating │ └── HalfRating.tsx │ └── BookDetails │ ├── BookRatingDeleteDialog.tsx │ ├── BookAddRatingDialog.tsx │ ├── BookInfoSection.tsx │ ├── BookInfoDialog.tsx │ └── BookReviewsSection.tsx ├── atoms └── index.ts ├── const └── index.ts ├── selectors └── index.ts ├── lib ├── prisma.ts ├── utils.ts └── http.ts ├── tailwind.config.ts ├── package.json ├── README.md └── LICENSE /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingcap/tidb-prisma-vercel-demo/HEAD/public/favicon.png -------------------------------------------------------------------------------- /.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingcap/tidb-prisma-vercel-demo/HEAD/.yarn/install-state.gz -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/tidb-cloud-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingcap/tidb-prisma-vercel-demo/HEAD/public/tidb-cloud-logo.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // next.config.js 2 | module.exports = { 3 | images: { 4 | domains: ["picsum.photos"], 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /pages/api/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | const health = async ( 4 | req: NextApiRequest, 5 | res: NextApiResponse 6 | ) => { 7 | res.status(200).json({ up: true }) 8 | } 9 | 10 | export default health; 11 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body { 7 | padding: 0; 8 | margin: 0; 9 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 10 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 11 | } 12 | 13 | a { 14 | color: inherit; 15 | text-decoration: none; 16 | } 17 | 18 | * { 19 | box-sizing: border-box; 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .now 2 | node_modules/ 3 | *.env* 4 | 5 | .vercel 6 | package-lock.json 7 | 8 | # next.js 9 | /.next/ 10 | /out/ 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | *.pem 18 | 19 | # debug 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # local env files 25 | .env.local 26 | .env.development.local 27 | .env.test.local 28 | .env.production.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # yarn 34 | .yarn/ 35 | -------------------------------------------------------------------------------- /pages/api/books/types.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | import { BookType } from '@prisma/client'; 4 | 5 | const bookTypes = Object.values(BookType); 6 | 7 | const bookTypeListHandler = async ( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) => { 11 | if (req.method === 'GET') { 12 | res.status(200).json(bookTypes); 13 | } else { 14 | res.status(401).json({ 15 | message: `HTTP method ${req.method} is not supported.` 16 | }); 17 | } 18 | } 19 | 20 | export default bookTypeListHandler; 21 | -------------------------------------------------------------------------------- /scripts/env.mjs: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | 5 | const { TIDB_USER, TIDB_PASSWORD, TIDB_HOST, TIDB_PORT, TIDB_DB_NAME = 'bookshop', DATABASE_URL } = process.env; 6 | // Notice: When using TiDb Cloud Serverless Tier, you **MUST** set the following flags to enable tls connection. 7 | const SSL_FLAGS = 'pool_timeout=60&sslaccept=accept_invalid_certs'; 8 | 9 | if(TIDB_USER && TIDB_HOST && TIDB_PORT) { 10 | console.log(`mysql://${TIDB_USER}:${TIDB_PASSWORD}@${TIDB_HOST}:${TIDB_PORT}/${TIDB_DB_NAME}?${SSL_FLAGS}`); 11 | } else { 12 | console.log(`${DATABASE_URL}?${SSL_FLAGS}`); 13 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 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 | "baseUrl": "." 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /pages/api/stats/book-orders-total.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | import prisma from '../../../lib/prisma' 4 | 5 | const bookOrdersTotalHandler = async ( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) => { 9 | if (req.method === 'GET') { 10 | res.status(200).json(await getBookOrderTotal(req)); 11 | } else { 12 | res.status(401).json({ 13 | message: `HTTP method ${req.method} is not supported.` 14 | }); 15 | } 16 | } 17 | 18 | async function getBookOrderTotal(req: NextApiRequest) { 19 | const total = await prisma.order.count(); 20 | 21 | return { 22 | orders: total 23 | } 24 | } 25 | 26 | export default bookOrdersTotalHandler; 27 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "reviewers": ["janpio", "ruheni", "zachtil"], 4 | "semanticCommits": "enabled", 5 | "dependencyDashboard": true, 6 | "timezone": "Europe/Berlin", 7 | "baseBranches": ["main", "data-proxy"], 8 | "packageRules": [ 9 | { 10 | "matchPackageNames": ["next", "react", "react-dom"], 11 | "groupName": "deps (non-major)", 12 | "automerge": "true" 13 | }, 14 | { 15 | "matchPackagePatterns": ["@prisma/*"], 16 | "matchPackageNames": ["prisma"], 17 | "matchUpdateTypes": ["minor", "patch"], 18 | "groupName": "Prisma Dependencies", 19 | "groupSlug": "prisma-deps", 20 | "automerge": "true" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /public/prisma.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /pages/cart.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import Head from 'next/head'; 4 | 5 | import CommonLayout from 'components/v2/Layout'; 6 | import ShoppingCartList from 'components/v2/List/ShoppingCartList'; 7 | 8 | const Cart: NextPage = () => { 9 | return ( 10 | <> 11 | 12 | Shopping Cart 13 | 14 | 15 | 16 | 17 | 22 |

Shopping Cart

23 | 24 |
25 | 26 | ); 27 | }; 28 | 29 | export default Cart; 30 | -------------------------------------------------------------------------------- /components/v2/Pagination/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface PaginationProps { 4 | currentPage: number; 5 | pages: number; 6 | onClick?: (page: number) => void; 7 | } 8 | 9 | export default function Pagination(props: PaginationProps) { 10 | const { currentPage, pages, onClick } = props; 11 | 12 | return ( 13 |
14 | {new Array(pages).fill(0).map((_, idx) => { 15 | return ( 16 | 27 | ); 28 | })} 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /atoms/index.ts: -------------------------------------------------------------------------------- 1 | import { atom, selector, useRecoilState, useRecoilValue } from "recoil"; 2 | 3 | import { shoppingCartItemProps, BookProps, PAGE_SIZE } from "const"; 4 | 5 | export const homePageBookSumState = atom({ 6 | key: "homePageBookSumState", 7 | default: 0, 8 | }); 9 | 10 | export const shoppingCartState = atom({ 11 | key: "shoppingCartState", 12 | default: [], 13 | }); 14 | 15 | export const bookTypeListState = atom({ 16 | key: "bookTypeListState", 17 | default: [], 18 | }); 19 | 20 | export const homePageQueryState = atom({ 21 | key: "homePageQueryState", 22 | default: { page: 1, type: "", sort: "", size: PAGE_SIZE }, 23 | }); 24 | 25 | export const bookDetailsIdState = atom({ 26 | key: "bookDetailsIdState", 27 | default: "", 28 | }); 29 | 30 | export const currentUserIdState = atom({ 31 | key: "currentUserIdState", 32 | default: "1", 33 | }); 34 | -------------------------------------------------------------------------------- /components/v2/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import NextLink from 'next/link'; 3 | import { 4 | Bars3Icon, 5 | MagnifyingGlassIcon, 6 | ShoppingBagIcon, 7 | XMarkIcon, 8 | ShoppingCartIcon, 9 | UserIcon, 10 | } from '@heroicons/react/24/outline'; 11 | 12 | import Header, { HeaderProps } from 'components/v2/Layout/Header'; 13 | 14 | export interface CommonLayoutProps { 15 | children?: any; 16 | headerProps?: HeaderProps; 17 | } 18 | 19 | export default function CommonLayout(props: CommonLayoutProps) { 20 | const { headerProps, children } = props; 21 | 22 | return ( 23 | <> 24 |
25 |
26 | 27 |
28 |
29 | {/* Your content */} 30 | {children} 31 |
32 |
33 |
34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | import { RecoilRoot, useRecoilSnapshot } from "recoil"; 4 | import { useEffect } from "react"; 5 | import { SnackbarProvider } from "notistack"; 6 | 7 | function DebugObserver(): any { 8 | const snapshot = useRecoilSnapshot(); 9 | useEffect(() => { 10 | if (process.env.NODE_ENV !== "development") return; 11 | console.debug("The following atoms were modified:"); 12 | for (const node of snapshot.getNodes_UNSTABLE({ isModified: true })) { 13 | console.debug(node.key, snapshot.getLoadable(node)); 14 | } 15 | }, [snapshot]); 16 | 17 | return null; 18 | } 19 | 20 | function MyApp({ Component, pageProps }: AppProps) { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | export default MyApp; 32 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /const/index.ts: -------------------------------------------------------------------------------- 1 | export type AuthorType = { 2 | id: string; 3 | name: string; 4 | }; 5 | 6 | export interface BookProps { 7 | id: string; 8 | title: string; 9 | type: string; 10 | publishedAt: string; 11 | stock: number; 12 | price: string; 13 | authors: { author: AuthorType }[]; 14 | averageRating: number; 15 | ratings: number; 16 | } 17 | 18 | export interface shoppingCartItemProps extends BookProps { 19 | quantity: number; 20 | } 21 | 22 | export type BookDetailProps = Omit< 23 | BookProps, 24 | 'authors' | 'averageRating' | 'ratings' 25 | >; 26 | 27 | export interface BookRatingsProps { 28 | bookId: string; 29 | userId: string; 30 | score: number; 31 | ratedAt: string; 32 | user: { 33 | id: string; 34 | nickname: string; 35 | }; 36 | } 37 | 38 | export const starLabels: { [index: string]: string } = { 39 | 0.5: 'Useless', 40 | 1: 'Useless+', 41 | 1.5: 'Poor', 42 | 2: 'Poor+', 43 | 2.5: 'Ok', 44 | 3: 'Ok+', 45 | 3.5: 'Good', 46 | 4: 'Good+', 47 | 4.5: 'Excellent', 48 | 5: 'Excellent+', 49 | }; 50 | 51 | export const PAGE_SIZE = 6; 52 | 53 | export const SORT_VALUE = ['published_at', 'price']; 54 | -------------------------------------------------------------------------------- /pages/api/stats/book-orders-mom.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | import prisma from '../../../lib/prisma' 4 | 5 | const bookOrderMoMHandler = async ( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) => { 9 | if (req.method === 'GET') { 10 | res.status(200).json(await getBookOrdersMonthOverMonth(req)); 11 | } else { 12 | res.status(401).json({ 13 | message: `HTTP method ${req.method} is not supported.` 14 | }); 15 | } 16 | } 17 | 18 | async function getBookOrdersMonthOverMonth(req: NextApiRequest) { 19 | const result = await prisma.$queryRaw` 20 | WITH orders_group_by_month AS ( 21 | SELECT 22 | b.type AS book_type, 23 | DATE_FORMAT(ordered_at, '%Y-%c') AS month, 24 | COUNT(*) AS orders 25 | FROM orders o 26 | LEFT JOIN books b ON o.book_id = b.id 27 | WHERE b.type IS NOT NULL 28 | GROUP BY book_type, month 29 | ), acc AS ( 30 | SELECT 31 | book_type, 32 | month, 33 | SUM(orders) OVER(PARTITION BY book_type ORDER BY book_type, month ASC) as acc 34 | FROM orders_group_by_month 35 | ORDER BY book_type, month ASC 36 | ) 37 | SELECT * FROM acc; 38 | `; 39 | 40 | return { 41 | result: result 42 | } 43 | } 44 | 45 | export default bookOrderMoMHandler; 46 | -------------------------------------------------------------------------------- /selectors/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | atom, 3 | selector, 4 | selectorFamily, 5 | useRecoilState, 6 | useRecoilValue, 7 | waitForNone, 8 | } from "recoil"; 9 | import { bookDetailsIdState, homePageQueryState } from "atoms"; 10 | import { 11 | fetchBookDetailsById, 12 | fetchBookRatingsById, 13 | fetchBooks, 14 | } from "lib/http"; 15 | 16 | export const homePageQuery = selector({ 17 | key: "homePage", 18 | get: async ({ get }) => { 19 | const { page, size, type, sort } = get(homePageQueryState); 20 | const response = await fetchBooks({ page, size, type, sort }); 21 | return response; 22 | }, 23 | }); 24 | 25 | export const bookInfoQuery = selector({ 26 | key: "BookInfoQuery", 27 | get: async ({ get }) => { 28 | const bookID = get(bookDetailsIdState); 29 | const response = await fetchBookDetailsById(bookID); 30 | if (response.error) { 31 | throw response.error; 32 | } 33 | return response; 34 | }, 35 | }); 36 | 37 | export const bookRatingQuery = selector({ 38 | key: "BookRatingQuery", 39 | get: async ({ get }) => { 40 | const bookID = get(bookDetailsIdState); 41 | if (!bookID) { 42 | throw new Error('Required bookID'); 43 | } 44 | const response = await fetchBookRatingsById(bookID); 45 | if (response.error) { 46 | throw response.error; 47 | } 48 | return response; 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /pages/book/[id].tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import BookReviewsSection from 'components/v2/BookDetails/BookReviewsSection'; 4 | import CommonLayout from 'components/v2/Layout'; 5 | import Head from 'next/head'; 6 | import type { NextPage } from 'next'; 7 | import { bookDetailsIdState } from 'atoms'; 8 | import dynamic from 'next/dynamic'; 9 | import { useRecoilState } from 'recoil'; 10 | import { useRouter } from 'next/router'; 11 | 12 | const BookInfoSection = dynamic(import('components/v2/BookDetails/BookInfoSection'), { ssr: false }) 13 | 14 | const Book: NextPage = () => { 15 | const router = useRouter(); 16 | const { id } = router.query; 17 | 18 | const [, setBookDetailsId] = useRecoilState(bookDetailsIdState); 19 | // const bookDetailsLodable = useRecoilValueLoadable(bookDetailsQuery); 20 | 21 | React.useEffect(() => { 22 | id && setBookDetailsId(id as string); 23 | }, [id, setBookDetailsId]); 24 | 25 | return ( 26 | <> 27 | 28 | Book Details 29 | 30 | 31 | 32 | 33 | 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default Book; 46 | -------------------------------------------------------------------------------- /lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | // PrismaClient is attached to the `global` object in development to prevent 4 | // exhausting your database connection limit. 5 | // 6 | // Learn more: 7 | // https://pris.ly/d/help/next-js-best-practices 8 | 9 | declare global { 10 | // allow global `var` declarations 11 | // eslint-disable-next-line no-var 12 | var prisma: PrismaClient | undefined 13 | } 14 | 15 | let prisma: PrismaClient 16 | 17 | const { TIDB_USER, TIDB_PASSWORD, TIDB_HOST, TIDB_PORT, TIDB_DB_NAME = 'bookshop', DATABASE_URL } = process.env; 18 | // Notice: When using TiDb Cloud Serverless Tier, you **MUST** set the following flags to enable tls connection. 19 | const SSL_FLAGS = 'pool_timeout=60&sslaccept=accept_invalid_certs'; 20 | const databaseURL = DATABASE_URL 21 | ? `${DATABASE_URL}?${SSL_FLAGS}` 22 | : `mysql://${TIDB_USER}:${TIDB_PASSWORD}@${TIDB_HOST}:${TIDB_PORT}/${TIDB_DB_NAME}?${SSL_FLAGS}`; 23 | 24 | if (process.env.NODE_ENV === 'production') { 25 | prisma = new PrismaClient({ 26 | datasources: { 27 | db: { 28 | url: databaseURL, 29 | }, 30 | }, 31 | }); 32 | } else { 33 | if (!global.prisma) { 34 | global.prisma = new PrismaClient({ 35 | datasources: { 36 | db: { 37 | url: databaseURL, 38 | }, 39 | }, 40 | }); 41 | } 42 | prisma = global.prisma 43 | } 44 | 45 | export default prisma 46 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { shoppingCartItemProps } from 'const'; 2 | // import _ from 'lodash'; 3 | 4 | export function currencyFormat(num: number | string) { 5 | return parseFloat(`${num}`) 6 | .toFixed(2) 7 | .replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); 8 | } 9 | 10 | export function calcCartItemSum(cartItems: shoppingCartItemProps[]) { 11 | const sum = cartItems.reduce((prev, item) => { 12 | const qty = item.quantity; 13 | return prev + qty; 14 | }, 0); 15 | return Math.round(sum); 16 | } 17 | 18 | export function calcCartItemTotalPrice(cartItems: shoppingCartItemProps[]) { 19 | const sum = cartItems.reduce((prev, item) => { 20 | const qty = item.quantity; 21 | const unitPrice = parseFloat(item.price); 22 | const total = qty * unitPrice; 23 | return prev + total; 24 | }, 0); 25 | return roundAt2DecimalPlaces(sum); 26 | } 27 | 28 | export function roundAt2DecimalPlaces(num: number) { 29 | return Math.round((num + Number.EPSILON) * 100) / 100; 30 | } 31 | 32 | export function roundHalf(num: number) { 33 | return Math.round(num * 2) / 2; 34 | } 35 | 36 | export function isInDesiredForm(str: string) { 37 | var n = Math.floor(Number(str)); 38 | return n !== Infinity && String(n) === str && n >= 0; 39 | } 40 | 41 | export function upperCaseEachWord(str: string) { 42 | return str.replace(/\w\S*/g, (w) => w.replace(/^\w/, (c) => c.toUpperCase())); 43 | } 44 | 45 | export function checkIsValidInteger(str: string) { 46 | return /^[0-9]+$/.test(str); 47 | } 48 | -------------------------------------------------------------------------------- /components/v2/Chips/FilteredChips.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { XMarkIcon } from '@heroicons/react/24/outline'; 3 | import { SetterOrUpdater } from 'recoil'; 4 | 5 | export function Chip(props: any) { 6 | const { label, onDelete } = props; 7 | 8 | return ( 9 |
10 | {label} 11 | 16 |
17 | ); 18 | } 19 | 20 | export const FilteredChips = (props: { 21 | data: { page: number; type: string; sort: string; size: number }; 22 | onChange: SetterOrUpdater<{ 23 | page: number; 24 | type: string; 25 | sort: string; 26 | size: number; 27 | }>; 28 | }) => { 29 | const { data, onChange } = props; 30 | const handleDelete = (key: 'type' | 'sort') => { 31 | onChange((originData) => ({ ...originData, [key]: '' })); 32 | }; 33 | return ( 34 |
35 | {data.type && ( 36 | { 41 | handleDelete('type'); 42 | }} 43 | /> 44 | )} 45 | {data.sort && ( 46 | { 49 | handleDelete('sort'); 50 | }} 51 | /> 52 | )} 53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | // './src/app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [ 19 | require('@tailwindcss/typography'), 20 | require('@tailwindcss/aspect-ratio'), 21 | require('daisyui'), 22 | ], 23 | daisyui: { 24 | themes: false, // true: all themes | false: only light + dark | array: specific themes like this ["light", "dark", "cupcake"] 25 | darkTheme: 'dark', // name of one of the included themes for dark mode 26 | base: true, // applies background color and foreground color for root element by default 27 | styled: true, // include daisyUI colors and design decisions for all components 28 | utils: true, // adds responsive and modifier utility classes 29 | rtl: false, // rotate style direction from left-to-right to right-to-left. You also need to add dir="rtl" to your html tag and install `tailwindcss-flip` plugin for Tailwind CSS. 30 | prefix: '', // prefix for daisyUI classnames (components, modifiers and responsive class names. Not colors) 31 | logs: true, // Shows info about daisyUI version and used config in the console when building your CSS 32 | }, 33 | }; 34 | export default config; 35 | -------------------------------------------------------------------------------- /pages/api/orders/[id].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | import prisma from '../../../lib/prisma' 4 | 5 | const orderDetailHandler = async ( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) => { 9 | switch(req.method) { 10 | case 'GET': 11 | try { 12 | res.status(200).json(await getOrderDetail(req)); 13 | } catch (err:any) { 14 | console.error(err) 15 | res.status(500).json({ 16 | message: err.message 17 | }); 18 | } 19 | break; 20 | default: 21 | res.status(401).json({ 22 | message: `HTTP method ${req.method} is not supported.` 23 | }); 24 | } 25 | } 26 | 27 | async function getOrderDetail(req: NextApiRequest) { 28 | // Get orderID; 29 | if (typeof req.query.id !== 'string' && typeof req.query.id !== 'number') { 30 | throw new Error('Invalid parameter `id`.'); 31 | } 32 | const orderId = Number(req.query.id); 33 | 34 | // Get record by unique identifier. 35 | // Reference: https://www.prisma.io/docs/concepts/components/prisma-client/crud#get-record-by-compound-id-or-compound-unique-identifier 36 | const order: any = await prisma.order.findUnique({ 37 | where: { 38 | id: orderId 39 | }, 40 | include: { 41 | user: { 42 | select: { 43 | id: true, 44 | nickname: true 45 | } 46 | }, 47 | book: true 48 | }, 49 | }); 50 | 51 | return order; 52 | } 53 | 54 | export default orderDetailHandler; 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tidb-prisma-vercel-demo", 3 | "version": "1.0.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=18.0.0" 7 | }, 8 | "scripts": { 9 | "build": "prisma generate && next build", 10 | "dev": "next dev", 11 | "prisma:deploy": "prisma migrate deploy", 12 | "prisma:generate": "prisma generate", 13 | "setup": "NODE_OPTIONS='--experimental-json-modules' node ./scripts/setup.mjs", 14 | "start": "next start", 15 | "vercel-build": "export DATABASE_URL=$(node ./scripts/env.mjs) && yarn run prisma:deploy && yarn run setup && yarn run build" 16 | }, 17 | "dependencies": { 18 | "@faker-js/faker": "^7.6.0", 19 | "@heroicons/react": "^2.0.18", 20 | "@mui/lab": "^5.0.0-alpha.93", 21 | "@prisma/client": "^4.5.0", 22 | "@tailwindcss/typography": "^0.5.9", 23 | "axios": "^1.7.7", 24 | "dotenv": "^16.0.3", 25 | "lodash": "^4.17.21", 26 | "next": "^13.4.13", 27 | "notistack": "^3.0.1", 28 | "postcss": "^8.4.27", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "recoil": "^0.7.5" 32 | }, 33 | "devDependencies": { 34 | "@tailwindcss/aspect-ratio": "^0.4.2", 35 | "@types/lodash": "^4.14.197", 36 | "@types/node": "^18.6.3", 37 | "@types/react": "^18.0.15", 38 | "@types/react-dom": "^18.0.6", 39 | "autoprefixer": "^10.4.14", 40 | "daisyui": "^3.5.1", 41 | "eslint": "8.20.0", 42 | "eslint-config-next": "12.2.2", 43 | "prisma": "^4.5.0", 44 | "tailwindcss": "^3.3.3", 45 | "typescript": "4.7.4" 46 | }, 47 | "packageManager": "yarn@4.3.1+sha512.af78262d7d125afbfeed740602ace8c5e4405cd7f4735c08feb327286b2fdb2390fbca01589bfd1f50b1240548b74806767f5a063c94b67e431aabd0d86f7774" 48 | } 49 | -------------------------------------------------------------------------------- /components/v2/List/ShoppingCartList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { InformationCircleIcon } from '@heroicons/react/24/outline'; 3 | 4 | import { useRecoilState } from 'recoil'; 5 | import { shoppingCartState } from 'atoms'; 6 | import { calcCartItemSum, calcCartItemTotalPrice } from 'lib/utils'; 7 | import ShoppingCartListItem from 'components/v2/List/ShoppingCartListItem'; 8 | 9 | export default function ShoppingCartList() { 10 | const [shoppingCart] = useRecoilState(shoppingCartState); 11 | 12 | return ( 13 |
14 | {shoppingCart.map((cartItem) => ( 15 | 16 | ))} 17 | {!!shoppingCart.length && ( 18 | 22 | )} 23 | {!shoppingCart.length && } 24 |
25 | ); 26 | } 27 | 28 | const EmptyCartAlert = () => { 29 | return ( 30 | <> 31 |
32 | 33 | Your shopping cart is empty. 34 |
35 | 36 | ); 37 | }; 38 | 39 | const SubTotal = (props: { sum: number; price: number }) => { 40 | const { sum, price } = props; 41 | 42 | return ( 43 |
44 |

45 | 46 | {sum === 1 47 | ? `Subtotal: (${sum} item) $` 48 | : `Subtotal: (${sum} items) $`} 49 | 50 | {price} 51 |

52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /components/v2/Cards/ShoppingItemCardList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useRecoilState, useRecoilValueLoadable } from 'recoil'; 4 | 5 | import ShoopingItemCard from 'components/v2/Cards/ShoppingItemCard'; 6 | import { homePageBookSumState } from 'atoms'; 7 | import { homePageQuery } from 'selectors'; 8 | 9 | export interface BookListProps { 10 | page: number; 11 | pageSize: number; 12 | } 13 | 14 | export default function BookList(props: BookListProps) { 15 | const { page, pageSize } = props; 16 | const bookListLoadable = useRecoilValueLoadable(homePageQuery); 17 | const [homePageBookSum, setHomePageBookSum] = useRecoilState(homePageBookSumState); 18 | switch (bookListLoadable.state) { 19 | case 'hasValue': 20 | setHomePageBookSum(bookListLoadable.contents.total); 21 | return ( 22 | <> 23 | {!!homePageBookSum && ( 24 |
{`${ 25 | pageSize * (page - 1) + 1 26 | } ~ ${ 27 | pageSize * page > homePageBookSum 28 | ? homePageBookSum 29 | : pageSize * page 30 | } of over ${homePageBookSum} results`}
31 | )} 32 |
33 | {bookListLoadable.contents?.content?.map((book) => ( 34 | 35 | ))} 36 |
37 | 38 | ); 39 | case 'loading': 40 | return ( 41 |
42 | 43 |
44 | ); 45 | case 'hasError': 46 | throw bookListLoadable.contents; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { homePageBookSumState, homePageQueryState } from 'atoms'; 4 | 5 | import CommonLayout from 'components/v2/Layout'; 6 | import { FilteredChips } from 'components/v2/Chips/FilteredChips'; 7 | import Head from 'next/head'; 8 | import type { NextPage } from 'next'; 9 | import { PAGE_SIZE } from 'const'; 10 | import Pagination from 'components/v2/Pagination'; 11 | import { Suspense } from 'react'; 12 | import dynamic from 'next/dynamic'; 13 | import { useRecoilState } from 'recoil'; 14 | 15 | const BookList = dynamic(import('components/v2/Cards/ShoppingItemCardList'), { ssr: false }) 16 | 17 | const Home: NextPage = () => { 18 | const [homePageQueryData, setHomePageQueryData] = 19 | useRecoilState(homePageQueryState); 20 | const [homePageBookSum] = useRecoilState(homePageBookSumState); 21 | 22 | const handleClickPagination = (page: number) => { 23 | setHomePageQueryData({ ...homePageQueryData, page }); 24 | }; 25 | 26 | return ( 27 | <> 28 | 29 | Bookstore Home 30 | 31 | 32 | 33 | 34 | 35 | {(homePageQueryData.sort || homePageQueryData.type) && ( 36 | 40 | )} 41 | Loading...}> 42 | 43 | 44 |
45 | 50 |
51 |
52 | 53 | ); 54 | }; 55 | 56 | export default Home; 57 | -------------------------------------------------------------------------------- /pages/api/orders/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | import prisma from '../../../lib/prisma' 4 | 5 | const DEFAULT_PAGE_NUM = 1; 6 | const DEFAULT_PAGE_SIZE = 8; 7 | 8 | const orderListHandler = async ( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) => { 12 | if (req.method === 'GET') { 13 | try { 14 | res.status(200).json(await getOrderList(req)); 15 | } catch (err:any) { 16 | console.error(err) 17 | res.status(500).json({ 18 | message: err.message 19 | }) 20 | } 21 | } else { 22 | res.status(401).json({ 23 | message: `HTTP method ${req.method} is not supported.` 24 | }); 25 | } 26 | } 27 | 28 | async function getOrderList(req: NextApiRequest) { 29 | const query = parseOrderListQuery(req.query, true, true); 30 | const orders: any[] = await prisma.order.findMany({ 31 | ...query, 32 | include: { 33 | user: { 34 | select: { 35 | id: true, 36 | nickname: true 37 | } 38 | }, 39 | book: true 40 | }, 41 | }); 42 | 43 | // Counting. 44 | const total = await prisma.order.count(parseOrderListQuery(req.query)); 45 | 46 | return { 47 | content: orders, 48 | total: total 49 | } 50 | } 51 | 52 | function parseOrderListQuery(query: any, sorting: boolean = false, paging: boolean = false) { 53 | const q:any = {} 54 | 55 | q.where = {}; 56 | // TODO: get user ID for context. 57 | if (typeof query.userId === 'string') { 58 | q.where.userId = Number(query.userId); 59 | } else { 60 | throw new Error('Must provide userId.'); 61 | } 62 | 63 | // Paging. 64 | if (paging) { 65 | let page = DEFAULT_PAGE_NUM; 66 | let size = DEFAULT_PAGE_SIZE; 67 | if (typeof query.page === 'string') { 68 | page = parseInt(query.page); 69 | } 70 | if (typeof query.size === 'string') { 71 | size = parseInt(query.size); 72 | } 73 | if (size < 0 || size > 100) { 74 | throw new Error('Parameter `size` must between 0 and 100.'); 75 | } 76 | q.take = size; 77 | q.skip = (page - 1) * size; 78 | } 79 | 80 | return q; 81 | } 82 | 83 | export default orderListHandler; 84 | -------------------------------------------------------------------------------- /components/v2/Layout/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import NextLink from 'next/link'; 3 | import { 4 | Bars3Icon, 5 | ShoppingCartIcon, 6 | BookOpenIcon, 7 | } from '@heroicons/react/24/outline'; 8 | 9 | import BookTypeMenu from 'components/v2/Layout/BookTypeMenu'; 10 | import { shoppingCartState } from 'atoms'; 11 | import { useRecoilState } from 'recoil'; 12 | 13 | import { calcCartItemSum } from 'lib/utils'; 14 | 15 | export interface HeaderProps { 16 | hideMenu?: boolean; 17 | } 18 | 19 | export default function Header(props: HeaderProps) { 20 | const { hideMenu } = props; 21 | 22 | const [shoppingCart, setShoppingCart] = useRecoilState(shoppingCartState); 23 | 24 | return ( 25 | <> 26 |
27 |
28 | {!hideMenu && ( 29 |
30 | 36 | 37 |
38 | )} 39 |
40 |
41 | 42 | 43 | Bookstore 44 | 45 |
46 |
47 | 48 |
49 | 50 | 51 | {calcCartItemSum(shoppingCart)} 52 | 53 |
54 |
55 | 56 | {/* */} 62 |
63 |
64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /components/v2/Rating/HalfRating.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import clsx from 'clsx'; 3 | 4 | export interface HalfRatingProps { 5 | rating?: number; 6 | disabled?: boolean; 7 | onChange?: (rating: number) => void; 8 | } 9 | 10 | const STAR_COUNT = 5; 11 | 12 | export default function HalfRating(props: HalfRatingProps) { 13 | const { rating = 0, disabled = false, onChange } = props; 14 | 15 | const [value, setValue] = React.useState(rating); 16 | const randomId = React.useId(); 17 | 18 | const handleClick = (event: React.MouseEvent) => { 19 | const { value } = event.currentTarget; 20 | setValue(Number(value)); 21 | onChange?.(Number(value)); 22 | }; 23 | 24 | return ( 25 |
26 | {new Array(STAR_COUNT).fill(0).map((_, index) => { 27 | const checkedIdx = Math.floor(value); 28 | const isHalf = value - checkedIdx >= 0.5; 29 | 30 | return ( 31 | 32 | {index === 0 && checkedIdx === 0 && !isHalf && ( 33 | 41 | )} 42 | 52 | 62 | 63 | ); 64 | })} 65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /components/v2/BookDetails/BookRatingDeleteDialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useSnackbar } from 'notistack'; 3 | import NextRouter from 'next/router'; 4 | 5 | import { deleteRating } from 'lib/http'; 6 | 7 | export interface BookRatingDeleteDialog { 8 | bookId: string; 9 | userId: string; 10 | } 11 | 12 | const BookRatingDeleteDialog = React.forwardRef( 13 | (props: BookRatingDeleteDialog, ref: any) => { 14 | const { bookId, userId } = props; 15 | const [loading, setLoading] = React.useState(false); 16 | 17 | const { enqueueSnackbar } = useSnackbar(); 18 | 19 | const handleClose = () => { 20 | ref?.current?.close(); 21 | }; 22 | 23 | const handleDelete = async (e: any) => { 24 | e.preventDefault(); 25 | 26 | setLoading(true); 27 | const response = await deleteRating(props.bookId, props.userId); 28 | if (response.error) { 29 | enqueueSnackbar(`Error: Delete target rating.`, { 30 | variant: 'error', 31 | }); 32 | setLoading(false); 33 | handleClose(); 34 | return; 35 | } 36 | enqueueSnackbar(`The rating was successfully deleted.`, { 37 | variant: 'success', 38 | }); 39 | setLoading(false); 40 | handleClose(); 41 | NextRouter.reload(); 42 | }; 43 | 44 | return ( 45 | 46 |
47 |

48 | Delete this rating?{`[userID: ${userId}]`} 49 |

50 |

This operation is not reversible.

51 | 52 |
53 | {/* if there is a button in form, it will close the modal */} 54 | 55 | 63 |
64 |
65 |
66 | ); 67 | } 68 | ); 69 | 70 | BookRatingDeleteDialog.displayName = 'BookRatingDeleteDialog'; 71 | 72 | export default BookRatingDeleteDialog; 73 | -------------------------------------------------------------------------------- /components/v2/BookDetails/BookAddRatingDialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useSnackbar } from 'notistack'; 3 | import NextRouter from 'next/router'; 4 | 5 | import { addRatingByBookID } from 'lib/http'; 6 | import HalfRating from 'components/v2/Rating/HalfRating'; 7 | 8 | export interface BookAddRatingDialog { 9 | bookId: string; 10 | } 11 | 12 | const BookAddRatingDialog = React.forwardRef( 13 | (props: BookAddRatingDialog, ref: any) => { 14 | const { bookId } = props; 15 | const [loading, setLoading] = React.useState(false); 16 | const [value, setValue] = React.useState(null); 17 | 18 | const { enqueueSnackbar } = useSnackbar(); 19 | 20 | const handleChange = (newValue: number | null) => { 21 | setValue(newValue); 22 | }; 23 | 24 | const handleClose = () => { 25 | ref?.current?.close(); 26 | }; 27 | 28 | const handleAdd = async (e: any) => { 29 | e.preventDefault(); 30 | 31 | setLoading(true); 32 | const response = await addRatingByBookID(props.bookId, { 33 | score: value as number, 34 | }); 35 | if (response.error) { 36 | enqueueSnackbar(`Error: Add rating.`, { 37 | variant: 'error', 38 | }); 39 | setLoading(false); 40 | handleClose(); 41 | return; 42 | } 43 | enqueueSnackbar(`The rating was successfully added.`, { 44 | variant: 'success', 45 | }); 46 | setLoading(false); 47 | handleClose(); 48 | NextRouter.reload(); 49 | }; 50 | 51 | return ( 52 | 53 |
54 |

Add Rating

55 | 56 | {value} 57 | 58 |
59 | {/* if there is a button in form, it will close the modal */} 60 | 61 | 69 |
70 | 71 |
72 | ); 73 | } 74 | ); 75 | 76 | BookAddRatingDialog.displayName = 'BookAddRatingDialog'; 77 | 78 | export default BookAddRatingDialog; 79 | -------------------------------------------------------------------------------- /components/v2/Cards/ShoppingItemCard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import NextLink from 'next/link'; 3 | import Image from 'next/image'; 4 | import { VariantType, useSnackbar } from 'notistack'; 5 | import { ShoppingCartIcon } from '@heroicons/react/24/outline'; 6 | import { shoppingCartState } from 'atoms'; 7 | import { useRecoilState } from 'recoil'; 8 | 9 | import { BookProps } from 'const'; 10 | import { currencyFormat } from 'lib/utils'; 11 | import HalfRating from 'components/v2/Rating/HalfRating'; 12 | 13 | export default function ShoopingItemCard(props: BookProps) { 14 | const { 15 | id, 16 | title, 17 | type, 18 | price, 19 | averageRating = 0, 20 | authors, 21 | ratings, 22 | stock, 23 | } = props; 24 | const [shoppingCart, setShoppingCart] = useRecoilState(shoppingCartState); 25 | 26 | const { enqueueSnackbar } = useSnackbar(); 27 | 28 | const addItem = () => { 29 | setShoppingCart((oldShoppingCart) => { 30 | const existingItem = oldShoppingCart.find((i) => i.id === id); 31 | if (existingItem) { 32 | if (existingItem.quantity >= stock) { 33 | enqueueSnackbar(`Out of stock!`, { variant: 'error' }); 34 | return [...oldShoppingCart]; 35 | } 36 | const newItem = { 37 | ...existingItem, 38 | quantity: existingItem.quantity + 1, 39 | }; 40 | enqueueSnackbar(`"${title}" was successfully added.`, { 41 | variant: 'success', 42 | }); 43 | return [...oldShoppingCart.filter((i) => i.id !== id), newItem]; 44 | } 45 | enqueueSnackbar(`"${title}" was successfully added.`, { 46 | variant: 'success', 47 | }); 48 | return [ 49 | ...oldShoppingCart, 50 | { 51 | ...props, 52 | quantity: 1, 53 | }, 54 | ]; 55 | }); 56 | }; 57 | 58 | return ( 59 |
60 |
61 | {title} 67 |
68 |
69 |
70 | {' '} 71 | {type.replaceAll(`_nbsp_`, ` `).replaceAll(`_amp_`, `&`)} 72 |
73 |

{title}

74 |

75 | {authors.map((author) => author.author.name).join(`, `)} 76 |

77 | 78 |
79 | 83 | 84 | View Details 85 | 86 |
87 |
88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /components/v2/Layout/BookTypeMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useSnackbar } from 'notistack'; 3 | 4 | import { useRecoilState } from 'recoil'; 5 | import { bookTypeListState, homePageQueryState } from 'atoms'; 6 | import clsx from 'clsx'; 7 | 8 | import { SORT_VALUE } from 'const'; 9 | import { upperCaseEachWord } from 'lib/utils'; 10 | import { fetchBookTypes } from 'lib/http'; 11 | 12 | export default function BookTypeMenu() { 13 | const [loadingBookType, setLoadingBookType] = React.useState(false); 14 | 15 | const [bookTypeList, setBookTypeList] = useRecoilState(bookTypeListState); 16 | const [homePageQueryData, setHomePageQueryData] = 17 | useRecoilState(homePageQueryState); 18 | const { enqueueSnackbar } = useSnackbar(); 19 | 20 | React.useEffect(() => { 21 | const func = async () => { 22 | setLoadingBookType(true); 23 | const res = await fetchBookTypes(); 24 | const { error, content } = res; 25 | if (error) { 26 | setLoadingBookType(false); 27 | enqueueSnackbar(`Error: Fetch Book Types`, { 28 | variant: 'error', 29 | }); 30 | return; 31 | } 32 | setBookTypeList(content); 33 | setLoadingBookType(false); 34 | }; 35 | !bookTypeList.length && func(); 36 | }, [bookTypeList.length, enqueueSnackbar, setBookTypeList]); 37 | 38 | return ( 39 | <> 40 |
    44 |
  • 45 |
    Book Type
    46 |
      47 | {bookTypeList.map((bookType) => ( 48 |
    • { 51 | setHomePageQueryData({ 52 | ...homePageQueryData, 53 | page: 1, 54 | type: bookType, 55 | }); 56 | }} 57 | > 58 | 63 | {bookType.replaceAll(`_nbsp_`, ` `).replaceAll(`_amp_`, `&`)} 64 | 65 |
    • 66 | ))} 67 |
    68 |
  • 69 | 70 |
  • 71 |
    Order by
    72 |
      73 | {SORT_VALUE.map((sortType) => ( 74 |
    • { 77 | setHomePageQueryData({ 78 | ...homePageQueryData, 79 | page: 1, 80 | sort: sortType, 81 | }); 82 | }} 83 | > 84 | 89 | {upperCaseEachWord(sortType.replaceAll(`_`, ` `))} 90 | 91 |
    • 92 | ))} 93 |
    94 |
  • 95 |
96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /pages/api/books/[id]/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | import prisma from '../../../../lib/prisma' 4 | 5 | const ALLOW_UPDATE_FIELDS = ['type', 'price', 'stock', 'publishedAt'] 6 | 7 | const bookDetailHandler = async ( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) => { 11 | switch(req.method) { 12 | case 'GET': 13 | try { 14 | res.status(200).json(await getBookDetail(req)); 15 | } catch (err:any) { 16 | console.error(err) 17 | res.status(500).json({ 18 | message: err.message 19 | }); 20 | } 21 | break; 22 | case 'PUT': 23 | try { 24 | await updateBookDetail(req, res); 25 | } catch (err:any) { 26 | console.error(err) 27 | res.status(500).json({ 28 | message: err.message 29 | }); 30 | } 31 | break; 32 | default: 33 | res.status(401).json({ 34 | message: `HTTP method ${req.method} is not supported.` 35 | }); 36 | } 37 | } 38 | 39 | async function getBookDetail(req: NextApiRequest) { 40 | // Get bookID; 41 | if (typeof req.query.id !== 'string' && typeof req.query.id !== 'number') { 42 | throw new Error('Invalid parameter `id`.'); 43 | } 44 | const bookId = Number(req.query.id); 45 | 46 | // Get record by unique identifier. 47 | // Reference: https://www.prisma.io/docs/concepts/components/prisma-client/crud#get-record-by-compound-id-or-compound-unique-identifier 48 | const book: any = await prisma.book.findUnique({ 49 | where: { 50 | id: bookId 51 | } 52 | }); 53 | 54 | // Aggregation. 55 | // Reference: https://www.prisma.io/docs/concepts/components/prisma-client/aggregation-grouping-summarizing 56 | const averageRating = await prisma.rating.aggregate({ 57 | _avg: { 58 | score: true 59 | }, 60 | where: { 61 | bookId: { 62 | equals: bookId 63 | } 64 | }, 65 | }); 66 | book.averageRating = averageRating._avg.score; 67 | 68 | return book; 69 | } 70 | 71 | async function updateBookDetail(req: NextApiRequest, res: NextApiResponse) { 72 | // Get bookID; 73 | if (typeof req.query.id !== 'string' && typeof req.query.id !== 'number') { 74 | throw new Error('Invalid parameter `id`.'); 75 | } 76 | const bookId = Number(req.query.id); 77 | 78 | if (req.body == null || typeof req.body !== 'object') { 79 | throw new Error('Invalid parameters.'); 80 | } 81 | 82 | const updateData:any = {}; 83 | for (const [key, value] of Object.entries(req.body)) { 84 | if (ALLOW_UPDATE_FIELDS.includes(key)) { 85 | updateData[key] = value; 86 | } 87 | } 88 | 89 | const result = await prisma.book.update({ 90 | data: updateData, 91 | where: { 92 | id: bookId 93 | } 94 | }); 95 | 96 | res.status(200).json({ 97 | message: 'success', 98 | data: result 99 | }); 100 | } 101 | 102 | export default bookDetailHandler; 103 | -------------------------------------------------------------------------------- /prisma/migrations/20241211035943_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `authors` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `name` VARCHAR(100) NOT NULL, 5 | `gender` BOOLEAN NULL, 6 | `birth_year` SMALLINT NULL, 7 | `death_year` SMALLINT NULL, 8 | 9 | PRIMARY KEY (`id`) 10 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 11 | 12 | -- CreateTable 13 | CREATE TABLE `book_authors` ( 14 | `book_id` INTEGER NOT NULL, 15 | `author_id` INTEGER NOT NULL, 16 | 17 | PRIMARY KEY (`book_id`, `author_id`) 18 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 19 | 20 | -- CreateTable 21 | CREATE TABLE `books` ( 22 | `id` INTEGER NOT NULL AUTO_INCREMENT, 23 | `title` VARCHAR(100) NOT NULL, 24 | `type` ENUM('Magazine', 'Novel', 'Life', 'Arts', 'Comics', 'Education & Reference', 'Humanities & Social Sciences', 'Science & Technology', 'Kids', 'Sports') NOT NULL, 25 | `published_at` DATETIME(0) NOT NULL, 26 | `stock` INTEGER NOT NULL DEFAULT 0, 27 | `price` DECIMAL(15, 2) NOT NULL DEFAULT 0.0, 28 | 29 | PRIMARY KEY (`id`) 30 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 31 | 32 | -- CreateTable 33 | CREATE TABLE `orders` ( 34 | `id` INTEGER NOT NULL AUTO_INCREMENT, 35 | `book_id` INTEGER NOT NULL, 36 | `user_id` INTEGER NOT NULL, 37 | `quality` TINYINT NOT NULL, 38 | `ordered_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), 39 | 40 | INDEX `orders_book_id_idx`(`book_id`), 41 | PRIMARY KEY (`id`) 42 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 43 | 44 | -- CreateTable 45 | CREATE TABLE `ratings` ( 46 | `book_id` INTEGER NOT NULL, 47 | `user_id` INTEGER NOT NULL, 48 | `score` TINYINT NOT NULL, 49 | `rated_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), 50 | 51 | UNIQUE INDEX `uniq_book_user_idx`(`book_id`, `user_id`), 52 | PRIMARY KEY (`book_id`, `user_id`) 53 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 54 | 55 | -- CreateTable 56 | CREATE TABLE `users` ( 57 | `id` INTEGER NOT NULL AUTO_INCREMENT, 58 | `balance` DECIMAL(15, 2) NOT NULL DEFAULT 0.0, 59 | `nickname` VARCHAR(100) NOT NULL, 60 | 61 | UNIQUE INDEX `nickname`(`nickname`), 62 | PRIMARY KEY (`id`) 63 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 64 | 65 | -- AddForeignKey 66 | ALTER TABLE `book_authors` ADD CONSTRAINT `book_authors_book_id_fkey` FOREIGN KEY (`book_id`) REFERENCES `books`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 67 | 68 | -- AddForeignKey 69 | ALTER TABLE `book_authors` ADD CONSTRAINT `book_authors_author_id_fkey` FOREIGN KEY (`author_id`) REFERENCES `authors`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 70 | 71 | -- AddForeignKey 72 | ALTER TABLE `orders` ADD CONSTRAINT `orders_book_id_fkey` FOREIGN KEY (`book_id`) REFERENCES `books`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 73 | 74 | -- AddForeignKey 75 | ALTER TABLE `orders` ADD CONSTRAINT `orders_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 76 | 77 | -- AddForeignKey 78 | ALTER TABLE `ratings` ADD CONSTRAINT `ratings_book_id_fkey` FOREIGN KEY (`book_id`) REFERENCES `books`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 79 | 80 | -- AddForeignKey 81 | ALTER TABLE `ratings` ADD CONSTRAINT `ratings_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 82 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | previewFeatures = ["referentialIntegrity", "interactiveTransactions"] 4 | } 5 | 6 | // TiDB is highly compatible with the MySQL 5.7 protocol and the common features 7 | // and syntax of MySQL 5.7. 8 | // 9 | // TiDB currently does not support foreign key constraints. If you need to use the feature of 10 | // referential integrity, you can use the application layer implementation of prisma. 11 | // 12 | // Refercene: https://www.prisma.io/docs/concepts/components/prisma-schema/relations/referential-integrity#handling-the-referential-integrity-in-prisma 13 | // Related Issue [WIP]: https://github.com/pingcap/tidb/issues/18209 14 | // 15 | // More descriptions about MySQL compatibility: 16 | // Refercene: https://docs.pingcap.com/tidb/dev/mysql-compatibility 17 | 18 | datasource db { 19 | provider = "mysql" 20 | url = env("DATABASE_URL") 21 | } 22 | 23 | // 24 | // https://www.prisma.io/docs/concepts/components/prisma-schema/data-model 25 | 26 | model Author { 27 | id Int @id @default(autoincrement()) 28 | name String @db.VarChar(100) 29 | gender Boolean? 30 | birthYear Int? @db.SmallInt @map("birth_year") 31 | deathYear Int? @db.SmallInt @map("death_year") 32 | books BookAuthor[] 33 | @@map("authors") 34 | } 35 | 36 | model BookAuthor { 37 | book Book @relation(fields: [bookId], references: [id]) 38 | bookId Int @map("book_id") 39 | author Author @relation(fields: [authorId], references: [id]) 40 | authorId Int @map("author_id") 41 | 42 | @@id([bookId, authorId]) 43 | @@map("book_authors") 44 | } 45 | 46 | model Book { 47 | id Int @id @default(autoincrement()) 48 | title String @db.VarChar(100) 49 | type BookType 50 | publishedAt DateTime @db.DateTime(0) @map("published_at") 51 | stock Int @default(0) 52 | price Decimal @default(0.0) @db.Decimal(15, 2) 53 | authors BookAuthor[] 54 | ratings Rating[] 55 | orders Order[] 56 | @@map("books") 57 | } 58 | 59 | model Order { 60 | id Int @id @default(autoincrement()) 61 | book Book @relation(fields: [bookId], references: [id]) 62 | bookId Int @map("book_id") 63 | user User @relation(fields: [userId], references: [id]) 64 | userId Int @map("user_id") 65 | quality Int @db.TinyInt 66 | orderedAt DateTime @default(now()) @db.DateTime(0) @map("ordered_at") 67 | 68 | @@index([bookId]) 69 | @@map("orders") 70 | } 71 | 72 | model Rating { 73 | book Book @relation(fields: [bookId], references: [id]) 74 | bookId Int @map("book_id") 75 | user User @relation(fields: [userId], references: [id]) 76 | userId Int @map("user_id") 77 | score Int @db.TinyInt 78 | ratedAt DateTime @default(now()) @db.DateTime(0) @map("rated_at") 79 | 80 | @@id([bookId, userId]) 81 | @@unique([bookId, userId], map: "uniq_book_user_idx") 82 | @@map("ratings") 83 | } 84 | 85 | model User { 86 | id Int @id @default(autoincrement()) 87 | balance Decimal @default(0.0) @db.Decimal(15, 2) 88 | nickname String @unique(map: "nickname") @db.VarChar(100) 89 | ratings Rating[] 90 | orders Order[] 91 | 92 | @@map("users") 93 | } 94 | 95 | // Because special characters cannot be used in the schema definition of the data model. 96 | // We use `_nbsp_` to represent one space and use `_amp_` to represent `&`. 97 | 98 | enum BookType { 99 | Magazine 100 | Novel 101 | Life 102 | Arts 103 | Comics 104 | Education_nbsp__amp__nbsp_Reference @map("Education & Reference") 105 | Humanities_nbsp__amp__nbsp_Social_nbsp_Sciences @map("Humanities & Social Sciences") 106 | Science_nbsp__amp__nbsp_Technology @map("Science & Technology") 107 | Kids 108 | Sports 109 | @@map("books_type") 110 | } 111 | -------------------------------------------------------------------------------- /components/v2/BookDetails/BookInfoSection.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import NextLink from 'next/link'; 3 | import Image from 'next/image'; 4 | import { 5 | useRecoilState, 6 | useRecoilValue, 7 | useRecoilValueLoadable, 8 | useSetRecoilState, 9 | } from 'recoil'; 10 | import { HomeIcon, BookmarkIcon } from '@heroicons/react/24/outline'; 11 | 12 | import { bookInfoQuery, bookRatingQuery } from 'selectors'; 13 | import { BookDetailProps, BookRatingsProps, starLabels } from 'const'; 14 | import { currencyFormat, roundHalf } from 'lib/utils'; 15 | import BookInfoDialog from 'components/v2/BookDetails/BookInfoDialog'; 16 | 17 | export default function BookInfoSection() { 18 | const [bookDetailsState, setBookDetailsState] = React.useState< 19 | BookDetailProps | undefined 20 | >(); 21 | const editBookDetailDialogRef = React.useRef(null); 22 | 23 | const bookDetailsLodable = useRecoilValueLoadable(bookInfoQuery); 24 | 25 | const handleUpdate = (data: BookDetailProps) => { 26 | setBookDetailsState(data); 27 | }; 28 | 29 | switch (bookDetailsLodable.state) { 30 | case 'hasValue': 31 | const data = bookDetailsLodable.contents.content; 32 | return ( 33 | <> 34 |
35 |
    36 |
  • 37 | 38 | 39 | Book 40 | 41 |
  • 42 |
  • 43 | 44 | {data.title} 45 |
  • 46 |
47 |
48 | 49 |
50 |
51 | {`book 57 |
58 |

{data.title}

59 |

60 | Type: 61 | {data.type.replaceAll(`_nbsp_`, ` `).replaceAll(`_amp_`, `&`)} 62 |

63 |

64 | 65 | Publication date: 66 | 67 | {new Date(data.publishedAt).toLocaleDateString()} 68 |

69 |

70 | Price: 71 | {`$ ${currencyFormat(data.price)}`} 72 |

73 |

74 | In stock: 75 | {bookDetailsState?.stock || data.stock} 76 |

77 | 85 |
86 |
87 |
88 | 89 | {data && ( 90 | 97 | )} 98 | 99 | ); 100 | case 'loading': 101 | return ( 102 | <> 103 |
104 | 105 |
106 | 107 | ); 108 | case 'hasError': 109 | throw bookDetailsLodable.contents; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TiDB example with Prisma and Vercel 5 | 9 | 53 | 54 | 55 |
56 |
57 |

58 | TiDB example with Prisma and Vercel 59 |

60 | 66 | 72 | 78 | 84 | 85 |
86 |
87 | 88 |
89 | 92 |
93 | 94 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bookshop Demo 2 | 3 | Bookshop is a virtual online bookstore application through which you can find books of various categories and rate the books. 4 | 5 | You can perform CRUD operations such as viewing book details, adding and deleting ratings, editing book inventory, etc. 6 | 7 | > Powered by TiDB Cloud, Prisma and Vercel. 8 | 9 | ## 🔥 Visit Live Demo 10 | 11 | [👉 Click here to visit](https://tidb-prisma-vercel-demo.vercel.app/) 12 | 13 | ![image](https://github.com/pingcap/tidb-prisma-vercel-demo/assets/56986964/2ef5fd7f-9023-45f4-b639-f4ba4ddec157) 14 | 15 | ## Deploy on Vercel 16 | 17 | ## 🧑‍🍳 Before We Start 18 | 19 | Create a [TiDB Cloud](https://tidbcloud.com/) account and get your free trial cluster. 20 | 21 | ### 🚀 One Click Deploy 22 | 23 | You can click the button to quickly deploy this demo if already has an TiDB Cloud cluster. 24 | 25 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?demo-title=TiDB%20Cloud%20Starter&demo-description=A%20bookstore%20demo%20built%20on%20TiDB%20Cloud%20and%20Next.js.&demo-url=https%3A%2F%2Ftidb-prisma-vercel-demo.vercel.app%2F&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F2HMASOQn8hQit2IFi2hK3j%2Fcfe7cc2aeba4b8f6760a3ea14c32f707%2Fscreenshot-20220902-160324_-_Chen_Zhen.png&project-name=TiDB%20Cloud%20Starter&repository-name=tidb-cloud-starter&repository-url=https%3A%2F%2Fgithub.com%2Fpingcap%2Ftidb-prisma-vercel-demo&from=templates&integration-ids=oac_coKBVWCXNjJnCEth1zzKoF1j) 26 | 27 | > Integration will guide you connect your TiDB Cloud cluster to Vercel. 28 | 29 |
30 |

Manually Deploy (Not recommended)

31 | 32 | #### 1. Get connection details 33 | 34 | You can get the connection details by clicking the `Connect` button. 35 | 36 | ![image](https://github.com/pingcap/tidb-prisma-vercel-demo/assets/56986964/86e5df8d-0d61-49ca-a1a8-d53f2a3f618c) 37 | 38 | Get `User` and `Host` field from the dialog. 39 | 40 | > Note: For importing initial data from local, you can set an Allow All traffic filter here by entering an IP address of `0.0.0.0/0`. 41 | 42 | ![image](https://github.com/pingcap/tidb-prisma-vercel-demo/assets/56986964/8d32ed58-4edb-412f-8af8-0e1303cceed9) 43 | 44 | Your `DATABASE_URL` should look like `mysql://:@:4000/bookshop` 45 | 46 | #### 2. Deploy on Vercel 47 | 48 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fpingcap%2Ftidb-prisma-vercel-demo&repository-name=tidb-prisma-vercel-demo&env=DATABASE_URL&envDescription=TiDB%20Cloud%20connection%20string&envLink=https%3A%2F%2Fdocs.pingcap.com%2Ftidb%2Fdev%2Fdev-guide-build-cluster-in-cloud&project-name=tidb-prisma-vercel-demo) 49 | 50 | ![image](https://user-images.githubusercontent.com/56986964/199161016-2d236629-bb6a-4e3c-a700-c0876523ca6a.png) 51 | 52 |
53 | 54 | ## Deploy on AWS Linux 55 | 56 | ### Install git and nodejs pkgs 57 | 58 | ```bash 59 | sudo yum install -y git 60 | 61 | # Ref: https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-up-node-on-ec2-instance.html 62 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash; 63 | source ~/.bashrc; 64 | nvm install --lts; 65 | node -e "console.log('Running Node.js ' + process.version)" 66 | ``` 67 | 68 | ### Clone the repository 69 | 70 | ```bash 71 | git clone https://github.com/pingcap/tidb-prisma-vercel-demo.git; 72 | cd tidb-prisma-vercel-demo; 73 | ``` 74 | 75 | ### Install dependencies 76 | 77 | ```bash 78 | corepack enable; 79 | corepack yarn install; 80 | yarn; 81 | ``` 82 | 83 | ### Connect to TiDB Cloud and create a database 84 | 85 | ```bash 86 | mysql -h gateway01.us-west-2.prod.aws.tidbcloud.com -P 4000 -u user -p 87 | ``` 88 | 89 | ``` 90 | mysql> create database tidb_labs_bookshop; 91 | ``` 92 | 93 | ### Set environment variables 94 | 95 | ```bash 96 | export DATABASE_URL=mysql://user:pass@gateway01.us-west-2.prod.aws.tidbcloud.com:4000/tidb_labs_bookshop 97 | ``` 98 | 99 | ### Build the project 100 | 101 | ```bash 102 | yarn run prisma:deploy && yarn run setup && yarn run build 103 | ``` 104 | 105 | ### Start the server 106 | 107 | ```bash 108 | yarn start 109 | ``` 110 | 111 | ### Open the browser 112 | 113 | Open the browser and visit `http://:3000`. 114 | 115 | ## 📖 Development Reference 116 | 117 | ### Prisma 118 | 119 | [Prisma Deployment Guide](https://www.prisma.io/docs/guides/deployment/deploying-to-vercel) 120 | 121 | ### Bookshop Schema 122 | 123 | [Bookshop Schema Design](https://docs.pingcap.com/tidbcloud/dev-guide-bookshop-schema-design) 124 | -------------------------------------------------------------------------------- /pages/api/books/[id]/buy.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | import prisma from '../../../../lib/prisma' 4 | 5 | const buyBookHandler = async ( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) => { 9 | if (req.method === 'POST') { 10 | try { 11 | const result = await buyBook(req); 12 | res.status(result.status).json({ 13 | message: result.message, 14 | data: result.data 15 | }); 16 | } catch (err:any) { 17 | console.error(err) 18 | res.status(500).json({ 19 | message: err.message 20 | }) 21 | } 22 | } else { 23 | res.status(401).json({ 24 | message: `HTTP method ${req.method} is not supported.` 25 | }); 26 | } 27 | } 28 | 29 | async function buyBook(req:NextApiRequest): Promise { 30 | // Get bookID; 31 | if (typeof req.query.id !== 'string' && typeof req.query.id !== 'number') { 32 | throw new Error('Invalid parameter `id`.'); 33 | } 34 | const bookId = Number(req.query.id); 35 | 36 | // Get quality; 37 | if (typeof req.query.quality !== 'string' && typeof req.query.quality !== 'number') { 38 | throw new Error('Invalid parameter `num`.'); 39 | } 40 | const quality = Math.floor(Number(req.query.quality)); 41 | if (quality <= 0) { 42 | throw new Error('Parameter `quality` must greater than zero.'); 43 | } 44 | 45 | // TODO: get user ID from context. 46 | if (typeof req.query.userId !== 'string' && typeof req.query.userId !== 'number') { 47 | throw new Error('Invalid parameter `userId`.'); 48 | } 49 | const userId = Number(req.query.userId); 50 | 51 | try { 52 | const result = await prisma.$transaction(async tx => { 53 | // Found the book that the user want to purchase. 54 | const book = await tx.book.findFirst({ 55 | where: { 56 | id: bookId 57 | }, 58 | }); 59 | 60 | if (book === undefined || book === null) { 61 | throw new Error(`Can not found the book <${bookId}> that you want to buy.`); 62 | } 63 | 64 | // Check if has enough books for the user purchase. 65 | const stock = book.stock; 66 | if (quality > stock) { 67 | throw new Error(`Didn't have enough stock of book <${bookId}> for your purchase.`); 68 | } 69 | 70 | // Cost the user balance to buy the book. 71 | const cost = book?.price.mul(quality).toNumber(); 72 | const purchaser = await tx.user.update({ 73 | data: { 74 | balance: { 75 | decrement: cost, 76 | }, 77 | }, 78 | where: { 79 | id: userId, 80 | }, 81 | }); 82 | if (purchaser.balance.lt(0)) { 83 | throw new Error(`User <${userId}> doesn't have enough money to buy book <${bookId}>, which need to cost ${cost}.`) 84 | } 85 | 86 | // Update the book stock. 87 | const newBook = await tx.book.update({ 88 | data: { 89 | stock: { 90 | decrement: 1, 91 | } 92 | }, 93 | where: { 94 | id: bookId 95 | } 96 | }); 97 | if (newBook.stock < 0) { 98 | throw new Error(`The book ${newBook.stock} is out of stock.`); 99 | } 100 | 101 | // Generate a new order to record. 102 | const order = await tx.order.create({ 103 | data: { 104 | userId: userId, 105 | bookId: bookId, 106 | quality: quality 107 | } 108 | }) 109 | 110 | return { 111 | userId: userId, 112 | bookId: bookId, 113 | bookTitle: book.title, 114 | cost: cost, 115 | remaining: purchaser.balance, 116 | orderId: order.id 117 | }; 118 | }); 119 | return { 120 | status: 200, 121 | message: `User <${userId}> buy ${quality} books <${bookId}> successfully, cost: ${result.cost}, remain: ${result.remaining} .`, 122 | data: result 123 | }; 124 | } catch(err: any) { 125 | console.error(err); 126 | return { 127 | status: 500, 128 | message: `Failed to buy book ${bookId} for user ${userId}: ${err.message}` 129 | }; 130 | } 131 | } 132 | 133 | export default buyBookHandler; 134 | -------------------------------------------------------------------------------- /lib/http.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { BookProps, BookDetailProps, BookRatingsProps } from 'const'; 3 | 4 | export async function fetchBooks(data: { 5 | page?: number; 6 | size?: number; 7 | type?: string; 8 | sort?: string; 9 | }): Promise<{ content: BookProps[]; total: number; error?: any }> { 10 | try { 11 | const queryArray = Object.keys(data).reduce((prev: string[], item) => { 12 | const value = data[item as keyof typeof data]; 13 | if (value) { 14 | prev.push(`${item}=${value}`); 15 | } 16 | return prev; 17 | }, []); 18 | const response = await axios.get(`/api/books?${queryArray.join(`&`)}`); 19 | if (response.status !== 200) { 20 | throw new Error(`${response.status} - ${response.data}`); 21 | } 22 | return response.data; 23 | } catch (error) { 24 | console.error(error); 25 | return { error, content: [], total: 0 }; 26 | } 27 | } 28 | 29 | export async function fetchBookTypes(): Promise<{ 30 | content: string[]; 31 | error?: any; 32 | }> { 33 | try { 34 | const response = await axios.get(`/api/books/types`); 35 | if (response.status !== 200) { 36 | throw new Error(`${response.status} - ${response.data}`); 37 | } 38 | return { content: response.data as string[] }; 39 | } catch (error) { 40 | console.error(error); 41 | return { error, content: [] }; 42 | } 43 | } 44 | 45 | export async function fetchBookDetailsById(id: string): Promise<{ 46 | content: BookDetailProps; 47 | error?: any; 48 | }> { 49 | try { 50 | const response = await axios.get(`/api/books/${id}`); 51 | if (response.status !== 200) { 52 | throw new Error(`${response.status} - ${response.data}`); 53 | } 54 | return { content: response.data as BookDetailProps }; 55 | } catch (error) { 56 | console.error(error); 57 | return { error, content: {} as BookDetailProps }; 58 | } 59 | } 60 | 61 | export async function fetchBookRatingsById(id: string): Promise<{ 62 | content: { content: BookRatingsProps[]; total: number }; 63 | error?: any; 64 | }> { 65 | try { 66 | const response = await axios.get(`/api/books/${id}/ratings`); 67 | if (response.status !== 200) { 68 | throw new Error(`${response.status} - ${response.data}`); 69 | } 70 | return { content: response.data }; 71 | } catch (error) { 72 | console.error(error); 73 | return { error, content: { content: [], total: 0 } }; 74 | } 75 | } 76 | 77 | export async function updateBookDetails( 78 | id: string, 79 | params: Partial 80 | ): Promise<{ 81 | content?: { data: BookDetailProps; message: string }; 82 | error?: any; 83 | }> { 84 | try { 85 | const response = await axios.put(`/api/books/${id}`, params); 86 | if (response.status !== 200) { 87 | throw new Error(`${response.status} - ${response.data}`); 88 | } 89 | return { content: response.data }; 90 | } catch (error) { 91 | console.error(error); 92 | return { error }; 93 | } 94 | } 95 | 96 | export async function addRatingByBookID( 97 | bookID: string, 98 | params: { 99 | score: number; 100 | } 101 | ): Promise<{ 102 | content?: { data: Omit; message: string }; 103 | error?: any; 104 | }> { 105 | try { 106 | const response = await axios.post(`/api/books/${bookID}/ratings`, params); 107 | if (response.status !== 200) { 108 | throw new Error(`${response.status} - ${response.data}`); 109 | } 110 | return { content: response.data }; 111 | } catch (error) { 112 | console.error(error); 113 | return { error }; 114 | } 115 | } 116 | 117 | export async function deleteRating( 118 | bookID: string, 119 | userID: string 120 | ): Promise<{ 121 | content?: { message: string }; 122 | error?: any; 123 | }> { 124 | try { 125 | const response = await axios.delete( 126 | `/api/books/${bookID}/ratings?userId=${userID}` 127 | ); 128 | if (response.status !== 200) { 129 | throw new Error(`${response.status} - ${response.data}`); 130 | } 131 | return { content: response.data }; 132 | } catch (error) { 133 | console.error(error); 134 | return { error }; 135 | } 136 | } 137 | 138 | export async function buyBook( 139 | bookID: string, 140 | params: { userID: string; quality: number } 141 | ): Promise<{ 142 | content?: { message: string }; 143 | error?: any; 144 | }> { 145 | try { 146 | const response = await axios.post( 147 | `/api/books/${bookID}/buy?userId=${params.userID}&quality=${params.quality}` 148 | ); 149 | if (response.status !== 200) { 150 | throw new Error(`${response.status} - ${response.data}`); 151 | } 152 | return { content: response.data }; 153 | } catch (error) { 154 | console.error(error); 155 | return { error }; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /pages/api/books/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | import { BookType } from '@prisma/client'; 4 | import prisma from '../../../lib/prisma' 5 | 6 | const DEFAULT_PAGE_NUM = 1; 7 | const DEFAULT_PAGE_SIZE = 8; 8 | 9 | enum SortType { 10 | PRICE = 'price', 11 | PUBLISHED_AT = 'publishedAt' 12 | }; 13 | enum SortOrder { 14 | ASC = 'asc', 15 | DESC = 'desc' 16 | }; 17 | const sortTypes = Object.values(SortType); 18 | const sortOrders = Object.values(SortOrder); 19 | const bookTypes = Object.keys(BookType); 20 | 21 | const bookListHandler = async ( 22 | req: NextApiRequest, 23 | res: NextApiResponse 24 | ) => { 25 | if (req.method === 'GET') { 26 | try { 27 | res.status(200).json(await getBookList(req)); 28 | } catch (err:any) { 29 | console.error(err) 30 | res.status(500).json({ 31 | message: err.message 32 | }) 33 | } 34 | } else { 35 | res.status(401).json({ 36 | message: `HTTP method ${req.method} is not supported.` 37 | }); 38 | } 39 | } 40 | 41 | async function getBookList(req: NextApiRequest) { 42 | // Querying with joins (Many to many relation). 43 | const query = parseBookListQuery(req.query, true, true); 44 | const books: any[] = await prisma.book.findMany({ 45 | ...query, 46 | include: { 47 | authors: { 48 | select: { 49 | author: { 50 | select: { 51 | id: true, 52 | name: true 53 | } 54 | } 55 | } 56 | }, 57 | }, 58 | }); 59 | const bookIds = books.map((b) => b.id); 60 | 61 | // Grouping. 62 | // 63 | // Calculate the average rating score for the books in the result. 64 | // 65 | // Notice: It is more suitable to add column named `average_rating` in books table to store 66 | // the average rating score, which can avoid the need to query every time you use it, and 67 | // it is easier to implement the sorting feature. 68 | const bookAverageRatings = await prisma.rating.groupBy({ 69 | by: ['bookId'], 70 | _avg: { 71 | score: true 72 | }, 73 | where: { 74 | bookId: { 75 | in: bookIds 76 | } 77 | }, 78 | // Why must set orderBy? 79 | orderBy: { 80 | _avg: { 81 | score: 'asc' 82 | } 83 | } 84 | }); 85 | for (const rating of bookAverageRatings) { 86 | const index = books.findIndex((b) => b.id === rating.bookId); 87 | books[index].averageRating = rating._avg.score; 88 | } 89 | 90 | const bookCountRatings = await prisma.rating.groupBy({ 91 | by: ['bookId'], 92 | _count: { 93 | bookId: true 94 | }, 95 | where: { 96 | bookId: { 97 | in: bookIds 98 | } 99 | }, 100 | orderBy: { 101 | _count: { 102 | bookId: 'asc' 103 | }, 104 | } 105 | }); 106 | for (const rating of bookCountRatings) { 107 | const index = books.findIndex((b) => b.id === rating.bookId); 108 | books[index].ratings = rating._count.bookId; 109 | } 110 | 111 | // Counting. 112 | const total = await prisma.book.count(parseBookListQuery(req.query)); 113 | 114 | return { 115 | content: books, 116 | total: total 117 | } 118 | } 119 | 120 | function parseBookListQuery(query: any, sorting: boolean = false, paging: boolean = false) { 121 | const q:any = {} 122 | 123 | // Filtering. 124 | // Reference: https://www.prisma.io/docs/concepts/components/prisma-client/filtering-and-sorting 125 | q.where = {}; 126 | if (typeof query.type === 'string') { 127 | if (!bookTypes.includes(query.type)) { 128 | throw new Error(`Parameter \`type\` must be one of [${bookTypes.join(', ')}].`); 129 | } 130 | q.where.type = query.type; 131 | } 132 | 133 | // Sorting. 134 | if (sorting) { 135 | if (sortTypes.includes(query.sort)) { 136 | let order = SortOrder.ASC; 137 | if (sortOrders.includes(query.order)) { 138 | order = query.order 139 | } 140 | 141 | if (query.sort === SortType.PRICE) { 142 | q.orderBy = { 143 | price: order 144 | }; 145 | } else if (query.sort === SortType.PUBLISHED_AT) { 146 | q.orderBy = { 147 | publishedAt: order 148 | }; 149 | } 150 | } 151 | } 152 | 153 | // Paging. 154 | if (paging) { 155 | let page = DEFAULT_PAGE_NUM; 156 | let size = DEFAULT_PAGE_SIZE; 157 | if (typeof query.page === 'string') { 158 | page = parseInt(query.page); 159 | } 160 | if (typeof query.size === 'string') { 161 | size = parseInt(query.size); 162 | } 163 | if (size < 0 || size > 100) { 164 | throw new Error('Parameter `size` must between 0 and 100.'); 165 | } 166 | q.take = size; 167 | q.skip = (page - 1) * size; 168 | } 169 | 170 | return q; 171 | } 172 | 173 | export default bookListHandler; 174 | -------------------------------------------------------------------------------- /components/v2/BookDetails/BookInfoDialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useSnackbar } from 'notistack'; 3 | 4 | import { BookDetailProps } from 'const'; 5 | import { currencyFormat, checkIsValidInteger } from 'lib/utils'; 6 | import { updateBookDetails } from 'lib/http'; 7 | 8 | export interface BookInfoDialogProps { 9 | data: BookDetailProps; 10 | id: string; 11 | onSuccess?: (data: BookDetailProps) => void; 12 | } 13 | 14 | const BookInfoDialog = React.forwardRef( 15 | (props: BookInfoDialogProps, ref: any) => { 16 | const { data, id, onSuccess } = props; 17 | 18 | const [isStockValid, setIsStockValid] = React.useState(true); 19 | const [isUpdating, setIsUpdating] = React.useState(false); 20 | const [stock, setStock] = React.useState(data.stock); 21 | 22 | const { enqueueSnackbar } = useSnackbar(); 23 | 24 | const handleUpdateStock = (e: React.ChangeEvent) => { 25 | const value = e.target.value; 26 | try { 27 | const isValid = checkIsValidInteger(value); 28 | if (isValid) { 29 | setIsStockValid(true); 30 | setStock(parseInt(value)); 31 | } else { 32 | throw new Error('Invalid stock value'); 33 | } 34 | } catch (error) { 35 | setIsStockValid(false); 36 | } 37 | }; 38 | 39 | const handleUpdate = async (event: any) => { 40 | event.preventDefault(); 41 | 42 | setIsUpdating(true); 43 | const res = await updateBookDetails(data.id, { 44 | stock: stock, 45 | }); 46 | if (res.error) { 47 | enqueueSnackbar(`Error: Update book details.`, { 48 | variant: 'error', 49 | }); 50 | setIsUpdating(false); 51 | return; 52 | } 53 | enqueueSnackbar(`Book details was updated.`, { 54 | variant: 'success', 55 | }); 56 | res.content?.data && onSuccess && onSuccess(res.content.data); 57 | setIsUpdating(false); 58 | 59 | ref?.current?.close(); 60 | }; 61 | 62 | return ( 63 | 64 |
65 |

Edit Book Details

66 |
67 | 70 | 76 |
77 |
78 | 81 | 87 |
88 |
89 | 92 | 98 |
99 |
100 | 103 | 109 |
110 |
111 | 114 | 120 | {!isStockValid && ( 121 | 126 | )} 127 |
128 |
129 | {/* if there is a button in form, it will close the modal */} 130 | 131 | 139 |
140 |
141 |
142 | ); 143 | } 144 | ); 145 | 146 | BookInfoDialog.displayName = 'BookInfoDialog'; 147 | 148 | export default BookInfoDialog; 149 | -------------------------------------------------------------------------------- /pages/api/books/[id]/ratings.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | import { Prisma } from '@prisma/client'; 4 | import prisma from '../../../../lib/prisma' 5 | 6 | const ratingListHandler = async ( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) => { 10 | if (req.method === 'GET') { 11 | try { 12 | res.status(200).json(await getBookRatings(req)); 13 | } catch (err:any) { 14 | console.error(err) 15 | res.status(500).json({ 16 | message: err.message 17 | }) 18 | } 19 | } else if (req.method === 'POST') { 20 | try { 21 | await addBookRating(req, res); 22 | } catch (err:any) { 23 | console.error(err) 24 | res.status(500).json({ 25 | message: err.message 26 | }) 27 | } 28 | } else if (req.method === 'DELETE') { 29 | try { 30 | await removeBookRating(req, res); 31 | } catch (err:any) { 32 | console.error(err) 33 | res.status(500).json({ 34 | message: err.message 35 | }) 36 | } 37 | } else { 38 | res.status(401).json({ 39 | message: `HTTP method ${req.method} is not supported` 40 | }); 41 | } 42 | } 43 | 44 | async function getBookRatings(req: NextApiRequest) { 45 | // Get bookID; 46 | if (typeof req.query.id !== 'string' && typeof req.query.id !== 'number') { 47 | throw new Error('Invalid parameter `id`.'); 48 | } 49 | const bookId = Number(req.query.id); 50 | 51 | // Querying with joins. (Many to one relation) 52 | const ratings: any[] = await prisma.rating.findMany({ 53 | where: { 54 | bookId: bookId 55 | }, 56 | include: { 57 | user: { 58 | select: { 59 | id: true, 60 | nickname: true 61 | } 62 | } 63 | } 64 | }); 65 | 66 | // Counting. 67 | const total = await prisma.rating.count({ 68 | where: { 69 | bookId: { 70 | equals: bookId 71 | } 72 | }, 73 | }); 74 | 75 | return { 76 | content: ratings, 77 | total: total 78 | }; 79 | } 80 | 81 | async function addBookRating(req: NextApiRequest, res: NextApiResponse) { 82 | // Get bookID; 83 | if (typeof req.query.id !== 'string' && typeof req.query.id !== 'number') { 84 | throw new Error('Invalid parameter `id`.'); 85 | } 86 | const bookId = Number(req.query.id); 87 | 88 | // Get Score. 89 | if (typeof req.body.score !== 'number') { 90 | throw new Error('Invalid parameter `score`.'); 91 | } 92 | const score = parseInt(req.body.score); 93 | if (score < 0 || score > 5) { 94 | throw new Error('Parameter `score` must between 0 and 5.'); 95 | } 96 | 97 | // Get random user ID. 98 | const users:any = await prisma.$queryRaw` 99 | SELECT id FROM users ORDER BY RAND() LIMIT 1; 100 | `; 101 | const user = users[0]; 102 | if (user === undefined) { 103 | throw new Error('Can not find a user by random.'); 104 | } 105 | console.log(`Get random user ID: ${user.id}.`); 106 | 107 | // Insert a new rating record. 108 | try { 109 | const resp = await prisma.rating.create({ 110 | data: { 111 | bookId: bookId, 112 | userId: user.id, 113 | score: score, 114 | ratedAt: new Date() 115 | } 116 | }); 117 | res.status(200).json({ 118 | message: 'success', 119 | data: resp 120 | }); 121 | } catch(err: any) { 122 | // Error handling. 123 | // 124 | // Reference: https://www.prisma.io/docs/concepts/components/prisma-client/handling-exceptions-and-errors 125 | // About P2002: https://www.prisma.io/docs/reference/api-reference/error-reference#p2002 126 | if (err instanceof Prisma.PrismaClientKnownRequestError) { 127 | if (err.code === 'P2002') { 128 | res.status(200).json({ 129 | message: `User <${user.id}> has already rate for book <${bookId}>.` 130 | }); 131 | } 132 | } else { 133 | throw err 134 | } 135 | } 136 | } 137 | 138 | async function removeBookRating(req: NextApiRequest, res: NextApiResponse) { 139 | // Get bookID; 140 | if (typeof req.query.id !== 'string' && typeof req.query.id !== 'number') { 141 | throw new Error('Invalid parameter `id`.'); 142 | } 143 | const bookId = Number(req.query.id); 144 | 145 | // Get userID; 146 | if (typeof req.query.userId !== 'string' && typeof req.query.userId !== 'number') { 147 | throw new Error('Parameter `userId` must be provided.'); 148 | } 149 | let userId = Number(req.query.userId);; 150 | 151 | // Delete a single rating record. 152 | // 153 | // Reference: https://www.prisma.io/docs/concepts/components/prisma-client/crud#delete 154 | try { 155 | await prisma.rating.delete({ 156 | where: { 157 | bookId_userId: { 158 | bookId: bookId, 159 | userId: userId 160 | } 161 | } 162 | }); 163 | res.status(200).json({ 164 | message: 'success' 165 | }); 166 | } catch(err) { 167 | if (err instanceof Prisma.PrismaClientKnownRequestError) { 168 | // About P2025: https://www.prisma.io/docs/reference/api-reference/error-reference#p2025 169 | if (err.code === 'P2025') { 170 | res.status(200).json({ 171 | message: `Rating record to delete does not exist.` 172 | }); 173 | } 174 | } else { 175 | throw err 176 | } 177 | } 178 | } 179 | 180 | export default ratingListHandler; -------------------------------------------------------------------------------- /components/v2/List/ShoppingCartListItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Image from 'next/image'; 3 | import { useSnackbar } from 'notistack'; 4 | import { PlusIcon, MinusIcon, TrashIcon } from '@heroicons/react/24/outline'; 5 | 6 | import { useRecoilState } from 'recoil'; 7 | import { shoppingCartState, currentUserIdState } from 'atoms'; 8 | 9 | import { shoppingCartItemProps } from 'const'; 10 | import { currencyFormat, calcCartItemTotalPrice } from 'lib/utils'; 11 | import { buyBook } from 'lib/http'; 12 | 13 | export default function ShoppingCartListItem(props: shoppingCartItemProps) { 14 | const { 15 | id, 16 | title, 17 | authors, 18 | type, 19 | price, 20 | averageRating, 21 | quantity, 22 | stock, 23 | publishedAt, 24 | } = props; 25 | const [loading, setLoading] = React.useState(false); 26 | 27 | const [shoppingCart, setShoppingCart] = useRecoilState(shoppingCartState); 28 | const [currentUserId] = useRecoilState(currentUserIdState); 29 | 30 | const { enqueueSnackbar } = useSnackbar(); 31 | 32 | function handleAddQty() { 33 | setShoppingCart((oldShoppingCart) => { 34 | return oldShoppingCart.reduce((prev, item) => { 35 | if (item.id === id) { 36 | prev.push({ 37 | ...item, 38 | quantity: quantity + 1, 39 | }); 40 | } else { 41 | prev.push(item); 42 | } 43 | return prev; 44 | }, []); 45 | }); 46 | } 47 | 48 | function handleRemoveQty() { 49 | setShoppingCart((oldShoppingCart) => { 50 | return oldShoppingCart.reduce((prev, item) => { 51 | if (item.id === id) { 52 | prev.push({ 53 | ...item, 54 | quantity: quantity - 1, 55 | }); 56 | } else { 57 | prev.push(item); 58 | } 59 | return prev; 60 | }, []); 61 | }); 62 | } 63 | 64 | function deleteItem() { 65 | setShoppingCart((oldShoppingCart) => { 66 | return [...oldShoppingCart.filter((i) => i.id !== id)]; 67 | }); 68 | } 69 | 70 | const handleBuyClick = async () => { 71 | setLoading(true); 72 | const response = await buyBook(id, { 73 | userID: currentUserId, 74 | quality: quantity, 75 | }); 76 | if (response.error) { 77 | enqueueSnackbar(`Error: ${response.error}.`, { 78 | variant: 'error', 79 | }); 80 | setLoading(false); 81 | return; 82 | } 83 | enqueueSnackbar(`${response.content?.message}`, { 84 | variant: 'success', 85 | }); 86 | setLoading(false); 87 | setShoppingCart((oldShoppingCart) => { 88 | return oldShoppingCart.filter((i) => i.id !== id); 89 | }); 90 | }; 91 | 92 | return ( 93 | <> 94 |
95 |
96 | {title} 102 |
103 |
104 |
105 |

106 | Title: 107 | {title} 108 |

109 |

110 | Type: 111 | {type.replaceAll(`_nbsp_`, ` `).replaceAll(`_amp_`, `&`)} 112 |

113 |

114 | Publication date: 115 | {new Date(publishedAt).toLocaleDateString()} 116 |

117 |

118 | Price: 119 | {`$ ${currencyFormat(price)}`} 120 |

121 |

122 | In stock: 123 | {stock} 124 |

125 |
126 |
127 | 134 | 139 | 146 |
147 |
148 |
149 | 150 | {quantity === 1 151 | ? `(${quantity} item) $` 152 | : `(${quantity} items) $`} 153 | 154 | {calcCartItemTotalPrice([props])} 155 |
156 |
157 |
158 |
159 | 163 | 171 |
172 |
173 |
174 |
175 | 176 | ); 177 | } 178 | -------------------------------------------------------------------------------- /scripts/setup.mjs: -------------------------------------------------------------------------------- 1 | import { BookType, PrismaClient } from '@prisma/client'; 2 | 3 | import dotenv from 'dotenv'; 4 | import { faker } from '@faker-js/faker'; 5 | 6 | dotenv.config(); 7 | 8 | const { TIDB_USER, TIDB_PASSWORD, TIDB_HOST, TIDB_PORT, TIDB_DB_NAME = 'bookshop', DATABASE_URL } = process.env; 9 | // Notice: When using TiDb Cloud Serverless Tier, you **MUST** set the following flags to enable tls connection. 10 | const SSL_FLAGS = 'pool_timeout=60&sslaccept=accept_invalid_certs'; 11 | // TODO: When TiDB Cloud support return DATABASE_URL, we can remove it. 12 | const databaseURL = DATABASE_URL 13 | ? `${DATABASE_URL}?${SSL_FLAGS}` 14 | : `mysql://${TIDB_USER}:${TIDB_PASSWORD}@${TIDB_HOST}:${TIDB_PORT}/${TIDB_DB_NAME}?${SSL_FLAGS}`; 15 | 16 | const setup = async () => { 17 | let client; 18 | 19 | try { 20 | client = new PrismaClient({ 21 | datasources: { 22 | db: { 23 | url: databaseURL 24 | } 25 | } 26 | }); 27 | await client.$connect(); 28 | 29 | const hasData = await client.user.count() > 0; 30 | 31 | if (hasData) { 32 | console.log('Database already exists with data'); 33 | client.$disconnect(); 34 | return; 35 | } 36 | 37 | // Seed data. 38 | const users = await seedUsers(client, 20); 39 | const authors = await seedAuthors(client, 20); 40 | const books = await seedBooks(client, 100); 41 | await seedBooksAndAuthors(client, books, authors); 42 | await seedRatings(client, books, users); 43 | } catch (error) { 44 | throw error; 45 | } finally { 46 | if (client) { 47 | await client.$disconnect(); 48 | } 49 | } 50 | }; 51 | 52 | // Seed users data. 53 | async function seedUsers(client, num) { 54 | const records = [...Array(num)].map((value, index) => { 55 | const id = index + 1; 56 | const nickname = faker.internet.userName(); 57 | const balance = faker.random.numeric(6); 58 | 59 | return { 60 | id, 61 | nickname, 62 | balance 63 | }; 64 | }); 65 | 66 | const added = await client.user.createMany({ 67 | data: records, 68 | skipDuplicates: true 69 | }); 70 | 71 | if (added.count > 0) { 72 | console.log(`Successfully inserted ${added.count} user records.`); 73 | } 74 | 75 | return records; 76 | } 77 | 78 | // Seed authors data. 79 | async function seedAuthors(client, num) { 80 | const records = [...Array(num)].map((value, index) => { 81 | const id = index + 1; 82 | const name = faker.name.fullName(); 83 | const gender = faker.datatype.boolean(); 84 | const birthYear = faker.datatype.number({ min: 1900, max: 2000 }); 85 | let deathYear = birthYear + faker.datatype.number({ min: 20, max: 100 }); 86 | if (deathYear > 100) { 87 | deathYear = undefined; 88 | } 89 | return { 90 | id, 91 | name, 92 | gender, 93 | birthYear, 94 | deathYear 95 | }; 96 | }); 97 | 98 | const added = await client.author.createMany({ 99 | data: records, 100 | skipDuplicates: true 101 | }); 102 | 103 | if (added.count > 0) { 104 | console.log(`Successfully inserted ${added.count} author records.`); 105 | } 106 | 107 | return records; 108 | } 109 | 110 | // Seed books data. 111 | const bookTypes = Object.keys(BookType); 112 | async function seedBooks(client, num) { 113 | const records = [...Array(num)].map((value, index) => { 114 | const id = index + 1; 115 | const title = faker.music.songName(); 116 | const bookTypeIndex = faker.datatype.number({ min: 0, max: bookTypes.length - 1 }); 117 | const type = bookTypes[bookTypeIndex]; 118 | const publishedAt = faker.date.between('2000-01-01T00:00:00.000Z', Date.now().toString()); 119 | const stock = faker.datatype.number({ min: 0, max: 200 }); 120 | const price = faker.datatype.number({ min: 0, max: 200, precision: 0.01 }); 121 | 122 | return { 123 | id, 124 | title, 125 | type, 126 | publishedAt, 127 | stock, 128 | price 129 | }; 130 | }); 131 | 132 | const added = await client.book.createMany({ 133 | data: records, 134 | skipDuplicates: true 135 | }); 136 | 137 | if (added.count > 0) { 138 | console.log(`Successfully inserted ${added.count} book records.`); 139 | } 140 | 141 | return records; 142 | } 143 | 144 | // Seed books and authors data. 145 | async function seedBooksAndAuthors(client, books, authors) { 146 | const records = books.map((book) => { 147 | const authorIndex = faker.datatype.number({ min: 0, max: authors.length - 1 }); 148 | const author = authors[authorIndex]; 149 | 150 | return { 151 | bookId: book.id, 152 | authorId: author.id 153 | } 154 | }); 155 | 156 | const added = await client.bookAuthor.createMany({ 157 | data: records, 158 | skipDuplicates: true 159 | }); 160 | 161 | if (added.count > 0) { 162 | console.log(`Successfully inserted ${added.count} book and author relation records.`); 163 | } 164 | 165 | return records; 166 | } 167 | 168 | // Seed ratings data. 169 | async function seedRatings(client, books, users) { 170 | let total = 0; 171 | for (const book of books) { 172 | const ratingNum = faker.datatype.number({ min: 10, max: 30}); 173 | const bookId = book.id; 174 | const records = [...Array(ratingNum)].map(() => { 175 | const score = faker.datatype.number({ min: 1, max: 5 }); 176 | const userIndex = faker.datatype.number({ min: 1, max: users.length - 1 }); 177 | const userId = users[userIndex].id; 178 | const ratedAt = faker.date.between(book.publishedAt.toString(), Date.now().toString()); 179 | 180 | return { 181 | userId, 182 | bookId, 183 | score, 184 | ratedAt 185 | } 186 | }); 187 | 188 | const added = await client.rating.createMany({ 189 | data: records, 190 | skipDuplicates: true 191 | }); 192 | 193 | total += added.count; 194 | } 195 | 196 | if (total > 0) { 197 | console.log(`Successfully inserted ${total} rating records.`); 198 | } 199 | } 200 | 201 | try { 202 | await setup(); 203 | console.log('Setup completed.'); 204 | } catch(error) { 205 | console.warn('Database is not ready yet. Skipping seeding...\n', error); 206 | } 207 | 208 | export { setup }; -------------------------------------------------------------------------------- /components/v2/BookDetails/BookReviewsSection.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useRecoilState, useRecoilValueLoadable } from 'recoil'; 3 | 4 | import { bookDetailsIdState } from 'atoms'; 5 | import { bookRatingQuery } from 'selectors'; 6 | import { BookRatingsProps, starLabels } from 'const'; 7 | import { roundHalf } from 'lib/utils'; 8 | import HalfRating from 'components/v2/Rating/HalfRating'; 9 | import BookRatingDeleteDialog from 'components/v2/BookDetails/BookRatingDeleteDialog'; 10 | import BookAddRatingDialog from 'components/v2/BookDetails/BookAddRatingDialog'; 11 | 12 | export default function BookReviewsSection() { 13 | const addRatingDialogRef = React.useRef(null); 14 | 15 | const bookRatingLoadable = useRecoilValueLoadable(bookRatingQuery); 16 | const [bookDetailsId] = useRecoilState(bookDetailsIdState); 17 | 18 | switch (bookRatingLoadable.state) { 19 | case 'hasValue': 20 | const data = bookRatingLoadable.contents.content; 21 | return ( 22 | <> 23 |
24 |
25 |
26 |

Customer Reviews

27 |

28 | 29 |

30 | 38 |
39 |
40 | {data?.content?.length > 0 && ( 41 | 42 | )} 43 |
44 |
45 |
46 | 50 | 51 | ); 52 | case 'loading': 53 | return ( 54 | <> 55 |
56 | 57 |
58 | 59 | ); 60 | case 'hasError': 61 | // throw bookRatingLodable.contents; 62 | return <>; 63 | } 64 | } 65 | 66 | const ReviewOverview = (props: { content: BookRatingsProps[] }) => { 67 | const num = props.content.length; 68 | const sum = props.content.reduce((prev, item) => { 69 | return prev + item.score; 70 | }, 0); 71 | const avg = sum / num; 72 | return ( 73 |
74 |
75 | 76 |
{starLabels[roundHalf(avg)]}
77 |
78 |
{`${num} global ratings`}
79 | i.score === 5).length / num) * 100 || 0 83 | } 84 | /> 85 | i.score === 4).length / num) * 100 || 0 89 | } 90 | /> 91 | i.score === 3).length / num) * 100 || 0 95 | } 96 | /> 97 | i.score === 2).length / num) * 100 || 0 101 | } 102 | /> 103 | i.score === 1).length / num) * 100 || 0 107 | } 108 | /> 109 | i.score === 0).length / num) * 100 || 0 113 | } 114 | /> 115 |
116 | ); 117 | }; 118 | 119 | const StarPercentageBar = (props: { leftText?: string; value: number }) => { 120 | const { leftText, value = 0 } = props; 121 | const valueRound = Math.round(value); 122 | return ( 123 |
124 | {leftText && ( 125 | {leftText} 126 | )} 127 | 132 | {`${valueRound}%`} 133 |
134 | ); 135 | }; 136 | 137 | const ReviewsTable = (props: { 138 | content: BookRatingsProps[]; 139 | bookId: string; 140 | }) => { 141 | const { content, bookId } = props; 142 | const [targetUserId, setTargetUserId] = React.useState(null); 143 | 144 | const deletaDialogRef = React.useRef(null); 145 | 146 | const handleDelete = (userId: string) => () => { 147 | setTargetUserId(userId); 148 | deletaDialogRef.current?.showModal(); 149 | }; 150 | 151 | return ( 152 | <> 153 | 154 | {/* head */} 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | {/* row 1 */} 165 | {content.map((item) => { 166 | return ( 167 | <> 168 | 169 | 186 | 189 | 190 | 198 | 199 | 200 | ); 201 | })} 202 | 203 |
NameRatingDate
170 |
171 |
172 |
173 | 174 | {item.user.nickname.substring(0, 1)} 175 | 176 |
177 |
178 |
179 |
{item.user.nickname}
180 |
181 | User ID: {item.user.id} 182 |
183 |
184 |
185 |
187 | 188 | {`${new Date(item.ratedAt).toLocaleDateString()}`} 191 | 197 |
204 | {targetUserId && ( 205 | 210 | )} 211 | 212 | ); 213 | }; 214 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2022] [Pingcap] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------