├── public ├── favicon.ico ├── screenshot.png └── img │ ├── logo-black.png │ ├── logo-white.png │ ├── advertisement-one.webp │ ├── hero-carousel-five.webp │ ├── hero-carousel-four.webp │ ├── hero-carousel-one.webp │ ├── hero-carousel-six.webp │ ├── hero-carousel-three.webp │ └── hero-carousel-two.webp ├── postcss.config.cjs ├── prettier.config.cjs ├── src ├── styles │ ├── globals.css │ ├── customtable.module.css │ └── searchbar.module.css ├── server │ ├── stripe │ │ ├── client.ts │ │ └── stripe-webhook-handlers.ts │ ├── cloudinary │ │ └── cloudinary.ts │ ├── trpc │ │ ├── router │ │ │ ├── admin │ │ │ │ ├── index.ts │ │ │ │ ├── orders.ts │ │ │ │ ├── users.ts │ │ │ │ └── products.ts │ │ │ ├── _app.ts │ │ │ ├── products.ts │ │ │ ├── users.ts │ │ │ ├── stripe.ts │ │ │ └── orders.ts │ │ ├── context.ts │ │ └── trpc.ts │ ├── db │ │ └── client.ts │ └── common │ │ └── get-server-auth-session.ts ├── components │ ├── layouts │ │ ├── CompactLayout.tsx │ │ ├── Footer.tsx │ │ ├── DefaultLayout.tsx │ │ ├── Meta.tsx │ │ └── Navbar.tsx │ ├── screens │ │ ├── LoadingScreen.tsx │ │ ├── DeactivatedScreen.tsx │ │ ├── RestrictedScreen.tsx │ │ └── ErrorScreen.tsx │ ├── ui │ │ ├── ToastWrapper.tsx │ │ ├── Button.tsx │ │ └── FileInput.tsx │ ├── CategoryList.tsx │ ├── Searchbar.tsx │ ├── Hero.tsx │ ├── ConfirmationModal.tsx │ ├── ProductList.tsx │ └── Cart.tsx ├── types │ ├── globals.ts │ └── next-auth.d.ts ├── pages │ ├── _document.tsx │ ├── api │ │ ├── trpc │ │ │ └── [trpc].ts │ │ ├── restricted.ts │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ ├── og.tsx │ │ └── stripe-webhook.ts │ ├── app │ │ ├── checkout │ │ │ └── index.tsx │ │ ├── products │ │ │ ├── index.tsx │ │ │ └── [productId].tsx │ │ ├── categories │ │ │ ├── index.tsx │ │ │ └── [category].tsx │ │ ├── index.tsx │ │ ├── orders │ │ │ ├── [orderId].tsx │ │ │ └── index.tsx │ │ └── account │ │ │ ├── index.tsx │ │ │ ├── prime.tsx │ │ │ └── update.tsx │ ├── 404.tsx │ ├── _app.tsx │ ├── index.tsx │ └── dashboard │ │ ├── index.tsx │ │ ├── orders │ │ ├── index.tsx │ │ └── [orderId].tsx │ │ ├── users │ │ ├── index.tsx │ │ └── [userId].tsx │ │ └── products │ │ ├── index.tsx │ │ └── add.tsx ├── utils │ ├── format.ts │ ├── render.tsx │ └── trpc.ts ├── env │ ├── server.mjs │ ├── client.mjs │ └── schema.mjs └── stores │ └── cart.ts ├── .eslintrc.json ├── next.config.mjs ├── tsconfig.json ├── .gitignore ├── .env.example ├── tailwind.config.cjs ├── README.md ├── package.json └── prisma └── schema.prisma /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sadmann7/amzn-web/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sadmann7/amzn-web/HEAD/public/screenshot.png -------------------------------------------------------------------------------- /public/img/logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sadmann7/amzn-web/HEAD/public/img/logo-black.png -------------------------------------------------------------------------------- /public/img/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sadmann7/amzn-web/HEAD/public/img/logo-white.png -------------------------------------------------------------------------------- /public/img/advertisement-one.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sadmann7/amzn-web/HEAD/public/img/advertisement-one.webp -------------------------------------------------------------------------------- /public/img/hero-carousel-five.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sadmann7/amzn-web/HEAD/public/img/hero-carousel-five.webp -------------------------------------------------------------------------------- /public/img/hero-carousel-four.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sadmann7/amzn-web/HEAD/public/img/hero-carousel-four.webp -------------------------------------------------------------------------------- /public/img/hero-carousel-one.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sadmann7/amzn-web/HEAD/public/img/hero-carousel-one.webp -------------------------------------------------------------------------------- /public/img/hero-carousel-six.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sadmann7/amzn-web/HEAD/public/img/hero-carousel-six.webp -------------------------------------------------------------------------------- /public/img/hero-carousel-three.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sadmann7/amzn-web/HEAD/public/img/hero-carousel-three.webp -------------------------------------------------------------------------------- /public/img/hero-carousel-two.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sadmann7/amzn-web/HEAD/public/img/hero-carousel-two.webp -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | module.exports = { 3 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 4 | }; 5 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html { 6 | scroll-behavior: smooth; 7 | } 8 | 9 | body { 10 | @apply bg-bg-white; 11 | } 12 | -------------------------------------------------------------------------------- /src/server/stripe/client.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | import { env } from "../../env/server.mjs"; 3 | 4 | export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { 5 | apiVersion: "2022-11-15", 6 | }); 7 | -------------------------------------------------------------------------------- /src/components/layouts/CompactLayout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | const CompactLayout = ({ children }: { children: ReactNode }) => { 4 | return <>{children}>; 5 | }; 6 | 7 | export default CompactLayout; 8 | -------------------------------------------------------------------------------- /src/types/globals.ts: -------------------------------------------------------------------------------- 1 | import type { Order, OrderItem, Product } from "@prisma/client"; 2 | 3 | export type OrderItemWithProduct = OrderItem & { product: Product }; 4 | 5 | export type OrderWithItems = Order & { items: OrderItemWithProduct[] }; 6 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from "next/document"; 2 | 3 | const Document = () => { 4 | return ( 5 | 6 |
12 | Please contact the administrator to reactivate your account. If you are 13 | the administrator, please check the logs for more information. 14 |
15 | You are not authorized to view this page. 16 |
, IP = P> = NextPage< 15 | P, 16 | IP 17 | > & { 18 | getLayout?: (page: ReactElement) => ReactNode; 19 | }; 20 | 21 | type AppPropsWithLayout = AppProps<{ 22 | session: Session | null; 23 | }> & { 24 | Component: NextPageWithLayout; 25 | }; 26 | 27 | function MyApp({ Component, pageProps }: AppPropsWithLayout) { 28 | const getLayout = 29 | Component.getLayout ?? ((page) => {page}); 30 | 31 | return ( 32 | 33 | 34 | Amzn Store 35 | 36 | {getLayout()} 37 | 38 | 39 | ); 40 | } 41 | 42 | export default trpc.withTRPC(MyApp); 43 | -------------------------------------------------------------------------------- /src/components/screens/ErrorScreen.tsx: -------------------------------------------------------------------------------- 1 | import type { AppRouter } from "@/server/trpc/router/_app"; 2 | import type { TRPCClientErrorLike } from "@trpc/client"; 3 | 4 | // external imports 5 | import { ExclamationCircleIcon } from "@heroicons/react/24/outline"; 6 | 7 | const ErrorScreen = ({ error }: { error?: TRPCClientErrorLike }) => { 8 | return ( 9 | 10 | 11 | 12 | {error?.message ?? "Something went wrong"} 13 | 14 | 15 | 16 | 17 | Try doing these: 18 | 19 | 20 | 21 | 22 | 1. Spine transfer to nosegrab frontflip 23 | 24 | 25 | 2. Wall flip to natas spin 26 | 27 | 28 | 3. Sticker slap to manual to wallplant 29 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default ErrorScreen; 37 | -------------------------------------------------------------------------------- /src/components/layouts/Meta.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | 3 | type MetaProps = { 4 | title?: string; 5 | description?: string; 6 | image?: string; 7 | keywords?: string; 8 | }; 9 | 10 | const Meta = ({ 11 | title = "Amzn Store", 12 | description = "An Amazon clone built with the t3-stack", 13 | image = "https://amzn-web.vercel.app/api/og?title=Amzn%20Store&description=An%20Amazon%20clone%20built%20with%20the%20t3-stack", 14 | keywords = "amazon, clone, t3-stack, nextjs, typescript, tailwindcss, react, vercel", 15 | }: MetaProps) => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | export default Meta; 36 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Link from "next/link"; 3 | import { useRouter } from "next/router"; 4 | import { useEffect } from "react"; 5 | import type { NextPageWithLayout } from "./_app"; 6 | 7 | // external imports 8 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 9 | 10 | const Home: NextPageWithLayout = () => { 11 | const router = useRouter(); 12 | useEffect(() => { 13 | router.push("/app"); 14 | }, [router]); 15 | 16 | return ( 17 | <> 18 | 19 | Amzn Store 20 | 21 | 22 | 23 | 24 | 25 | Redirecting to the app page 26 | 27 | 32 | Go to app 33 | 34 | 35 | > 36 | ); 37 | }; 38 | 39 | export default Home; 40 | 41 | Home.getLayout = (page) => {page}; 42 | -------------------------------------------------------------------------------- /src/pages/app/categories/[category].tsx: -------------------------------------------------------------------------------- 1 | import { trpc } from "@/utils/trpc"; 2 | import type { PRODUCT_CATEGORY } from "@prisma/client"; 3 | import Head from "next/head"; 4 | import Router from "next/router"; 5 | import type { NextPageWithLayout } from "../../_app"; 6 | 7 | // external imports 8 | import ProductList from "@/components/ProductList"; 9 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 10 | import ErrorScreen from "@/components/screens/ErrorScreen"; 11 | import LoadingScreen from "@/components/screens/LoadingScreen"; 12 | 13 | const ShowCategory: NextPageWithLayout = () => { 14 | const category = Router.query.category as PRODUCT_CATEGORY; 15 | 16 | // get products by category query 17 | const productsQuery = trpc.products.getByCategory.useQuery(category, { 18 | staleTime: 1000 * 60 * 60 * 24, 19 | }); 20 | 21 | if (productsQuery.isLoading) { 22 | return ; 23 | } 24 | 25 | if (productsQuery.isError) { 26 | return ; 27 | } 28 | 29 | return ( 30 | <> 31 | 32 | Products | Amzn Store 33 | 34 | 35 | 36 | 37 | > 38 | ); 39 | }; 40 | 41 | export default ShowCategory; 42 | 43 | ShowCategory.getLayout = (page) => {page}; 44 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { type NextAuthOptions } from "next-auth"; 2 | import GoogleProvider from "next-auth/providers/google"; 3 | // Prisma adapter for NextAuth, optional and can be removed 4 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 5 | 6 | import { env } from "../../../env/server.mjs"; 7 | import { prisma } from "../../../server/db/client"; 8 | 9 | export const authOptions: NextAuthOptions = { 10 | // Include user.id on session 11 | callbacks: { 12 | session({ session, user }) { 13 | if (session.user) { 14 | session.user.id = user.id; 15 | session.user.role = user.role; 16 | session.user.active = user.active; 17 | session.user.phone = user.phone; 18 | } 19 | return session; 20 | }, 21 | redirect({ url, baseUrl }) { 22 | return url.startsWith("/api/auth/signin") ? baseUrl + "/app" : url; 23 | }, 24 | }, 25 | // Configure one or more authentication providers 26 | adapter: PrismaAdapter(prisma), 27 | providers: [ 28 | GoogleProvider({ 29 | clientId: env.GOOGLE_CLIENT_ID, 30 | clientSecret: env.GOOGLE_CLIENT_SECRET, 31 | httpOptions: { 32 | timeout: 40000, 33 | }, 34 | }), 35 | // ...add more providers here 36 | ], 37 | theme: { 38 | colorScheme: "light", 39 | logo: "/img/logo-black.png", 40 | }, 41 | pages: { 42 | newUser: "/app", 43 | }, 44 | }; 45 | 46 | export default NextAuth(authOptions); 47 | -------------------------------------------------------------------------------- /src/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import { httpBatchLink, loggerLink } from "@trpc/client"; 2 | import { createTRPCNext } from "@trpc/next"; 3 | import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server"; 4 | import superjson from "superjson"; 5 | 6 | import { type AppRouter } from "../server/trpc/router/_app"; 7 | 8 | const getBaseUrl = () => { 9 | if (typeof window !== "undefined") return ""; // browser should use relative url 10 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url 11 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost 12 | }; 13 | 14 | export const trpc = createTRPCNext({ 15 | config() { 16 | return { 17 | transformer: superjson, 18 | links: [ 19 | loggerLink({ 20 | enabled: (opts) => 21 | process.env.NODE_ENV === "development" || 22 | (opts.direction === "down" && opts.result instanceof Error), 23 | }), 24 | httpBatchLink({ 25 | url: `${getBaseUrl()}/api/trpc`, 26 | }), 27 | ], 28 | }; 29 | }, 30 | ssr: false, 31 | }); 32 | 33 | /** 34 | * Inference helper for inputs 35 | * @example type HelloInput = RouterInputs['example']['hello'] 36 | **/ 37 | export type RouterInputs = inferRouterInputs; 38 | /** 39 | * Inference helper for outputs 40 | * @example type HelloOutput = RouterOutputs['example']['hello'] 41 | **/ 42 | export type RouterOutputs = inferRouterOutputs; 43 | -------------------------------------------------------------------------------- /src/server/trpc/router/products.ts: -------------------------------------------------------------------------------- 1 | import { PRODUCT_CATEGORY } from "@prisma/client"; 2 | import { TRPCError } from "@trpc/server"; 3 | import { z } from "zod"; 4 | import { publicProcedure, router } from "../trpc"; 5 | 6 | export const productsRouter = router({ 7 | get: publicProcedure.query(async ({ ctx }) => { 8 | const products = await ctx.prisma.product.findMany({ 9 | orderBy: { 10 | createdAt: "desc", 11 | }, 12 | }); 13 | return products; 14 | }), 15 | 16 | getOne: publicProcedure.input(z.string()).query(async ({ ctx, input }) => { 17 | const product = await ctx.prisma.product.findUnique({ 18 | where: { 19 | id: input, 20 | }, 21 | }); 22 | if (!product) { 23 | throw new TRPCError({ 24 | code: "NOT_FOUND", 25 | message: "Product not found!", 26 | }); 27 | } 28 | return product; 29 | }), 30 | 31 | getCategories: publicProcedure.query(async ({ ctx }) => { 32 | const products = await ctx.prisma.product.findMany(); 33 | const categories = products.map((product) => product.category); 34 | const uniqueCategories = [...new Set(categories)]; 35 | return uniqueCategories; 36 | }), 37 | 38 | getByCategory: publicProcedure 39 | .input(z.nativeEnum(PRODUCT_CATEGORY)) 40 | .query(async ({ ctx, input }) => { 41 | const products = await ctx.prisma.product.findMany({ 42 | where: { 43 | category: input, 44 | }, 45 | }); 46 | return products; 47 | }), 48 | }); 49 | -------------------------------------------------------------------------------- /src/server/trpc/context.ts: -------------------------------------------------------------------------------- 1 | import { type inferAsyncReturnType } from "@trpc/server"; 2 | import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; 3 | import { type Session } from "next-auth"; 4 | 5 | import { cloudinary } from "../cloudinary/cloudinary"; 6 | import { getServerAuthSession } from "../common/get-server-auth-session"; 7 | import { prisma } from "../db/client"; 8 | import { stripe } from "../stripe/client"; 9 | 10 | type CreateContextOptions = { 11 | session: Session | null; 12 | }; 13 | 14 | /** Use this helper for: 15 | * - testing, so we dont have to mock Next.js' req/res 16 | * - trpc's `createSSGHelpers` where we don't have req/res 17 | * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts 18 | **/ 19 | export const createContextInner = async (opts: CreateContextOptions) => { 20 | return { 21 | session: opts.session, 22 | prisma, 23 | cloudinary, 24 | stripe, 25 | }; 26 | }; 27 | 28 | /** 29 | * This is the actual context you'll use in your router 30 | * @link https://trpc.io/docs/context 31 | **/ 32 | export const createContext = async (opts: CreateNextContextOptions) => { 33 | const { req, res } = opts; 34 | 35 | // Get the session from the server using the unstable_getServerSession wrapper function 36 | const session = await getServerAuthSession({ req, res }); 37 | 38 | return { 39 | ...(await createContextInner({ session })), 40 | req, 41 | res, 42 | }; 43 | }; 44 | 45 | export type Context = inferAsyncReturnType; 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Amzn Store](https://amzn-web.vercel.app/) 2 | 3 | This project is an Amazon clone bootstrapped with the [T3 Stack](https://create.t3.gg/). 4 | 5 | [](https://amzn-web.vercel.app/) 6 | 7 | ## Tech Stack 8 | 9 | - [Next.js](https://nextjs.org) 10 | - [NextAuth.js](https://next-auth.js.org) 11 | - [Prisma](https://prisma.io) 12 | - [Tailwind CSS](https://tailwindcss.com) 13 | - [tRPC](https://trpc.io) 14 | - [Cloudinary](https://cloudinary.com) 15 | - [Stripe](https://stripe.com) 16 | 17 | ## Features 18 | 19 | - Authentication with NextAuth.js 20 | - CRUD operations with tRPC and Prisma 21 | - Search products with combobox 22 | - Add to cart, and proceed to orders 23 | - Image upload with Cloudinary 24 | - Subscription with Stripe 25 | 26 | ## Installation 27 | 28 | ### 1. Clone the repository 29 | 30 | ```bash 31 | git clone https://github.com/sadmann7/amzn-web.git 32 | ``` 33 | 34 | ### 2. Install dependencies 35 | 36 | ```bash 37 | yarn install 38 | ``` 39 | 40 | ### 3. Create a `.env` file 41 | 42 | Create a `.env` file in the root directory and add the environment variables as shown in the `.env.example` file. 43 | 44 | ### 4. Run the application 45 | 46 | ```bash 47 | yarn run dev 48 | ``` 49 | 50 | The application will be available at `http://localhost:3000`. 51 | 52 | ## Deployment 53 | 54 | Follow the deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. 55 | -------------------------------------------------------------------------------- /src/server/trpc/trpc.ts: -------------------------------------------------------------------------------- 1 | import { USER_ROLE } from "@prisma/client"; 2 | import { initTRPC, TRPCError } from "@trpc/server"; 3 | import superjson from "superjson"; 4 | 5 | import { type Context } from "./context"; 6 | 7 | const t = initTRPC.context().create({ 8 | transformer: superjson, 9 | errorFormatter({ shape }) { 10 | return shape; 11 | }, 12 | }); 13 | 14 | export const router = t.router; 15 | 16 | /** 17 | * Unprotected procedure 18 | **/ 19 | export const publicProcedure = t.procedure; 20 | 21 | /** 22 | * Reusable middleware to ensure 23 | * users are logged in 24 | */ 25 | const isAuthed = t.middleware(({ ctx, next }) => { 26 | if (!ctx.session || !ctx.session.user) { 27 | throw new TRPCError({ code: "UNAUTHORIZED" }); 28 | } 29 | return next({ 30 | ctx: { 31 | // infers the `session` as non-nullable 32 | session: { ...ctx.session, user: ctx.session.user }, 33 | }, 34 | }); 35 | }); 36 | 37 | /** 38 | * Protected procedure 39 | **/ 40 | export const protectedProcedure = t.procedure.use(isAuthed); 41 | 42 | /** 43 | * Admin procedure 44 | **/ 45 | export const adminProcedure = t.procedure.use(isAuthed).use(({ ctx, next }) => { 46 | if (!ctx.session || !ctx.session.user) { 47 | throw new TRPCError({ code: "UNAUTHORIZED" }); 48 | } 49 | if (ctx.session?.user.role !== USER_ROLE.ADMIN) { 50 | throw new TRPCError({ code: "FORBIDDEN" }); 51 | } 52 | return next({ 53 | ctx: { 54 | // infers the `session` as non-nullable 55 | session: { ...ctx.session, user: ctx.session.user }, 56 | }, 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/pages/app/index.tsx: -------------------------------------------------------------------------------- 1 | import { trpc } from "@/utils/trpc"; 2 | import Head from "next/head"; 3 | import type { NextPageWithLayout } from "../_app"; 4 | 5 | // external imports 6 | import CategoryList from "@/components/CategoryList"; 7 | import Hero from "@/components/Hero"; 8 | import ProductList from "@/components/ProductList"; 9 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 10 | import ErrorScreen from "@/components/screens/ErrorScreen"; 11 | import LoadingScreen from "@/components/screens/LoadingScreen"; 12 | 13 | const App: NextPageWithLayout = () => { 14 | // get queries 15 | const categoriesQuery = trpc.products.getCategories.useQuery(undefined, { 16 | staleTime: 1000 * 60 * 60 * 24, 17 | }); 18 | const productsQuery = trpc.products.get.useQuery(undefined, { 19 | staleTime: 1000 * 60 * 60 * 24, 20 | }); 21 | 22 | if (categoriesQuery.isLoading || productsQuery.isLoading) { 23 | return ; 24 | } 25 | 26 | if (categoriesQuery.isError) { 27 | return ; 28 | } 29 | 30 | if (productsQuery.isError) { 31 | return ; 32 | } 33 | 34 | return ( 35 | <> 36 | 37 | Amzn Store 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | > 47 | ); 48 | }; 49 | 50 | export default App; 51 | 52 | App.getLayout = (page) => {page}; 53 | -------------------------------------------------------------------------------- /src/pages/dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import { USER_ROLE } from "@prisma/client"; 2 | import { useSession } from "next-auth/react"; 3 | import Head from "next/head"; 4 | import Link from "next/link"; 5 | import type { NextPageWithLayout } from "../_app"; 6 | 7 | // external imports 8 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 9 | import RestrictedScreen from "@/components/screens/RestrictedScreen"; 10 | 11 | const dashboardRoutes = [ 12 | { 13 | name: "Users", 14 | path: "/dashboard/users", 15 | }, 16 | { 17 | name: "Products", 18 | path: "/dashboard/products", 19 | }, 20 | { 21 | name: "Orders", 22 | path: "/dashboard/orders", 23 | }, 24 | ]; 25 | 26 | const Dashboard: NextPageWithLayout = () => { 27 | const { data: session, status } = useSession(); 28 | 29 | if (status === "authenticated" && session?.user?.role !== USER_ROLE.ADMIN) { 30 | return ; 31 | } 32 | 33 | return ( 34 | <> 35 | 36 | Dashboard | Amzn Store 37 | 38 | 39 | 40 | {dashboardRoutes.map((route) => ( 41 | 42 | 47 | {route.name} 48 | 49 | 50 | ))} 51 | 52 | 53 | > 54 | ); 55 | }; 56 | 57 | export default Dashboard; 58 | 59 | Dashboard.getLayout = (page) => {page}; 60 | -------------------------------------------------------------------------------- /src/env/schema.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { z } from "zod"; 3 | 4 | /** 5 | * Specify your server-side environment variables schema here. 6 | * This way you can ensure the app isn't built with invalid env vars. 7 | */ 8 | export const serverSchema = z.object({ 9 | DATABASE_URL: z.string().url(), 10 | NODE_ENV: z.enum(["development", "test", "production"]), 11 | NEXTAUTH_SECRET: 12 | process.env.NODE_ENV === "production" 13 | ? z.string().min(1) 14 | : z.string().min(1).optional(), 15 | NEXTAUTH_URL: z.preprocess( 16 | // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL 17 | // Since NextAuth.js automatically uses the VERCEL_URL if present. 18 | (str) => process.env.VERCEL_URL ?? str, 19 | // VERCEL_URL doesn't include `https` so it cant be validated as a URL 20 | process.env.VERCEL ? z.string() : z.string().url() 21 | ), 22 | GOOGLE_CLIENT_ID: z.string(), 23 | GOOGLE_CLIENT_SECRET: z.string(), 24 | CLOUDINARY_CLOUD_NAME: z.string(), 25 | CLOUDINARY_API_KEY: z.string(), 26 | CLOUDINARY_API_SECRET: z.string(), 27 | STRIPE_PUBLISHABLE_KEY: z.string(), 28 | STRIPE_SECRET_KEY: z.string(), 29 | STRIPE_WEBHOOK_SECRET: z.string(), 30 | STRIPE_PRICE_ID: z.string(), 31 | }); 32 | 33 | /** 34 | * Specify your client-side environment variables schema here. 35 | * This way you can ensure the app isn't built with invalid env vars. 36 | * To expose them to the client, prefix them with `NEXT_PUBLIC_`. 37 | */ 38 | export const clientSchema = z.object({ 39 | // NEXT_PUBLIC_CLIENTVAR: z.string(), 40 | }); 41 | 42 | /** 43 | * You can't destruct `process.env` as a regular object, so you have to do 44 | * it manually here. This is because Next.js evaluates this at build time, 45 | * and only used environment variables are included in the build. 46 | * @type {{ [k in keyof z.infer]: z.infer[k] | undefined }} 47 | */ 48 | export const clientEnv = { 49 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 50 | }; 51 | -------------------------------------------------------------------------------- /src/stores/cart.ts: -------------------------------------------------------------------------------- 1 | import type { Product } from "@prisma/client"; 2 | import { create } from "zustand"; 3 | import { createJSONStorage, devtools, persist } from "zustand/middleware"; 4 | 5 | type CartState = { 6 | products: Product[]; 7 | addProduct: (product: Product) => void; 8 | removeProduct: (id: string) => void; 9 | removeProducts: (ids: string[]) => void; 10 | setQuantity: (id: string, quantity: number) => void; 11 | }; 12 | 13 | export const useCartStore = create()( 14 | devtools( 15 | persist( 16 | (set) => ({ 17 | products: [], 18 | addProduct: (product) => { 19 | set((state) => ({ 20 | products: state.products.some((p) => p.id === product.id) 21 | ? state.products.map((p) => 22 | p.id === product.id 23 | ? { 24 | ...p, 25 | quantity: p.quantity + 1, 26 | } 27 | : p 28 | ) 29 | : [...state.products, { ...product, quantity: 1 }], 30 | })); 31 | }, 32 | removeProduct: (id: string) => { 33 | set((state) => ({ 34 | products: state.products.filter((product) => product.id !== id), 35 | })); 36 | }, 37 | removeProducts: (ids: string[]) => { 38 | set((state) => ({ 39 | products: state.products.filter( 40 | (product) => !ids.includes(product.id) 41 | ), 42 | })); 43 | }, 44 | setQuantity: (id: string, quantity: number) => { 45 | set((state) => ({ 46 | products: state.products.map((product) => { 47 | if (product.id === id) { 48 | return { 49 | ...product, 50 | quantity, 51 | }; 52 | } 53 | return product; 54 | }), 55 | })); 56 | }, 57 | isLoading: false, 58 | }), 59 | { 60 | name: "cart", 61 | storage: createJSONStorage(() => sessionStorage), 62 | } 63 | ) 64 | ) 65 | ); 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amzn-web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "postinstall": "prisma generate", 9 | "lint": "next lint", 10 | "start": "next start", 11 | "stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe-webhook --latest" 12 | }, 13 | "dependencies": { 14 | "@headlessui/react": "^1.7.14", 15 | "@headlessui/tailwindcss": "^0.1.3", 16 | "@heroicons/react": "^2.0.17", 17 | "@hookform/resolvers": "^3.0.1", 18 | "@next-auth/prisma-adapter": "^1.0.5", 19 | "@prisma/client": "^4.12.0", 20 | "@tanstack/match-sorter-utils": "^8.8.4", 21 | "@tanstack/react-query": "^4.29.1", 22 | "@tanstack/react-table": "^8.8.5", 23 | "@trpc/client": "^10.19.1", 24 | "@trpc/next": "^10.19.1", 25 | "@trpc/react-query": "^10.19.1", 26 | "@trpc/server": "^10.19.1", 27 | "@vercel/og": "^0.5.2", 28 | "cloudinary": "^1.35.0", 29 | "dayjs": "^1.11.7", 30 | "micro": "^10.0.1", 31 | "next": "13.3.0", 32 | "next-auth": "^4.22.0", 33 | "react": "18.2.0", 34 | "react-dom": "18.2.0", 35 | "react-dropzone": "^14.2.3", 36 | "react-hook-form": "^7.43.9", 37 | "react-hot-toast": "^2.4.0", 38 | "stripe": "^12.0.0", 39 | "superjson": "1.12.2", 40 | "swiper": "^9.2.0", 41 | "zod": "^3.21.4", 42 | "zustand": "^4.3.7" 43 | }, 44 | "devDependencies": { 45 | "@tailwindcss/forms": "^0.5.3", 46 | "@tailwindcss/line-clamp": "^0.4.4", 47 | "@types/node": "^18.15.11", 48 | "@types/prettier": "^2.7.2", 49 | "@types/react": "^18.0.35", 50 | "@types/react-dom": "^18.0.11", 51 | "@typescript-eslint/eslint-plugin": "^5.58.0", 52 | "@typescript-eslint/parser": "^5.58.0", 53 | "autoprefixer": "^10.4.14", 54 | "eslint": "^8.38.0", 55 | "eslint-config-next": "13.3.0", 56 | "postcss": "^8.4.21", 57 | "prettier": "^2.8.7", 58 | "prettier-plugin-tailwindcss": "^0.2.7", 59 | "prisma": "^4.12.0", 60 | "tailwindcss": "^3.3.1", 61 | "typescript": "^5.0.4" 62 | }, 63 | "ct3aMetadata": { 64 | "initVersion": "6.12.0" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/server/trpc/router/users.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { z } from "zod"; 3 | import { protectedProcedure, publicProcedure, router } from "../trpc"; 4 | 5 | export const usersRouter = router({ 6 | get: protectedProcedure.query(async ({ ctx }) => { 7 | const users = await ctx.prisma.user.findMany(); 8 | return users; 9 | }), 10 | 11 | getOne: protectedProcedure.input(z.string()).query(async ({ ctx, input }) => { 12 | const user = await ctx.prisma.user.findUnique({ 13 | where: { 14 | id: input, 15 | }, 16 | }); 17 | if (!user) { 18 | throw new TRPCError({ 19 | code: "NOT_FOUND", 20 | message: "User not found!", 21 | }); 22 | } 23 | return user; 24 | }), 25 | 26 | getSession: publicProcedure.query(({ ctx }) => { 27 | return ctx.session; 28 | }), 29 | 30 | getSubscriptionStatus: protectedProcedure.query(async ({ ctx }) => { 31 | if (!ctx.session.user?.id) { 32 | throw new TRPCError({ 33 | code: "INTERNAL_SERVER_ERROR", 34 | message: "Could not find user", 35 | }); 36 | } 37 | const user = await ctx.prisma.user.findUnique({ 38 | where: { 39 | id: ctx.session.user?.id, 40 | }, 41 | select: { 42 | stripeSubscriptionStatus: true, 43 | }, 44 | }); 45 | if (!user) { 46 | throw new TRPCError({ 47 | code: "INTERNAL_SERVER_ERROR", 48 | message: "Could not find user", 49 | }); 50 | } 51 | return user.stripeSubscriptionStatus; 52 | }), 53 | 54 | update: protectedProcedure 55 | .input( 56 | z.object({ 57 | id: z.string(), 58 | name: z.string().min(3).max(50), 59 | email: z.string().email(), 60 | phone: z.string().min(10).max(11), 61 | }) 62 | ) 63 | .mutation(async ({ ctx, input }) => { 64 | const user = await ctx.prisma.user.update({ 65 | where: { 66 | id: input.id, 67 | }, 68 | data: { 69 | name: input.name, 70 | email: input.email, 71 | phone: input.phone, 72 | }, 73 | }); 74 | return user; 75 | }), 76 | 77 | delete: protectedProcedure 78 | .input(z.string()) 79 | .mutation(async ({ ctx, input }) => { 80 | const user = await ctx.prisma.user.delete({ 81 | where: { 82 | id: input, 83 | }, 84 | }); 85 | return user; 86 | }), 87 | }); 88 | -------------------------------------------------------------------------------- /src/components/Searchbar.tsx: -------------------------------------------------------------------------------- 1 | import styles from "@/styles/searchbar.module.css"; 2 | import { Combobox, Transition } from "@headlessui/react"; 3 | import type { Product } from "@prisma/client"; 4 | import Router from "next/router"; 5 | import * as React from "react"; 6 | 7 | // external imports 8 | import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; 9 | 10 | interface SearchbarProps extends React.HTMLAttributes { 11 | data: TData[]; 12 | route: string; 13 | } 14 | 15 | const Searchbar = ({ 16 | data, 17 | route, 18 | className, 19 | }: SearchbarProps) => { 20 | const [query, setQuery] = React.useState(""); 21 | 22 | // filter data 23 | const filteredData = 24 | query === "" 25 | ? data 26 | : data.filter((item) => 27 | item.name 28 | ? item.name 29 | .toLowerCase() 30 | .replace(/\s+/g, "") 31 | .includes(query.toLowerCase().replace(/\s+/g, "")) 32 | : item 33 | ); 34 | 35 | return ( 36 | { 41 | Router.push(`/app/${route}/${value.id}`); 42 | }} 43 | > 44 | 45 | setQuery(e.target.value)} 50 | /> 51 | 52 | 56 | 57 | 58 | setQuery("")} 64 | > 65 | 66 | {filteredData.length === 0 && query !== "" ? ( 67 | 68 | No item found 69 | 70 | ) : ( 71 | filteredData.map((item) => ( 72 | 77 | <> 78 | {item.name} 79 | > 80 | 81 | )) 82 | )} 83 | 84 | 85 | 86 | ); 87 | }; 88 | 89 | export default Searchbar; 90 | -------------------------------------------------------------------------------- /src/pages/app/orders/[orderId].tsx: -------------------------------------------------------------------------------- 1 | import { formatCurrency } from "@/utils/format"; 2 | import { trpc } from "@/utils/trpc"; 3 | import Head from "next/head"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | import Router from "next/router"; 7 | import type { NextPageWithLayout } from "../../_app"; 8 | 9 | // external imports 10 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 11 | import ErrorScreen from "@/components/screens/ErrorScreen"; 12 | import LoadingScreen from "@/components/screens/LoadingScreen"; 13 | 14 | const ShowOrder: NextPageWithLayout = () => { 15 | const orderId = Router.query.orderId as string; 16 | 17 | // get order query 18 | const orderQuery = trpc.orders.getOne.useQuery(orderId); 19 | 20 | if (orderQuery.isLoading) { 21 | return ; 22 | } 23 | 24 | if (orderQuery.isError) { 25 | return ; 26 | } 27 | 28 | return ( 29 | <> 30 | 31 | Order | Amzn Store 32 | 33 | 34 | 35 | {orderQuery.data.items.map((item) => ( 36 | 37 | 38 | 39 | 46 | 47 | 51 | 52 | {item.product.name} 53 | 54 | 55 | 56 | {item.quantity} x{" "} 57 | {formatCurrency(item.product.price, "USD")} 58 | 59 | 60 | 61 | 62 | ${item.product.price * item.quantity} 63 | 64 | 65 | 66 | 67 | ))} 68 | 69 | 70 | > 71 | ); 72 | }; 73 | 74 | export default ShowOrder; 75 | 76 | ShowOrder.getLayout = (page) => {page}; 77 | -------------------------------------------------------------------------------- /src/components/ui/FileInput.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { 3 | useCallback, 4 | useEffect, 5 | type Dispatch, 6 | type SetStateAction, 7 | } from "react"; 8 | import { useDropzone, type FileRejection } from "react-dropzone"; 9 | import type { 10 | FieldValues, 11 | Path, 12 | PathValue, 13 | UseFormSetValue, 14 | } from "react-hook-form"; 15 | import { toast } from "react-hot-toast"; 16 | 17 | interface DropProps 18 | extends React.HTMLAttributes { 19 | name: Path; 20 | setValue: UseFormSetValue; 21 | preview: string | undefined; 22 | setPreview: Dispatch>; 23 | } 24 | 25 | const FileInput = ({ 26 | name, 27 | setValue, 28 | preview, 29 | setPreview, 30 | className, 31 | id, 32 | }: DropProps) => { 33 | // react-dropzone 34 | const onDrop = useCallback( 35 | (acceptedFiles: File[], rejectedFiles: FileRejection[]) => 36 | acceptedFiles.forEach( 37 | (file) => { 38 | if (!file) return; 39 | setValue(name, file as PathValue>, { 40 | shouldValidate: true, 41 | }); 42 | setPreview(URL.createObjectURL(file)); 43 | }, 44 | rejectedFiles.forEach((file) => { 45 | if (file.errors[0]?.code === "file-too-large") { 46 | const size = Math.round(file.file.size / 1000000); 47 | toast.error( 48 | `Please upload a image smaller than 1MB. Current size: ${size}MB` 49 | ); 50 | } else { 51 | toast.error(toast.error(file.errors[0]?.message ?? "Error")); 52 | } 53 | }) 54 | ), 55 | 56 | [name, setPreview, setValue] 57 | ); 58 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ 59 | accept: { 60 | "image/*": [], 61 | }, 62 | maxSize: 1000000, 63 | onDrop: onDrop, 64 | }); 65 | 66 | useEffect(() => { 67 | if (!preview) return; 68 | return () => URL.revokeObjectURL(preview); 69 | }, [preview]); 70 | 71 | return ( 72 | 77 | 78 | {isDragActive ? ( 79 | Drop the files here ... 80 | ) : preview ? ( 81 | URL.revokeObjectURL(preview)} 89 | /> 90 | ) : ( 91 | Drag {`'n'`} drop image here, or click to select image 92 | )} 93 | 94 | ); 95 | }; 96 | 97 | export default FileInput; 98 | -------------------------------------------------------------------------------- /src/pages/app/products/[productId].tsx: -------------------------------------------------------------------------------- 1 | import { useCartStore } from "@/stores/cart"; 2 | import { formatCurrency, truncateText } from "@/utils/format"; 3 | import { trpc } from "@/utils/trpc"; 4 | import type { Product } from "@prisma/client"; 5 | import Head from "next/head"; 6 | import Image from "next/image"; 7 | import Router from "next/router"; 8 | import { toast } from "react-hot-toast"; 9 | import type { NextPageWithLayout } from "../../_app"; 10 | 11 | // external imports 12 | import Button from "@/components/ui/Button"; 13 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 14 | import ErrorScreen from "@/components/screens/ErrorScreen"; 15 | import LoadingScreen from "@/components/screens/LoadingScreen"; 16 | 17 | const ShowProduct: NextPageWithLayout = () => { 18 | const productId = Router.query.productId as string; 19 | 20 | // get product query 21 | const productQuery = trpc.products.getOne.useQuery(productId); 22 | 23 | // cart store 24 | const cartStore = useCartStore((state) => ({ 25 | addProduct: state.addProduct, 26 | })); 27 | 28 | if (productQuery.isLoading) { 29 | return ; 30 | } 31 | 32 | if (productQuery.isError) { 33 | return ; 34 | } 35 | 36 | return ( 37 | <> 38 | 39 | {productQuery.data.name ?? "Product"} | Amzn Store 40 | 41 | 42 | 43 | 44 | 52 | 53 | 54 | {productQuery.data.name} 55 | 56 | 57 | {productQuery.data.description} 58 | 59 | 60 | {formatCurrency(productQuery.data.price, "USD")} 61 | 62 | { 66 | cartStore.addProduct(productQuery.data as Product); 67 | toast.success( 68 | `${truncateText( 69 | productQuery.data?.name as string, 70 | 16 71 | )} added to cart` 72 | ); 73 | }} 74 | > 75 | Add to Cart 76 | 77 | 78 | 79 | 80 | 81 | > 82 | ); 83 | }; 84 | 85 | export default ShowProduct; 86 | 87 | ShowProduct.getLayout = (page) => {page}; 88 | -------------------------------------------------------------------------------- /src/components/Hero.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { useRef } from "react"; 3 | import type SwiperCore from "swiper"; 4 | import { Autoplay, Navigation } from "swiper"; 5 | import "swiper/css"; 6 | import { Swiper, SwiperSlide } from "swiper/react"; 7 | import type { NavigationOptions } from "swiper/types/modules/navigation"; 8 | 9 | // external imports 10 | import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; 11 | 12 | const heroImages = [ 13 | { src: "/img/hero-carousel-one.webp", alt: "hero-carousel-one" }, 14 | { src: "/img/hero-carousel-two.webp", alt: "hero-carousel-two" }, 15 | { src: "/img/hero-carousel-three.webp", alt: "hero-carousel-three" }, 16 | { src: "/img/hero-carousel-four.webp", alt: "hero-carousel-four" }, 17 | { src: "/img/hero-carousel-five.webp", alt: "hero-carousel-five" }, 18 | { src: "/img/hero-carousel-six.webp", alt: "hero-carousel-six" }, 19 | ]; 20 | 21 | const Hero = () => { 22 | const leftArrowRef = useRef(null); 23 | const rightArrowRef = useRef(null); 24 | const onBeforeInit = (swiper: SwiperCore) => { 25 | (swiper.params.navigation as NavigationOptions).prevEl = 26 | leftArrowRef.current; 27 | (swiper.params.navigation as NavigationOptions).nextEl = 28 | rightArrowRef.current; 29 | }; 30 | 31 | return ( 32 | 36 | 37 | 42 | 46 | 47 | 52 | 56 | 57 | 74 | {heroImages.map((image) => ( 75 | 76 | 84 | 85 | ))} 86 | 87 | 88 | ); 89 | }; 90 | 91 | export default Hero; 92 | -------------------------------------------------------------------------------- /src/server/stripe/stripe-webhook-handlers.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient, STRIPE_SUBSCRIPTION_STATUS } from "@prisma/client"; 2 | import type Stripe from "stripe"; 3 | 4 | // retrieves a Stripe customer id for a given user if it exists or creates a new one 5 | export const getOrCreateStripeCustomerIdForUser = async ({ 6 | stripe, 7 | prisma, 8 | userId, 9 | }: { 10 | stripe: Stripe; 11 | prisma: PrismaClient; 12 | userId: string; 13 | }) => { 14 | const user = await prisma.user.findUnique({ 15 | where: { 16 | id: userId, 17 | }, 18 | }); 19 | 20 | if (!user) throw new Error("User not found"); 21 | 22 | if (user.stripeCustomerId) { 23 | return user.stripeCustomerId; 24 | } 25 | 26 | // create a new customer 27 | const customer = await stripe.customers.create({ 28 | email: user.email ?? undefined, 29 | name: user.name ?? undefined, 30 | // use metadata to link this Stripe customer to internal user id 31 | metadata: { 32 | userId, 33 | }, 34 | }); 35 | 36 | // update with new customer id 37 | const updatedUser = await prisma.user.update({ 38 | where: { 39 | id: userId, 40 | }, 41 | data: { 42 | stripeCustomerId: customer.id, 43 | }, 44 | }); 45 | 46 | if (updatedUser.stripeCustomerId) { 47 | return updatedUser.stripeCustomerId; 48 | } 49 | }; 50 | 51 | export const handleInvoicePaid = async ({ 52 | event, 53 | stripe, 54 | prisma, 55 | }: { 56 | event: Stripe.Event; 57 | stripe: Stripe; 58 | prisma: PrismaClient; 59 | }) => { 60 | const invoice = event.data.object as Stripe.Invoice; 61 | const subscriptionId = invoice.subscription; 62 | const subscription = await stripe.subscriptions.retrieve( 63 | subscriptionId as string 64 | ); 65 | const userId = subscription.metadata.userId; 66 | 67 | // update user with subscription data 68 | await prisma.user.update({ 69 | where: { 70 | id: userId, 71 | }, 72 | data: { 73 | stripeSubscriptionId: subscription.id, 74 | stripeSubscriptionStatus: 75 | subscription.status as STRIPE_SUBSCRIPTION_STATUS, 76 | }, 77 | }); 78 | }; 79 | 80 | export const handleSubscriptionCreatedOrUpdated = async ({ 81 | event, 82 | prisma, 83 | }: { 84 | event: Stripe.Event; 85 | prisma: PrismaClient; 86 | }) => { 87 | const subscription = event.data.object as Stripe.Subscription; 88 | const userId = subscription.metadata.userId; 89 | 90 | // update user with subscription data 91 | await prisma.user.update({ 92 | where: { 93 | id: userId, 94 | }, 95 | data: { 96 | stripeSubscriptionId: subscription.id, 97 | stripeSubscriptionStatus: 98 | subscription.status as STRIPE_SUBSCRIPTION_STATUS, 99 | }, 100 | }); 101 | }; 102 | 103 | export const handleSubscriptionCanceled = async ({ 104 | event, 105 | prisma, 106 | }: { 107 | event: Stripe.Event; 108 | prisma: PrismaClient; 109 | }) => { 110 | const subscription = event.data.object as Stripe.Subscription; 111 | const userId = subscription.metadata.userId; 112 | 113 | // remove subscription data from user 114 | await prisma.user.update({ 115 | where: { 116 | id: userId, 117 | }, 118 | data: { 119 | stripeSubscriptionId: null, 120 | stripeSubscriptionStatus: null, 121 | }, 122 | }); 123 | }; 124 | -------------------------------------------------------------------------------- /src/pages/api/og.tsx: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from "next/server"; 2 | import { ImageResponse } from "@vercel/og"; 3 | import type { PageConfig } from "next"; 4 | 5 | export const config: PageConfig = { 6 | runtime: "edge", 7 | }; 8 | 9 | export default function handler(req: NextRequest) { 10 | try { 11 | const { searchParams } = new URL(req.url); 12 | 13 | // ?title= 14 | const hasTitle = searchParams.has("title"); 15 | const title = hasTitle 16 | ? searchParams.get("title")?.slice(0, 100) 17 | : "My default title"; 18 | 19 | // ?description= 20 | const hasDescription = searchParams.has("description"); 21 | const description = hasDescription 22 | ? searchParams.get("description")?.slice(0, 200) 23 | : "My default description"; 24 | 25 | return new ImageResponse( 26 | ( 27 | 33 | 34 | 40 | 44 | 45 | 46 | 52 | 53 | {title} 54 | 55 | 56 | {description} 57 | 58 | 59 | 60 | ), 61 | { 62 | width: 1200, 63 | height: 630, 64 | } 65 | ); 66 | } catch (error) { 67 | error instanceof Error 68 | ? console.log(`${error.message as string}`) 69 | : console.log(error); 70 | return new Response(`Failed to generate the image`, { 71 | status: 500, 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/pages/app/account/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from "next-auth/react"; 2 | import Head from "next/head"; 3 | import Link from "next/link"; 4 | import type { NextPageWithLayout } from "../../_app"; 5 | 6 | // external imports 7 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 8 | 9 | const Account: NextPageWithLayout = () => { 10 | const { status } = useSession(); 11 | 12 | const accountLinks = [ 13 | { 14 | name: "Your Orders", 15 | description: "Track, return, or buy things again", 16 | href: "/app/orders", 17 | }, 18 | { 19 | name: "Login & security", 20 | description: "Edit login, name, and mobile number", 21 | href: 22 | status === "authenticated" ? "/app/account/update" : "/api/auth/signin", 23 | }, 24 | { 25 | name: "Prime", 26 | description: "View benefits and payment settings", 27 | href: 28 | status === "authenticated" ? "/app/account/prime" : "/api/auth/signin", 29 | }, 30 | { 31 | name: "Gift cards", 32 | description: "View balance, redeem, or reload cards", 33 | href: "##", 34 | }, 35 | { 36 | name: "Your Payments", 37 | description: "View all transactions, manage payment methods and settings", 38 | href: "##", 39 | }, 40 | { 41 | name: "Your Profiles", 42 | description: 43 | "Manage, add, or remove user profiles for personalized experiences", 44 | href: "##", 45 | }, 46 | { 47 | name: "Digital Services and Device Support", 48 | description: "Troubleshoot device issues", 49 | href: "##", 50 | }, 51 | { 52 | name: "Your Messages", 53 | description: "View messages to and from Amazon, sellers, and buyers", 54 | href: "##", 55 | }, 56 | { 57 | name: "Archived orders", 58 | description: "View and manage your archived orders", 59 | href: 60 | status === "authenticated" 61 | ? "/app/orders?tab=archived" 62 | : "/api/auth/signin", 63 | }, 64 | { 65 | name: "Your Lists", 66 | description: "View, modify, and share your lists, or create new ones", 67 | href: "##", 68 | }, 69 | { 70 | name: "Customer Service", 71 | href: "##", 72 | }, 73 | ]; 74 | 75 | return ( 76 | <> 77 | 78 | Account | Amzn Store 79 | 80 | 81 | 82 | 83 | Your Account 84 | 85 | 86 | {accountLinks.map((link) => ( 87 | 92 | 93 | {link.name} 94 | 95 | {link.description ? ( 96 | 97 | {link.description} 98 | 99 | ) : null} 100 | 101 | ))} 102 | 103 | 104 | 105 | > 106 | ); 107 | }; 108 | 109 | export default Account; 110 | 111 | Account.getLayout = (page) => {page}; 112 | -------------------------------------------------------------------------------- /src/server/trpc/router/stripe.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { env } from "../../../env/server.mjs"; 3 | import { getOrCreateStripeCustomerIdForUser } from "../../stripe/stripe-webhook-handlers"; 4 | import { protectedProcedure, router } from "../trpc"; 5 | 6 | export const stripeRouter = router({ 7 | createCheckoutSession: protectedProcedure.mutation(async ({ ctx }) => { 8 | const customerId = await getOrCreateStripeCustomerIdForUser({ 9 | prisma: ctx.prisma, 10 | stripe: ctx.stripe, 11 | userId: ctx.session.user?.id, 12 | }); 13 | if (!customerId) { 14 | throw new TRPCError({ 15 | code: "INTERNAL_SERVER_ERROR", 16 | message: "Could not create customer id", 17 | }); 18 | } 19 | const baseUrl = 20 | env.NODE_ENV === "development" 21 | ? `http://${ctx.req.headers.host}` 22 | : `https://${ctx.req.headers.host}`; 23 | const checkoutSession = await ctx.stripe.checkout.sessions.create({ 24 | customer: customerId, 25 | client_reference_id: ctx.session.user?.id, 26 | payment_method_types: ["card"], 27 | mode: "subscription", 28 | line_items: [ 29 | { 30 | price: env.STRIPE_PRICE_ID, 31 | quantity: 1, 32 | }, 33 | ], 34 | success_url: `${baseUrl}/app/account/prime?checkoutSuccess=true`, 35 | cancel_url: `${baseUrl}/app/account/prime?checkoutCanceled=true`, 36 | subscription_data: { 37 | metadata: { 38 | userId: ctx.session.user?.id, 39 | }, 40 | }, 41 | }); 42 | if (!checkoutSession) { 43 | throw new TRPCError({ 44 | code: "INTERNAL_SERVER_ERROR", 45 | message: "Could not create checkout session", 46 | }); 47 | } 48 | return { checkoutUrl: checkoutSession.url }; 49 | }), 50 | 51 | createBillingPortalSession: protectedProcedure.mutation(async ({ ctx }) => { 52 | const customerId = await getOrCreateStripeCustomerIdForUser({ 53 | prisma: ctx.prisma, 54 | stripe: ctx.stripe, 55 | userId: ctx.session.user?.id, 56 | }); 57 | if (!customerId) { 58 | throw new TRPCError({ 59 | code: "INTERNAL_SERVER_ERROR", 60 | message: "Could not create customer id", 61 | }); 62 | } 63 | const baseUrl = 64 | env.NODE_ENV === "development" 65 | ? `http://${ctx.req.headers.host}` 66 | : `https://${ctx.req.headers.host}`; 67 | const stripeBillingPortalSession = 68 | await ctx.stripe.billingPortal.sessions.create({ 69 | customer: customerId, 70 | return_url: `${baseUrl}/app/account/prime`, 71 | }); 72 | if (!stripeBillingPortalSession) { 73 | throw new TRPCError({ 74 | code: "INTERNAL_SERVER_ERROR", 75 | message: "Could not create billing portal session", 76 | }); 77 | } 78 | return { billingPortalUrl: stripeBillingPortalSession.url }; 79 | }), 80 | 81 | getCustomer: protectedProcedure.query(async ({ ctx }) => { 82 | const customerId = await getOrCreateStripeCustomerIdForUser({ 83 | prisma: ctx.prisma, 84 | stripe: ctx.stripe, 85 | userId: ctx.session.user?.id, 86 | }); 87 | if (!customerId) { 88 | throw new TRPCError({ 89 | code: "INTERNAL_SERVER_ERROR", 90 | message: "Could not create customer id", 91 | }); 92 | } 93 | const customer = await ctx.stripe.customers.retrieve(customerId); 94 | if (!customer) { 95 | throw new TRPCError({ 96 | code: "INTERNAL_SERVER_ERROR", 97 | message: "Could not get customer", 98 | }); 99 | } 100 | return customer; 101 | }), 102 | }); 103 | -------------------------------------------------------------------------------- /src/components/ConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, Transition } from "@headlessui/react"; 2 | import type { Dispatch, MouseEventHandler, SetStateAction } from "react"; 3 | import { Fragment } from "react"; 4 | 5 | type ConfirmationModalProps = { 6 | isOpen: boolean; 7 | setIsOpen: Dispatch>; 8 | name: string; 9 | description: string; 10 | onConfirm: MouseEventHandler | undefined; 11 | isLoading?: boolean; 12 | }; 13 | 14 | const ConfirmationModal = ({ 15 | isOpen, 16 | setIsOpen, 17 | name, 18 | description, 19 | onConfirm, 20 | isLoading, 21 | }: ConfirmationModalProps) => { 22 | return ( 23 | 24 | setIsOpen(false)} 28 | > 29 | 38 | 39 | 40 | 41 | 42 | 51 | 52 | 56 | {name} 57 | 58 | 59 | {description} 60 | 61 | 62 | 69 | {isLoading ? "Loading..." : "Proceed"} 70 | 71 | setIsOpen(false)} 76 | disabled={isLoading} 77 | > 78 | Cancel 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | ); 88 | }; 89 | 90 | export default ConfirmationModal; 91 | -------------------------------------------------------------------------------- /src/components/ProductList.tsx: -------------------------------------------------------------------------------- 1 | import { useCartStore } from "@/stores/cart"; 2 | import { formatCurrency, truncateText } from "@/utils/format"; 3 | import { renderStars } from "@/utils/render"; 4 | import type { Product } from "@prisma/client"; 5 | import Image from "next/image"; 6 | import Link from "next/link"; 7 | import { Fragment } from "react"; 8 | import { toast } from "react-hot-toast"; 9 | 10 | 11 | // external imports 12 | import Button from "./ui/Button"; 13 | 14 | const ProductList = ({ products }: { products: Product[] }) => { 15 | return ( 16 | 20 | Product list 21 | 22 | 23 | 31 | 32 | 33 | 34 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default ProductList; 44 | 45 | // SlicedProducts.tsx 46 | type SlicedProductsProps = { 47 | products: Product[]; 48 | range: { 49 | from: number; 50 | to: number; 51 | }; 52 | }; 53 | 54 | const SlicedProducts = ({ products, range }: SlicedProductsProps) => { 55 | // zustand 56 | const cartStore = useCartStore((state) => ({ 57 | products: state.products, 58 | addProduct: state.addProduct, 59 | })); 60 | 61 | return ( 62 | 63 | {products.slice(range.from, range.to).map((product) => ( 64 | 68 | 72 | 80 | 81 | 82 | {product.rating ? renderStars(product.rating) : "-"} 83 | 84 | 85 | 86 | {product.name ?? "-"} 87 | 88 | 89 | 90 | {product.description ?? "-"} 91 | 92 | {product.price ? ( 93 | 94 | {formatCurrency(product.price, "USD")} 95 | 96 | ) : ( 97 | "-" 98 | )} 99 | { 103 | cartStore.addProduct(product); 104 | toast.success(`${truncateText(product.name, 16)} added to cart`); 105 | }} 106 | > 107 | Add to Cart 108 | 109 | 110 | ))} 111 | 112 | ); 113 | }; 114 | -------------------------------------------------------------------------------- /src/pages/api/stripe-webhook.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse, PageConfig } from "next"; 2 | import { env } from "../../env/server.mjs"; 3 | import { prisma } from "../../server/db/client"; 4 | import type Stripe from "stripe"; 5 | import { buffer } from "micro"; 6 | import { 7 | handleInvoicePaid, 8 | handleSubscriptionCanceled, 9 | handleSubscriptionCreatedOrUpdated, 10 | } from "../../server/stripe/stripe-webhook-handlers"; 11 | import { stripe } from "../../server/stripe/client"; 12 | 13 | // Stripe requires the raw body to construct the event. 14 | export const config: PageConfig = { 15 | api: { 16 | bodyParser: false, 17 | }, 18 | }; 19 | 20 | const webhookSecret = env.STRIPE_WEBHOOK_SECRET; 21 | 22 | export default async function handler( 23 | req: NextApiRequest, 24 | res: NextApiResponse 25 | ) { 26 | if (req.method === "POST") { 27 | const buf = await buffer(req); 28 | const sig = req.headers["stripe-signature"]; 29 | 30 | let event: Stripe.Event; 31 | 32 | try { 33 | event = stripe.webhooks.constructEvent(buf, sig as string, webhookSecret); 34 | 35 | // Handle the event 36 | switch (event.type) { 37 | case "invoice.paid": 38 | // Used to provision services after the trial has ended. 39 | // The status of the invoice will show up as paid. Store the status in your database to reference when a user accesses your service to avoid hitting rate limits. 40 | await handleInvoicePaid({ 41 | event, 42 | stripe, 43 | prisma, 44 | }); 45 | break; 46 | case "customer.subscription.created": 47 | // Used to provision services as they are added to a subscription. 48 | await handleSubscriptionCreatedOrUpdated({ 49 | event, 50 | prisma, 51 | }); 52 | break; 53 | case "customer.subscription.updated": 54 | // Used to provision services as they are updated. 55 | await handleSubscriptionCreatedOrUpdated({ 56 | event, 57 | prisma, 58 | }); 59 | break; 60 | case "invoice.payment_failed": 61 | // If the payment fails or the customer does not have a valid payment method, 62 | // an invoice.payment_failed event is sent, the subscription becomes past_due. 63 | // Use this webhook to notify your user that their payment has 64 | // failed and to retrieve new card details. 65 | // Can also have Stripe send an email to the customer notifying them of the failure. See settings: https://dashboard.stripe.com/settings/billing/automatic 66 | break; 67 | case "customer.subscription.deleted": 68 | // handle subscription cancelled automatically based 69 | // upon your subscription settings. 70 | await handleSubscriptionCanceled({ 71 | event, 72 | prisma, 73 | }); 74 | break; 75 | default: 76 | // Unexpected event type 77 | } 78 | 79 | // record the event in the database 80 | await prisma.stripeEvent.create({ 81 | data: { 82 | id: event.id, 83 | type: event.type, 84 | object: event.object, 85 | api_version: event.api_version, 86 | acount: event.account, 87 | createdAt: new Date(event.created * 1000), // convert to milliseconds 88 | data: { 89 | object: event.data.object, 90 | previous_attributes: event.data.previous_attributes, 91 | }, 92 | livemode: event.livemode, 93 | pending_webhooks: event.pending_webhooks, 94 | request: { 95 | id: event.request?.id, 96 | idempotency_key: event.request?.idempotency_key, 97 | }, 98 | }, 99 | }); 100 | 101 | res.json({ received: true }); 102 | } catch (error) { 103 | res 104 | .status(400) 105 | .send( 106 | `Webhook Error: ${error instanceof Error ? error.message : error}` 107 | ); 108 | return; 109 | } 110 | } else { 111 | res.setHeader("Allow", "POST"); 112 | res.status(405).end("Method Not Allowed"); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/pages/dashboard/orders/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPageWithLayout } from "@/pages/_app"; 2 | import { trpc } from "@/utils/trpc"; 3 | import type { Order, User } from "@prisma/client"; 4 | import type { 5 | ColumnDef, 6 | ColumnFiltersState, 7 | PaginationState, 8 | SortingState, 9 | VisibilityState, 10 | } from "@tanstack/react-table"; 11 | import dayjs from "dayjs"; 12 | import Head from "next/head"; 13 | import Link from "next/link"; 14 | import Router from "next/router"; 15 | import { useMemo, useState } from "react"; 16 | 17 | // external imports 18 | import Button from "@/components/ui/Button"; 19 | import CustomTable from "@/components/ui/Table"; 20 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 21 | 22 | type OrderWithUser = Order & { user: User }; 23 | 24 | const Orders: NextPageWithLayout = () => { 25 | // tanstack/react-table 26 | const [sorting, setSorting] = useState([ 27 | { id: "createdAt", desc: true }, 28 | ]); 29 | const [columnFilters, setColumnFilters] = useState([]); 30 | const [columnVisibility, setColumnVisibility] = useState({ 31 | id: false, 32 | }); 33 | const [{ pageIndex, pageSize }, setPagination] = useState({ 34 | pageIndex: 0, 35 | pageSize: 10, 36 | }); 37 | const pagination = useMemo( 38 | () => ({ 39 | pageIndex, 40 | pageSize, 41 | }), 42 | [pageIndex, pageSize] 43 | ); 44 | 45 | const columns = useMemo[]>( 46 | () => [ 47 | { 48 | accessorKey: "id", 49 | }, 50 | { 51 | accessorKey: "user.name", 52 | header: "Creator name", 53 | }, 54 | { 55 | accessorKey: "user.email", 56 | header: "Creator email", 57 | }, 58 | { 59 | accessorKey: "createdAt", 60 | header: "Created at", 61 | enableColumnFilter: false, 62 | enableGlobalFilter: false, 63 | cell: ({ cell }) => 64 | cell.getValue() 65 | ? dayjs(cell.getValue()).format("DD/MM/YYYY, hh:mm a") 66 | : "-", 67 | }, 68 | { 69 | accessorKey: "updatedAt", 70 | header: "Updated at", 71 | enableColumnFilter: false, 72 | enableGlobalFilter: false, 73 | cell: ({ cell }) => 74 | cell.getValue() 75 | ? dayjs(cell.getValue()).format("DD/MM/YYYY, hh:mm a") 76 | : "-", 77 | }, 78 | ], 79 | [] 80 | ); 81 | 82 | // get orders query 83 | const { data, isLoading, isError, isRefetching } = 84 | trpc.admin.orders.get.useQuery( 85 | { 86 | page: pagination.pageIndex, 87 | perPage: pagination.pageSize, 88 | }, 89 | { refetchOnWindowFocus: false } 90 | ); 91 | 92 | return ( 93 | <> 94 | 95 | Orders | Amzn Store 96 | 97 | 98 | 99 | 100 | tableTitle={ 101 | <> 102 | {`Orders (${data?.count ?? 0} entries)`} 103 | 104 | Add order 105 | 106 | > 107 | } 108 | columns={columns} 109 | data={data?.orders ?? []} 110 | state={{ 111 | sorting, 112 | pagination, 113 | columnVisibility, 114 | columnFilters, 115 | }} 116 | setSorting={setSorting} 117 | setColumnFilters={setColumnFilters} 118 | setColumnVisibility={setColumnVisibility} 119 | setPagination={setPagination} 120 | itemsCount={data?.count} 121 | isLoading={isLoading} 122 | isRefetching={isRefetching} 123 | isError={isError} 124 | manualFiltering 125 | manualPagination 126 | manualSorting 127 | rowHoverEffect 128 | disableGlobalFilter 129 | bodyRowProps={(row) => ({ 130 | onClick: () => { 131 | const orderId = row.getValue("id") as string; 132 | Router.push(`/dashboard/orders/${orderId}`); 133 | }, 134 | })} 135 | /> 136 | 137 | 138 | > 139 | ); 140 | }; 141 | 142 | export default Orders; 143 | 144 | Orders.getLayout = (page) => {page}; 145 | -------------------------------------------------------------------------------- /src/server/trpc/router/admin/orders.ts: -------------------------------------------------------------------------------- 1 | import type { Prisma } from "@prisma/client"; 2 | import { TRPCError } from "@trpc/server"; 3 | import { z } from "zod"; 4 | import { adminProcedure, router } from "../../trpc"; 5 | 6 | export const ordersAdminRouter = router({ 7 | get: adminProcedure 8 | .input( 9 | z.object({ 10 | page: z.number().int().default(0), 11 | perPage: z.number().int().default(10), 12 | sortBy: z.enum(["createdAt", "updatedAt"]).optional(), 13 | sortDesc: z.boolean().default(false), 14 | }) 15 | ) 16 | .query(async ({ ctx, input }) => { 17 | const params: Prisma.OrderFindManyArgs = { 18 | orderBy: input.sortBy 19 | ? { [input.sortBy]: input.sortDesc ? "desc" : "asc" } 20 | : undefined, 21 | }; 22 | 23 | const [count, orders] = await ctx.prisma.$transaction([ 24 | ctx.prisma.order.count({ where: params.where }), 25 | ctx.prisma.order.findMany({ 26 | ...params, 27 | skip: input.page * input.perPage, 28 | take: input.perPage, 29 | include: { 30 | user: true, 31 | }, 32 | }), 33 | ]); 34 | return { count, orders }; 35 | }), 36 | 37 | getOne: adminProcedure.input(z.string()).query(async ({ ctx, input }) => { 38 | const order = await ctx.prisma.order.findUnique({ 39 | where: { id: input }, 40 | include: { 41 | items: { 42 | include: { 43 | product: true, 44 | }, 45 | }, 46 | }, 47 | }); 48 | if (!order) { 49 | throw new TRPCError({ 50 | code: "NOT_FOUND", 51 | message: "Order not found!", 52 | }); 53 | } 54 | return order; 55 | }), 56 | 57 | delete: adminProcedure.input(z.string()).mutation(async ({ ctx, input }) => { 58 | const order = await ctx.prisma.order.delete({ 59 | where: { 60 | id: input, 61 | }, 62 | }); 63 | if (!order) { 64 | throw new TRPCError({ 65 | code: "NOT_FOUND", 66 | message: "Order not found!", 67 | }); 68 | } 69 | return order; 70 | }), 71 | 72 | deleteItem: adminProcedure 73 | .input(z.string()) 74 | .mutation(async ({ ctx, input }) => { 75 | const orderItem = await ctx.prisma.orderItem.delete({ 76 | where: { 77 | id: input, 78 | }, 79 | }); 80 | if (!orderItem) { 81 | throw new TRPCError({ 82 | code: "NOT_FOUND", 83 | message: "Order item not found!", 84 | }); 85 | } 86 | const orderItems = await ctx.prisma.orderItem.findMany({ 87 | where: { 88 | orderId: orderItem.orderId, 89 | }, 90 | }); 91 | if (orderItems.length === 0) { 92 | await ctx.prisma.order.delete({ 93 | where: { 94 | id: orderItem.orderId, 95 | }, 96 | }); 97 | } 98 | return orderItem; 99 | }), 100 | 101 | prev: adminProcedure.input(z.string()).mutation(async ({ ctx, input }) => { 102 | const order = await ctx.prisma.order.findUnique({ 103 | where: { id: input }, 104 | }); 105 | if (!order) { 106 | throw new TRPCError({ 107 | code: "NOT_FOUND", 108 | message: "Order not found!", 109 | }); 110 | } 111 | const prevOrder = await ctx.prisma.order.findFirst({ 112 | where: { 113 | createdAt: { 114 | lt: order.createdAt, 115 | }, 116 | }, 117 | orderBy: { 118 | createdAt: "desc", 119 | }, 120 | }); 121 | if (!prevOrder) { 122 | const lastOrder = await ctx.prisma.order.findFirst({ 123 | orderBy: { 124 | createdAt: "desc", 125 | }, 126 | }); 127 | return lastOrder; 128 | } 129 | return prevOrder; 130 | }), 131 | 132 | next: adminProcedure.input(z.string()).mutation(async ({ ctx, input }) => { 133 | const order = await ctx.prisma.order.findUnique({ 134 | where: { id: input }, 135 | }); 136 | if (!order) { 137 | throw new TRPCError({ 138 | code: "NOT_FOUND", 139 | message: "Order not found!", 140 | }); 141 | } 142 | const nextOrder = await ctx.prisma.order.findFirst({ 143 | where: { 144 | createdAt: { 145 | gt: order.createdAt, 146 | }, 147 | }, 148 | orderBy: { 149 | createdAt: "asc", 150 | }, 151 | }); 152 | if (!nextOrder) { 153 | const firstOrder = await ctx.prisma.order.findFirst({ 154 | orderBy: { 155 | createdAt: "asc", 156 | }, 157 | }); 158 | return firstOrder; 159 | } 160 | return nextOrder; 161 | }), 162 | }); 163 | -------------------------------------------------------------------------------- /src/pages/dashboard/users/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPageWithLayout } from "@/pages/_app"; 2 | import { formatEnum } from "@/utils/format"; 3 | import { trpc } from "@/utils/trpc"; 4 | import type { User, USER_ROLE } from "@prisma/client"; 5 | import type { 6 | ColumnDef, 7 | ColumnFiltersState, 8 | PaginationState, 9 | SortingState, 10 | VisibilityState, 11 | } from "@tanstack/react-table"; 12 | import dayjs from "dayjs"; 13 | import Head from "next/head"; 14 | import Router from "next/router"; 15 | import { useMemo, useState } from "react"; 16 | 17 | // external imports 18 | import CustomTable from "@/components/ui/Table"; 19 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 20 | 21 | type TextField = string | undefined; 22 | type CategoryField = USER_ROLE | undefined; 23 | 24 | const Users: NextPageWithLayout = () => { 25 | // tanstack/react-table 26 | const [sorting, setSorting] = useState([ 27 | { id: "createdAt", desc: true }, 28 | ]); 29 | const [columnFilters, setColumnFilters] = useState([]); 30 | const [columnVisibility, setColumnVisibility] = useState({ 31 | id: false, 32 | }); 33 | const [{ pageIndex, pageSize }, setPagination] = useState({ 34 | pageIndex: 0, 35 | pageSize: 10, 36 | }); 37 | const pagination = useMemo( 38 | () => ({ 39 | pageIndex, 40 | pageSize, 41 | }), 42 | [pageIndex, pageSize] 43 | ); 44 | 45 | const columns = useMemo[]>( 46 | () => [ 47 | { 48 | accessorKey: "name", 49 | header: "Name", 50 | }, 51 | { 52 | accessorKey: "email", 53 | header: "Email", 54 | }, 55 | { 56 | accessorKey: "role", 57 | header: "Role", 58 | cell: ({ cell }) => 59 | cell.getValue() ? formatEnum(cell.getValue()) : "-", 60 | }, 61 | { 62 | accessorKey: "active", 63 | header: "Status", 64 | cell: ({ cell }) => (cell.getValue() ? "Active" : "Inactive"), 65 | }, 66 | { 67 | accessorKey: "createdAt", 68 | header: "Created at", 69 | enableColumnFilter: false, 70 | enableGlobalFilter: false, 71 | cell: ({ cell }) => 72 | cell.getValue() 73 | ? dayjs(cell.getValue()).format("DD/MM/YYYY, hh:mm a") 74 | : "-", 75 | }, 76 | ], 77 | [] 78 | ); 79 | 80 | // get users query 81 | const { data, isLoading, isError, isRefetching } = 82 | trpc.admin.users.get.useQuery( 83 | { 84 | page: pagination.pageIndex, 85 | perPage: pagination.pageSize, 86 | name: columnFilters.find((f) => f.id === "name")?.value as TextField, 87 | email: columnFilters.find((f) => f.id === "email")?.value as TextField, 88 | role: columnFilters.find((f) => f.id === "role") 89 | ?.value as CategoryField, 90 | sortBy: sorting[0]?.id as 91 | | "name" 92 | | "email" 93 | | "role" 94 | | "active" 95 | | "createdAt" 96 | | undefined, 97 | sortDesc: sorting[0]?.desc, 98 | }, 99 | { refetchOnWindowFocus: false } 100 | ); 101 | 102 | return ( 103 | <> 104 | 105 | Users | Amzn Store 106 | 107 | 108 | 109 | 110 | tableTitle={`Users (${data?.count ?? 0} entries)`} 111 | columns={columns} 112 | data={data?.users ?? []} 113 | state={{ 114 | sorting, 115 | pagination, 116 | columnVisibility, 117 | columnFilters, 118 | }} 119 | setSorting={setSorting} 120 | setColumnFilters={setColumnFilters} 121 | setColumnVisibility={setColumnVisibility} 122 | setPagination={setPagination} 123 | isLoading={isLoading} 124 | isRefetching={isRefetching} 125 | isError={isError} 126 | manualFiltering 127 | manualPagination 128 | manualSorting 129 | rowHoverEffect 130 | disableGlobalFilter 131 | bodyRowProps={(row) => ({ 132 | onClick: () => { 133 | const userId = row.original.id as string; 134 | Router.push(`/dashboard/users/${userId}`); 135 | }, 136 | })} 137 | /> 138 | 139 | 140 | > 141 | ); 142 | }; 143 | 144 | export default Users; 145 | 146 | Users.getLayout = (page) => {page}; 147 | -------------------------------------------------------------------------------- /src/server/trpc/router/orders.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { protectedProcedure, router } from "../trpc"; 3 | 4 | export const ordersRouter = router({ 5 | get: protectedProcedure.query(async ({ ctx }) => { 6 | const orders = await ctx.prisma.order.findMany({ 7 | where: { 8 | userId: ctx.session.user.id, 9 | archived: false, 10 | }, 11 | include: { 12 | items: { 13 | include: { 14 | product: true, 15 | }, 16 | where: { 17 | archived: false, 18 | }, 19 | }, 20 | }, 21 | }); 22 | return orders; 23 | }), 24 | 25 | getArchived: protectedProcedure.query(async ({ ctx }) => { 26 | const orders = await ctx.prisma.order.findMany({ 27 | where: { 28 | userId: ctx.session.user.id, 29 | }, 30 | include: { 31 | items: { 32 | include: { 33 | product: true, 34 | }, 35 | where: { 36 | archived: true, 37 | }, 38 | }, 39 | }, 40 | }); 41 | return orders; 42 | }), 43 | 44 | getOne: protectedProcedure.input(z.string()).query(async ({ ctx, input }) => { 45 | const order = await ctx.prisma.order.findUnique({ 46 | where: { 47 | id: input, 48 | }, 49 | include: { 50 | items: { 51 | include: { 52 | product: true, 53 | }, 54 | }, 55 | }, 56 | }); 57 | if (!order) { 58 | throw new Error("Order not found!"); 59 | } 60 | return order; 61 | }), 62 | 63 | getItems: protectedProcedure 64 | .input(z.string()) 65 | .query(async ({ ctx, input }) => { 66 | const orderItems = await ctx.prisma.orderItem.findMany({ 67 | where: { 68 | orderId: input, 69 | }, 70 | include: { 71 | product: true, 72 | }, 73 | }); 74 | if (!orderItems) { 75 | throw new Error("Order not found!"); 76 | } 77 | return orderItems; 78 | }), 79 | 80 | getUserItems: protectedProcedure.query(async ({ ctx }) => { 81 | const orderItems = await ctx.prisma.orderItem.findMany({ 82 | where: { 83 | order: { 84 | userId: ctx.session.user.id, 85 | }, 86 | }, 87 | include: { 88 | product: true, 89 | }, 90 | }); 91 | if (!orderItems) { 92 | throw new Error("Order not found!"); 93 | } 94 | return orderItems; 95 | }), 96 | 97 | updateItem: protectedProcedure 98 | .input( 99 | z.object({ 100 | id: z.string(), 101 | archived: z.boolean(), 102 | }) 103 | ) 104 | .mutation(async ({ ctx, input }) => { 105 | const orderItem = await ctx.prisma.orderItem.update({ 106 | where: { 107 | id: input.id, 108 | }, 109 | data: { 110 | archived: !input.archived, 111 | }, 112 | }); 113 | if (!orderItem) { 114 | throw new Error("Order item not found!"); 115 | } 116 | const orderItems = await ctx.prisma.orderItem.findMany({ 117 | where: { 118 | orderId: orderItem.orderId, 119 | }, 120 | }); 121 | const orderItemsArchived = orderItems.every((item) => item.archived); 122 | const order = await ctx.prisma.order.update({ 123 | where: { 124 | id: orderItem.orderId, 125 | }, 126 | data: { 127 | archived: orderItemsArchived, 128 | }, 129 | }); 130 | if (!order) { 131 | throw new Error("Order not found!"); 132 | } 133 | return orderItem; 134 | }), 135 | 136 | create: protectedProcedure 137 | .input( 138 | z.array( 139 | z.object({ 140 | productId: z.string(), 141 | productQuantity: z.number(), 142 | }) 143 | ) 144 | ) 145 | .mutation(async ({ ctx, input }) => { 146 | const order = await ctx.prisma.order.create({ 147 | data: { 148 | userId: ctx.session.user.id, 149 | }, 150 | }); 151 | if (!order) { 152 | throw new Error("Order not found!"); 153 | } 154 | const orderItems = await Promise.all( 155 | input.map(async ({ productId, productQuantity }) => { 156 | const product = await ctx.prisma.product.findUnique({ 157 | where: { 158 | id: productId, 159 | }, 160 | }); 161 | if (!product) { 162 | throw new Error("Product not found!"); 163 | } 164 | const orderItem = await ctx.prisma.orderItem.create({ 165 | data: { 166 | orderId: order.id, 167 | productId: product.id, 168 | quantity: productQuantity, 169 | }, 170 | }); 171 | return orderItem; 172 | }) 173 | ); 174 | return orderItems; 175 | }), 176 | }); 177 | -------------------------------------------------------------------------------- /src/pages/dashboard/products/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPageWithLayout } from "@/pages/_app"; 2 | import { formatCurrency, formatEnum } from "@/utils/format"; 3 | import { trpc } from "@/utils/trpc"; 4 | import type { Product, PRODUCT_CATEGORY } from "@prisma/client"; 5 | import type { 6 | ColumnDef, 7 | ColumnFiltersState, 8 | PaginationState, 9 | SortingState, 10 | VisibilityState, 11 | } from "@tanstack/react-table"; 12 | import dayjs from "dayjs"; 13 | import Head from "next/head"; 14 | import Link from "next/link"; 15 | import Router from "next/router"; 16 | import { useMemo, useState } from "react"; 17 | 18 | // external imports 19 | import Button from "@/components/ui/Button"; 20 | import CustomTable from "@/components/ui/Table"; 21 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 22 | 23 | type TextField = string | undefined; 24 | type NumberField = number | undefined; 25 | type CategoryField = PRODUCT_CATEGORY | undefined; 26 | 27 | const Products: NextPageWithLayout = () => { 28 | // tanstack/react-table 29 | const [sorting, setSorting] = useState([ 30 | { id: "createdAt", desc: true }, 31 | ]); 32 | const [columnFilters, setColumnFilters] = useState([]); 33 | const [columnVisibility, setColumnVisibility] = useState({ 34 | id: false, 35 | }); 36 | const [{ pageIndex, pageSize }, setPagination] = useState({ 37 | pageIndex: 0, 38 | pageSize: 10, 39 | }); 40 | const pagination = useMemo( 41 | () => ({ 42 | pageIndex, 43 | pageSize, 44 | }), 45 | [pageIndex, pageSize] 46 | ); 47 | 48 | const columns = useMemo[]>( 49 | () => [ 50 | { 51 | accessorKey: "id", 52 | }, 53 | { 54 | accessorKey: "name", 55 | header: "Name", 56 | }, 57 | { 58 | accessorKey: "price", 59 | header: "Price", 60 | cell: ({ cell }) => 61 | cell.getValue() ? formatCurrency(cell.getValue(), "USD") : "-", 62 | }, 63 | { 64 | accessorKey: "category", 65 | header: "Category", 66 | cell: ({ cell }) => 67 | cell.getValue() ? formatEnum(cell.getValue()) : "-", 68 | }, 69 | { 70 | accessorKey: "rating", 71 | header: "Rating", 72 | }, 73 | { 74 | accessorKey: "createdAt", 75 | header: "Created at", 76 | enableColumnFilter: false, 77 | enableGlobalFilter: false, 78 | cell: ({ cell }) => 79 | cell.getValue() 80 | ? dayjs(cell.getValue()).format("DD/MM/YYYY, hh:mm a") 81 | : "-", 82 | }, 83 | ], 84 | [] 85 | ); 86 | 87 | // get products query 88 | const { data, isLoading, isError, isRefetching } = 89 | trpc.admin.products.get.useQuery( 90 | { 91 | page: pagination.pageIndex, 92 | perPage: pagination.pageSize, 93 | name: columnFilters.find((f) => f.id === "name")?.value as TextField, 94 | price: columnFilters.find((f) => f.id === "price") 95 | ?.value as NumberField, 96 | category: columnFilters.find((f) => f.id === "category") 97 | ?.value as CategoryField, 98 | rating: columnFilters.find((f) => f.id === "rating") 99 | ?.value as NumberField, 100 | sortBy: sorting[0]?.id as 101 | | "name" 102 | | "category" 103 | | "quantity" 104 | | "price" 105 | | "rating" 106 | | "createdAt" 107 | | undefined, 108 | sortDesc: sorting[0]?.desc, 109 | }, 110 | { refetchOnWindowFocus: false } 111 | ); 112 | 113 | return ( 114 | <> 115 | 116 | Products | Amzn Store 117 | 118 | 119 | 120 | 121 | tableTitle={ 122 | <> 123 | {`Products (${data?.count ?? 0} entries)`} 124 | 125 | Add product 126 | 127 | > 128 | } 129 | columns={columns} 130 | data={data?.products ?? []} 131 | state={{ 132 | sorting, 133 | pagination, 134 | columnVisibility, 135 | columnFilters, 136 | }} 137 | setSorting={setSorting} 138 | setColumnFilters={setColumnFilters} 139 | setColumnVisibility={setColumnVisibility} 140 | setPagination={setPagination} 141 | itemsCount={data?.count} 142 | isLoading={isLoading} 143 | isRefetching={isRefetching} 144 | isError={isError} 145 | manualFiltering 146 | manualPagination 147 | manualSorting 148 | rowHoverEffect 149 | disableGlobalFilter 150 | bodyRowProps={(row) => ({ 151 | onClick: () => { 152 | const productId = row.getValue("id") as string; 153 | Router.push(`/dashboard/products/${productId}`); 154 | }, 155 | })} 156 | /> 157 | 158 | 159 | > 160 | ); 161 | }; 162 | 163 | export default Products; 164 | 165 | Products.getLayout = (page) => {page}; 166 | -------------------------------------------------------------------------------- /src/pages/app/account/prime.tsx: -------------------------------------------------------------------------------- 1 | import { getServerAuthSession } from "@/server/common/get-server-auth-session"; 2 | import { trpc } from "@/utils/trpc"; 3 | import { STRIPE_SUBSCRIPTION_STATUS } from "@prisma/client"; 4 | import type { GetServerSideProps } from "next"; 5 | import { useSession } from "next-auth/react"; 6 | import Head from "next/head"; 7 | import Router from "next/router"; 8 | import { useEffect } from "react"; 9 | import { toast } from "react-hot-toast"; 10 | import type { NextPageWithLayout } from "../../_app"; 11 | 12 | // external imports 13 | import Button from "@/components/ui/Button"; 14 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 15 | import ErrorScreen from "@/components/screens/ErrorScreen"; 16 | import LoadingScreen from "@/components/screens/LoadingScreen"; 17 | 18 | const Prime: NextPageWithLayout = () => { 19 | const { status } = useSession(); 20 | 21 | // subscription status query 22 | const subscriptionStatusQuery = trpc.users.getSubscriptionStatus.useQuery( 23 | undefined, 24 | { 25 | enabled: status === "authenticated", 26 | } 27 | ); 28 | 29 | // checkout session mutation 30 | const checkoutSessionMutation = trpc.stripe.createCheckoutSession.useMutation( 31 | { 32 | onSuccess: async (data) => { 33 | if (!data.checkoutUrl) return; 34 | Router.push(data.checkoutUrl); 35 | toast.success("Redirecting to checkout..."); 36 | }, 37 | onError: async (err) => { 38 | toast.error(err.message); 39 | }, 40 | } 41 | ); 42 | 43 | // billing portal session mutation 44 | const billingPortalSessionMutation = 45 | trpc.stripe.createBillingPortalSession.useMutation({ 46 | onSuccess: async (data) => { 47 | if (!data.billingPortalUrl) return; 48 | Router.push(data.billingPortalUrl); 49 | toast.success("Redirecting to billing portal..."); 50 | }, 51 | onError: async (err) => { 52 | toast.error(err.message); 53 | }, 54 | }); 55 | 56 | // refetch subscription status query 57 | const apiUtils = trpc.useContext(); 58 | const number = 0; 59 | useEffect(() => { 60 | if (number === 0) { 61 | apiUtils.users.getSubscriptionStatus.invalidate(); 62 | } 63 | subscriptionStatusQuery.refetch(); 64 | }, [apiUtils, subscriptionStatusQuery]); 65 | 66 | if (subscriptionStatusQuery.isLoading) { 67 | return ; 68 | } 69 | 70 | if (subscriptionStatusQuery.isError) { 71 | return ; 72 | } 73 | 74 | return ( 75 | <> 76 | 77 | Prime | Amzn Store 78 | 79 | 80 | 81 | {subscriptionStatusQuery.data === 82 | STRIPE_SUBSCRIPTION_STATUS.active ? ( 83 | 84 | 85 | You are a Prime member 86 | 87 | 88 | Thank you for being a Prime member 89 | 90 | { 94 | await billingPortalSessionMutation.mutateAsync(); 95 | }} 96 | disabled={billingPortalSessionMutation.isLoading} 97 | > 98 | {billingPortalSessionMutation.isLoading 99 | ? "Loading..." 100 | : "Manage your Prime membership"} 101 | 102 | 103 | ) : ( 104 | 105 | 106 | Join Prime to get free shipping, ad-free music, exclusive deals, 107 | and more 108 | 109 | 110 | Get unlimited free two-day shipping, ad-free music, exclusive 111 | deals, and more 112 | 113 | { 117 | await checkoutSessionMutation.mutateAsync(); 118 | }} 119 | disabled={checkoutSessionMutation.isLoading} 120 | > 121 | {checkoutSessionMutation.isLoading 122 | ? "Loading..." 123 | : "Start your 30-day free trial"} 124 | 125 | 126 | )} 127 | 128 | 129 | > 130 | ); 131 | }; 132 | 133 | export default Prime; 134 | 135 | Prime.getLayout = (page) => {page}; 136 | 137 | export const getServerSideProps: GetServerSideProps = async (ctx) => { 138 | const session = await getServerAuthSession({ 139 | req: ctx.req, 140 | res: ctx.res, 141 | }); 142 | 143 | if (!session) { 144 | return { 145 | redirect: { 146 | destination: "/api/auth/signin", 147 | permanent: false, 148 | }, 149 | }; 150 | } 151 | 152 | return { 153 | props: { 154 | session, 155 | }, 156 | }; 157 | }; 158 | -------------------------------------------------------------------------------- /src/pages/dashboard/orders/[orderId].tsx: -------------------------------------------------------------------------------- 1 | import type { OrderItemWithProduct } from "@/types/globals"; 2 | import { trpc } from "@/utils/trpc"; 3 | import { useIsMutating } from "@tanstack/react-query"; 4 | import Head from "next/head"; 5 | import Router from "next/router"; 6 | import { useEffect } from "react"; 7 | import { toast } from "react-hot-toast"; 8 | import type { NextPageWithLayout } from "../../_app"; 9 | 10 | // external imports 11 | import Button from "@/components/ui/Button"; 12 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 13 | import ErrorScreen from "@/components/screens/ErrorScreen"; 14 | import LoadingScreen from "@/components/screens/LoadingScreen"; 15 | import { 16 | ArrowLeftCircleIcon, 17 | ArrowRightCircleIcon, 18 | } from "@heroicons/react/20/solid"; 19 | 20 | const UpdateOrder: NextPageWithLayout = () => { 21 | const orderId = Router.query.orderId as string; 22 | 23 | // get order query 24 | const orderQuery = trpc.admin.orders.getOne.useQuery(orderId, { 25 | enabled: Boolean(orderId), 26 | }); 27 | 28 | // delete order mutation 29 | const deleteOrderMutation = trpc.admin.orders.delete.useMutation({ 30 | onSuccess: async () => { 31 | toast.success("Order deleted"); 32 | Router.push("/dashboard/orders"); 33 | }, 34 | onError: async (err) => { 35 | toast.error(err.message); 36 | }, 37 | }); 38 | 39 | // prev order mutation 40 | const prevOrderMutation = trpc.admin.orders.prev.useMutation({ 41 | onSuccess: async (data) => { 42 | if (!data) { 43 | return toast.error("No previous order!"); 44 | } 45 | Router.push(`/dashboard/orders/${data.id}`); 46 | }, 47 | onError: async (err) => { 48 | toast.error(err.message); 49 | }, 50 | }); 51 | 52 | // next user mutation 53 | const nextOrderMutation = trpc.admin.orders.next.useMutation({ 54 | onSuccess: async (data) => { 55 | if (!data) { 56 | return toast.error("No next order!"); 57 | } 58 | Router.push(`/dashboard/orders/${data.id}`); 59 | }, 60 | onError: async (err) => { 61 | toast.error(err.message); 62 | }, 63 | }); 64 | 65 | // refetch queries 66 | const utils = trpc.useContext(); 67 | const number = useIsMutating(); 68 | useEffect(() => { 69 | if (number === 0) { 70 | utils.admin.orders.getOne.invalidate(orderId); 71 | utils.orders.get.invalidate(); 72 | utils.orders.getArchived.invalidate(); 73 | } 74 | }, [number, orderId, utils]); 75 | 76 | // redirect if no order 77 | useEffect(() => { 78 | if (orderQuery.data === null) { 79 | Router.push("/dashboard/orders"); 80 | } 81 | }, [orderQuery.data]); 82 | 83 | if (orderQuery.isLoading) { 84 | return ; 85 | } 86 | 87 | if (orderQuery.isError) { 88 | return ; 89 | } 90 | 91 | return ( 92 | <> 93 | 94 | Update Order | Amzn Store 95 | 96 | 97 | 98 | 99 | Router.push("/dashboard/orders")} 103 | > 104 | 108 | 109 | 110 | prevOrderMutation.mutateAsync(orderId)} 113 | > 114 | 118 | 119 | nextOrderMutation.mutateAsync(orderId)} 122 | > 123 | 127 | 128 | 129 | 130 | 131 | 132 | {orderQuery.data?.items.map((item) => ( 133 | 134 | ))} 135 | {Number(orderQuery.data?.items.length) > 0 ? ( 136 | deleteOrderMutation.mutateAsync(orderId)} 140 | disabled={deleteOrderMutation.isLoading} 141 | > 142 | {deleteOrderMutation.isLoading ? "Loading..." : "Delete order"} 143 | 144 | ) : null} 145 | 146 | 147 | 148 | > 149 | ); 150 | }; 151 | 152 | export default UpdateOrder; 153 | 154 | UpdateOrder.getLayout = (page) => {page}; 155 | 156 | const Item = ({ item }: { item: OrderItemWithProduct }) => { 157 | // delete item mutation 158 | const deleteItemMutation = trpc.admin.orders.deleteItem.useMutation({ 159 | onSuccess: async () => { 160 | toast.success("Item deleted"); 161 | }, 162 | onError: async (err) => { 163 | toast.error(err.message); 164 | }, 165 | }); 166 | 167 | return ( 168 | 169 | 170 | {item.product.name} 171 | 172 | deleteItemMutation.mutateAsync(item.id)} 175 | > 176 | {deleteItemMutation.isLoading ? "Loading..." : "Delete product"} 177 | 178 | 179 | ); 180 | }; 181 | -------------------------------------------------------------------------------- /src/server/trpc/router/admin/users.ts: -------------------------------------------------------------------------------- 1 | import type { Prisma } from "@prisma/client"; 2 | import { USER_ROLE } from "@prisma/client"; 3 | import { TRPCError } from "@trpc/server"; 4 | import { z } from "zod"; 5 | import { adminProcedure, router } from "../../trpc"; 6 | 7 | export const usersAdminRouter = router({ 8 | get: adminProcedure 9 | .input( 10 | z.object({ 11 | page: z.number().int().default(0), 12 | perPage: z.number().int().default(10), 13 | name: z.string().optional(), 14 | email: z.string().optional(), 15 | role: z.nativeEnum(USER_ROLE).optional(), 16 | active: z.boolean().optional(), 17 | sortBy: z 18 | .enum(["email", "name", "role", "active", "createdAt"]) 19 | .optional(), 20 | sortDesc: z.boolean().default(false), 21 | }) 22 | ) 23 | .query(async ({ ctx, input }) => { 24 | const needFilter = input.name || input.email || input.role; 25 | 26 | const params: Prisma.UserFindManyArgs = { 27 | orderBy: input.sortBy 28 | ? { [input.sortBy]: input.sortDesc ? "desc" : "asc" } 29 | : undefined, 30 | where: needFilter 31 | ? { 32 | AND: { 33 | name: input.name ? { contains: input.name } : undefined, 34 | email: input.email ? { contains: input.email } : undefined, 35 | role: input.role ? { equals: input.role } : undefined, 36 | }, 37 | } 38 | : undefined, 39 | }; 40 | 41 | const [count, users] = await ctx.prisma.$transaction([ 42 | ctx.prisma.user.count({ where: params.where }), 43 | ctx.prisma.user.findMany({ 44 | ...params, 45 | skip: input.page * input.perPage, 46 | take: input.perPage, 47 | }), 48 | ]); 49 | return { count, users }; 50 | }), 51 | 52 | getOne: adminProcedure.input(z.string()).query(async ({ ctx, input }) => { 53 | const user = await ctx.prisma.user.findUnique({ 54 | where: { 55 | id: input, 56 | }, 57 | }); 58 | if (!user) 59 | throw new TRPCError({ 60 | code: "NOT_FOUND", 61 | message: "User not found!", 62 | }); 63 | return user; 64 | }), 65 | 66 | update: adminProcedure 67 | .input( 68 | z.object({ 69 | id: z.string(), 70 | name: z.string().min(3).max(50), 71 | email: z.string().email(), 72 | phone: z.string().min(10).max(11), 73 | }) 74 | ) 75 | .mutation(async ({ ctx, input }) => { 76 | const user = await ctx.prisma.user.update({ 77 | where: { 78 | id: input.id, 79 | }, 80 | data: { 81 | name: input.name, 82 | email: input.email, 83 | phone: input.phone, 84 | }, 85 | }); 86 | return user; 87 | }), 88 | 89 | updateRole: adminProcedure 90 | .input( 91 | z.object({ 92 | id: z.string(), 93 | role: z.nativeEnum(USER_ROLE), 94 | }) 95 | ) 96 | .mutation(async ({ ctx, input }) => { 97 | const currentUser = await ctx.prisma.user.findUnique({ 98 | where: { 99 | id: ctx.session.user.id, 100 | }, 101 | }); 102 | if (!currentUser) 103 | throw new TRPCError({ 104 | code: "NOT_FOUND", 105 | message: "User not found!", 106 | }); 107 | if (currentUser.role !== USER_ROLE.ADMIN) { 108 | throw new TRPCError({ 109 | code: "FORBIDDEN", 110 | message: "Only admins can change roles!", 111 | }); 112 | } 113 | const user = await ctx.prisma.user.update({ 114 | where: { 115 | id: input.id, 116 | }, 117 | data: { 118 | role: input.role, 119 | }, 120 | }); 121 | return user; 122 | }), 123 | 124 | updateStatus: adminProcedure 125 | .input( 126 | z.object({ 127 | id: z.string(), 128 | status: z.boolean(), 129 | }) 130 | ) 131 | .mutation(async ({ ctx, input }) => { 132 | const currentUser = await ctx.prisma.user.findUnique({ 133 | where: { 134 | id: ctx.session.user.id, 135 | }, 136 | }); 137 | if (!currentUser) 138 | throw new TRPCError({ 139 | code: "NOT_FOUND", 140 | message: "User not found!", 141 | }); 142 | if (currentUser.role !== USER_ROLE.ADMIN) { 143 | throw new TRPCError({ 144 | code: "FORBIDDEN", 145 | message: "Only admins can change status!", 146 | }); 147 | } 148 | const user = await ctx.prisma.user.update({ 149 | where: { 150 | id: input.id, 151 | }, 152 | data: { 153 | active: input.status, 154 | }, 155 | }); 156 | return user; 157 | }), 158 | 159 | delete: adminProcedure.input(z.string()).mutation(async ({ ctx, input }) => { 160 | const user = await ctx.prisma.user.delete({ 161 | where: { 162 | id: input, 163 | }, 164 | }); 165 | return user; 166 | }), 167 | 168 | prev: adminProcedure.input(z.string()).mutation(async ({ ctx, input }) => { 169 | const user = await ctx.prisma.user.findUnique({ 170 | where: { 171 | id: input, 172 | }, 173 | }); 174 | if (!user) 175 | throw new TRPCError({ 176 | code: "NOT_FOUND", 177 | message: "User not found!", 178 | }); 179 | const prevUser = await ctx.prisma.user.findFirst({ 180 | where: { 181 | id: { 182 | lt: user.id, 183 | }, 184 | }, 185 | orderBy: { 186 | id: "desc", 187 | }, 188 | }); 189 | if (!prevUser) { 190 | const lastUser = await ctx.prisma.user.findFirst({ 191 | orderBy: { 192 | id: "desc", 193 | }, 194 | }); 195 | return lastUser; 196 | } 197 | return prevUser; 198 | }), 199 | 200 | next: adminProcedure.input(z.string()).mutation(async ({ ctx, input }) => { 201 | const user = await ctx.prisma.user.findUnique({ 202 | where: { 203 | id: input, 204 | }, 205 | }); 206 | if (!user) 207 | throw new TRPCError({ 208 | code: "NOT_FOUND", 209 | message: "User not found!", 210 | }); 211 | const nextUser = await ctx.prisma.user.findFirst({ 212 | where: { 213 | id: { 214 | gt: user.id, 215 | }, 216 | }, 217 | orderBy: { 218 | id: "asc", 219 | }, 220 | }); 221 | if (!nextUser) { 222 | const firstUser = await ctx.prisma.user.findFirst({ 223 | orderBy: { 224 | id: "asc", 225 | }, 226 | }); 227 | return firstUser; 228 | } 229 | return nextUser; 230 | }), 231 | }); 232 | -------------------------------------------------------------------------------- /src/server/trpc/router/admin/products.ts: -------------------------------------------------------------------------------- 1 | import type { Prisma } from "@prisma/client"; 2 | import { PRODUCT_CATEGORY } from "@prisma/client"; 3 | import { TRPCError } from "@trpc/server"; 4 | import { z } from "zod"; 5 | import { adminProcedure, router } from "../../trpc"; 6 | 7 | export const productsAdminRouter = router({ 8 | get: adminProcedure 9 | .input( 10 | z.object({ 11 | page: z.number().int().default(0), 12 | perPage: z.number().int().default(10), 13 | name: z.string().optional(), 14 | price: z.number().optional(), 15 | category: z.nativeEnum(PRODUCT_CATEGORY).optional(), 16 | rating: z.number().min(0).max(5).optional(), 17 | sortBy: z 18 | .enum([ 19 | "name", 20 | "category", 21 | "quantity", 22 | "price", 23 | "rating", 24 | "createdAt", 25 | ]) 26 | .optional(), 27 | sortDesc: z.boolean().default(false), 28 | }) 29 | ) 30 | .query(async ({ ctx, input }) => { 31 | const needFilter = 32 | input.name || input.price || input.category || input.rating; 33 | 34 | const params: Prisma.ProductFindManyArgs = { 35 | orderBy: input.sortBy 36 | ? { [input.sortBy]: input.sortDesc ? "desc" : "asc" } 37 | : undefined, 38 | where: needFilter 39 | ? { 40 | AND: { 41 | name: input.name ? { contains: input.name } : undefined, 42 | price: input.price ? { equals: input.price } : undefined, 43 | category: input.category 44 | ? { equals: input.category } 45 | : undefined, 46 | rating: input.rating ? { equals: input.rating } : undefined, 47 | }, 48 | } 49 | : undefined, 50 | }; 51 | 52 | const [count, products] = await ctx.prisma.$transaction([ 53 | ctx.prisma.product.count({ where: params.where }), 54 | ctx.prisma.product.findMany({ 55 | ...params, 56 | skip: input.page * input.perPage, 57 | take: input.perPage, 58 | }), 59 | ]); 60 | return { count, products }; 61 | }), 62 | 63 | getOne: adminProcedure.input(z.string()).query(async ({ ctx, input }) => { 64 | const product = await ctx.prisma.product.findUnique({ 65 | where: { 66 | id: input, 67 | }, 68 | }); 69 | if (!product) 70 | throw new TRPCError({ 71 | code: "NOT_FOUND", 72 | message: "Product not found!", 73 | }); 74 | return product; 75 | }), 76 | 77 | create: adminProcedure 78 | .input( 79 | z.object({ 80 | name: z.string().min(3), 81 | price: z.number().min(0), 82 | category: z.nativeEnum(PRODUCT_CATEGORY), 83 | description: z.string().min(3), 84 | image: z.string(), 85 | rating: z.number().min(0).max(5), 86 | quantity: z.number().default(1), 87 | }) 88 | ) 89 | .mutation(async ({ ctx, input }) => { 90 | const uploadedPhoto = await ctx.cloudinary.uploader.upload(input.image, { 91 | resource_type: "image", 92 | format: "webp", 93 | folder: "amzn-store", 94 | transformation: [ 95 | { 96 | width: 224, 97 | height: 224, 98 | quality: 80, 99 | }, 100 | ], 101 | }); 102 | const product = await ctx.prisma.product.create({ 103 | data: { 104 | name: input.name, 105 | price: input.price, 106 | category: input.category, 107 | description: input.description, 108 | image: uploadedPhoto.secure_url, 109 | rating: input.rating, 110 | quantity: input.quantity, 111 | }, 112 | }); 113 | return product; 114 | }), 115 | 116 | update: adminProcedure 117 | .input( 118 | z.object({ 119 | id: z.string(), 120 | name: z.string().min(3), 121 | price: z.number().min(0), 122 | category: z.nativeEnum(PRODUCT_CATEGORY), 123 | description: z.string().min(3), 124 | image: z.string(), 125 | rating: z.number().min(0).max(5), 126 | }) 127 | ) 128 | .mutation(async ({ ctx, input }) => { 129 | const uploadedPhoto = await ctx.cloudinary.uploader.upload(input.image, { 130 | resource_type: "image", 131 | format: "webp", 132 | folder: "amzn-store", 133 | transformation: [ 134 | { 135 | width: 224, 136 | height: 224, 137 | quality: 80, 138 | }, 139 | ], 140 | }); 141 | const product = await ctx.prisma.product.update({ 142 | where: { 143 | id: input.id, 144 | }, 145 | data: { 146 | name: input.name, 147 | price: input.price, 148 | category: input.category, 149 | description: input.description, 150 | image: uploadedPhoto.secure_url, 151 | rating: input.rating, 152 | }, 153 | }); 154 | return product; 155 | }), 156 | 157 | delete: adminProcedure.input(z.string()).mutation(async ({ ctx, input }) => { 158 | const product = await ctx.prisma.product.delete({ 159 | where: { 160 | id: input, 161 | }, 162 | }); 163 | return product; 164 | }), 165 | 166 | prev: adminProcedure.input(z.string()).mutation(async ({ ctx, input }) => { 167 | const product = await ctx.prisma.product.findUnique({ 168 | where: { 169 | id: input, 170 | }, 171 | }); 172 | if (!product) 173 | throw new TRPCError({ 174 | code: "NOT_FOUND", 175 | message: "Product not found!", 176 | }); 177 | const prevProduct = await ctx.prisma.product.findFirst({ 178 | where: { 179 | id: { 180 | lt: product.id, 181 | }, 182 | }, 183 | orderBy: { 184 | id: "desc", 185 | }, 186 | }); 187 | if (!prevProduct) { 188 | const lastProduct = await ctx.prisma.product.findFirst({ 189 | orderBy: { 190 | id: "desc", 191 | }, 192 | }); 193 | return lastProduct; 194 | } 195 | return prevProduct; 196 | }), 197 | 198 | next: adminProcedure.input(z.string()).mutation(async ({ ctx, input }) => { 199 | const product = await ctx.prisma.product.findUnique({ 200 | where: { 201 | id: input, 202 | }, 203 | }); 204 | if (!product) 205 | throw new TRPCError({ 206 | code: "NOT_FOUND", 207 | message: "Product not found!", 208 | }); 209 | const nextProduct = await ctx.prisma.product.findFirst({ 210 | where: { 211 | id: { 212 | gt: product.id, 213 | }, 214 | }, 215 | orderBy: { 216 | id: "asc", 217 | }, 218 | }); 219 | if (!nextProduct) { 220 | const firstProduct = await ctx.prisma.product.findFirst({ 221 | orderBy: { 222 | id: "asc", 223 | }, 224 | }); 225 | return firstProduct; 226 | } 227 | return nextProduct; 228 | }), 229 | }); 230 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "mysql" 7 | url = env("DATABASE_URL") 8 | relationMode = "prisma" 9 | } 10 | 11 | // Necessary for Next auth 12 | model Account { 13 | id String @id @default(cuid()) 14 | userId String 15 | type String 16 | provider String 17 | providerAccountId String 18 | refresh_token String? @db.Text 19 | access_token String? @db.Text 20 | expires_at Int? 21 | token_type String? 22 | scope String? 23 | id_token String? @db.Text 24 | session_state String? 25 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 26 | 27 | @@unique([provider, providerAccountId]) 28 | @@index([userId]) 29 | } 30 | 31 | model Session { 32 | id String @id @default(cuid()) 33 | sessionToken String @unique 34 | userId String 35 | expires DateTime 36 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 37 | 38 | @@index([userId]) 39 | } 40 | 41 | model User { 42 | id String @id @default(cuid()) 43 | name String? 44 | email String? @unique 45 | emailVerified DateTime? 46 | image String? 47 | role USER_ROLE @default(USER) 48 | active Boolean @default(true) 49 | phone String? 50 | accounts Account[] 51 | sessions Session[] 52 | createdAt DateTime @default(now()) 53 | updatedAt DateTime @updatedAt 54 | orders Order[] 55 | stripeCustomerId String? 56 | stripeSubscriptionId String? 57 | stripeSubscriptionStatus STRIPE_SUBSCRIPTION_STATUS? @default(incomplete) 58 | stripeCustomer StripeCustomer? @relation(fields: [stripeCustomerId], references: [id], onDelete: Cascade) 59 | stripeSubscription StripeSubscription? @relation(fields: [stripeSubscriptionId], references: [id], onDelete: Cascade) 60 | 61 | @@index([id]) 62 | @@index([stripeCustomerId]) 63 | @@index([stripeSubscriptionId]) 64 | } 65 | 66 | model VerificationToken { 67 | identifier String 68 | token String @unique 69 | expires DateTime 70 | 71 | @@unique([identifier, token]) 72 | } 73 | 74 | model Product { 75 | id String @id @default(cuid()) 76 | name String 77 | price Float 78 | category PRODUCT_CATEGORY 79 | description String @db.Text 80 | image String 81 | rating Float @default(0) 82 | quantity Int @default(1) 83 | createdAt DateTime @default(now()) 84 | updatedAt DateTime @updatedAt 85 | items OrderItem[] 86 | } 87 | 88 | model Order { 89 | id String @id @default(cuid()) 90 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 91 | userId String 92 | createdAt DateTime @default(now()) 93 | updatedAt DateTime @updatedAt 94 | items OrderItem[] 95 | archived Boolean @default(false) 96 | status ORDER_STATUS @default(PENDING) 97 | 98 | @@index([userId]) 99 | } 100 | 101 | model OrderItem { 102 | id String @id @default(cuid()) 103 | order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) 104 | orderId String 105 | product Product @relation(fields: [productId], references: [id], onDelete: Cascade) 106 | productId String 107 | quantity Int 108 | createdAt DateTime @default(now()) 109 | updatedAt DateTime @updatedAt 110 | archived Boolean @default(false) 111 | status ORDER_STATUS @default(PENDING) 112 | 113 | @@index([orderId]) 114 | @@index([productId]) 115 | } 116 | 117 | model StripeEvent { 118 | id String @id @default(cuid()) 119 | api_version String? 120 | data Json 121 | request Json? 122 | type String 123 | object String 124 | acount String? 125 | livemode Boolean 126 | pending_webhooks Int 127 | createdAt DateTime @default(now()) 128 | updatedAt DateTime @updatedAt 129 | } 130 | 131 | model StripeCustomer { 132 | id String @id @default(cuid()) 133 | object String 134 | address Json? 135 | balance Int 136 | created DateTime 137 | currency String 138 | default_source String? 139 | delinquent Boolean 140 | description String? 141 | discount Json? 142 | email String? 143 | invoice_prefix String 144 | invoice_settings Json? 145 | livemode Boolean 146 | metadata Json? 147 | name String? 148 | next_invoice_sequence Int 149 | phone String? 150 | preferred_locales Json? 151 | shipping Json? 152 | tax_exempt String 153 | test_clock Json? 154 | createdAt DateTime @default(now()) 155 | updatedAt DateTime @updatedAt 156 | users User[] 157 | } 158 | 159 | model StripeSubscription { 160 | id String @id @default(cuid()) 161 | object String 162 | application String? 163 | application_fee_percent Int? 164 | automatic_tax Json? 165 | billing_cycle_anchor Int 166 | billing_thresholds Json? 167 | cancel_at DateTime? 168 | cancel_at_period_end Boolean 169 | canceled_at DateTime? 170 | collection_method String 171 | created DateTime 172 | currency String 173 | current_period_end Int 174 | current_period_start Int 175 | customer String 176 | days_until_due Int? 177 | default_payment_method String? 178 | default_source String? 179 | default_tax_rates Json? 180 | description String? 181 | discount Json? 182 | ended_at DateTime? 183 | items Json 184 | latest_invoice String? 185 | livemode Boolean 186 | metadata Json 187 | next_pending_invoice_item_invoice String? 188 | on_behalf_of String? 189 | pause_collection Json? 190 | payment_settings Json 191 | pending_invoice_item_interval Json? 192 | pending_setup_intent String? 193 | pending_update Json? 194 | schedule String? 195 | start_date Int 196 | status String 197 | test_clock Json? 198 | transfer_data Json? 199 | trial_end DateTime? 200 | trial_start DateTime? 201 | createdAt DateTime @default(now()) 202 | updatedAt DateTime @updatedAt 203 | users User[] 204 | } 205 | 206 | enum USER_ROLE { 207 | ADMIN 208 | USER 209 | } 210 | 211 | enum PRODUCT_CATEGORY { 212 | ELECTRONICS 213 | JWELLERY 214 | MENS_CLOTHING 215 | WOMENS_CLOTHING 216 | } 217 | 218 | enum ORDER_STATUS { 219 | PENDING 220 | PROCESSING 221 | SHIPPED 222 | DELIVERED 223 | CANCELED 224 | } 225 | 226 | enum STRIPE_SUBSCRIPTION_STATUS { 227 | incomplete 228 | incomplete_expired 229 | trialing 230 | active 231 | past_due 232 | canceled 233 | unpaid 234 | } 235 | -------------------------------------------------------------------------------- /src/components/layouts/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { useCartStore } from "@/stores/cart"; 2 | import { trpc } from "@/utils/trpc"; 3 | import { Menu, Transition } from "@headlessui/react"; 4 | import type { Product } from "@prisma/client"; 5 | import { signIn, signOut } from "next-auth/react"; 6 | import Image from "next/image"; 7 | import Link from "next/link"; 8 | import Router from "next/router"; 9 | import { Fragment } from "react"; 10 | 11 | // external imports 12 | import { ChevronDownIcon } from "@heroicons/react/20/solid"; 13 | import { ShoppingCartIcon } from "@heroicons/react/24/outline"; 14 | import Searchbar from "../Searchbar"; 15 | 16 | const bottomLinks = [ 17 | { 18 | name: "Today's Deals", 19 | href: "##", 20 | }, 21 | { 22 | name: "Best Sellers", 23 | href: "##", 24 | }, 25 | { 26 | name: "New Releases", 27 | href: "##", 28 | }, 29 | { 30 | name: "Categories", 31 | href: "/app/categories", 32 | }, 33 | { 34 | name: "Products", 35 | href: "/app/products", 36 | }, 37 | { 38 | name: "Gift Cards", 39 | href: "##", 40 | }, 41 | { 42 | name: "Registry", 43 | href: "##", 44 | }, 45 | { 46 | name: "Customer Service", 47 | href: "##", 48 | }, 49 | { 50 | name: "Browsing History", 51 | href: "##", 52 | }, 53 | { 54 | name: "Sell", 55 | href: "##", 56 | }, 57 | ]; 58 | 59 | const Navbar = ({ data: products }: { data: Product[] }) => { 60 | // cart store 61 | const cartStore = useCartStore((state) => ({ 62 | products: state.products, 63 | })); 64 | 65 | const totalQuantity = cartStore.products.reduce( 66 | (acc, product) => acc + product.quantity, 67 | 0 68 | ); 69 | 70 | return ( 71 | 72 | 73 | 74 | 75 | 83 | 84 | 89 | 90 | 91 | 92 | 93 | Retruns 94 | & Orders 95 | 96 | 97 | 98 | 99 | 103 | 104 | {totalQuantity} 105 | 106 | Cart 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | {bottomLinks.map((link) => ( 116 | 121 | {link.name} 122 | 123 | ))} 124 | 125 | 126 | 127 | ); 128 | }; 129 | 130 | export default Navbar; 131 | 132 | // Dropdown 133 | const dropLinks = [ 134 | { 135 | name: "Account", 136 | href: "/app/account", 137 | }, 138 | { 139 | name: "Create a List", 140 | href: "##", 141 | }, 142 | { 143 | name: "Lists", 144 | href: "##", 145 | }, 146 | 147 | { 148 | name: "Watchlist", 149 | href: "##", 150 | }, 151 | ]; 152 | 153 | const Dropdown = () => { 154 | // get session mutation 155 | const sessionMutation = trpc.users.getSession.useQuery(); 156 | 157 | return ( 158 | 159 | 160 | 161 | 162 | Hello, 163 | {sessionMutation.isLoading 164 | ? "Loading..." 165 | : sessionMutation.data 166 | ? sessionMutation.data.user?.name 167 | : "sign in"} 168 | 169 | 170 | 171 | Accounts & Lists 172 | 173 | 177 | 178 | 179 | 180 | 189 | 190 | 191 | {sessionMutation.data?.user?.role === "ADMIN" ? ( 192 | 193 | Router.push("/dashboard")} 197 | > 198 | Dashboard 199 | 200 | 201 | ) : null} 202 | {dropLinks.map((link) => ( 203 | 204 | 208 | {link.name} 209 | 210 | 211 | ))} 212 | 213 | (sessionMutation.data ? signOut() : signIn())} 217 | > 218 | {sessionMutation.data ? "Sign out" : "Sign in"} 219 | 220 | 221 | 222 | 223 | 224 | 225 | ); 226 | }; 227 | -------------------------------------------------------------------------------- /src/pages/app/account/update.tsx: -------------------------------------------------------------------------------- 1 | import { getServerAuthSession } from "@/server/common/get-server-auth-session"; 2 | import { trpc } from "@/utils/trpc"; 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import type { GetServerSideProps } from "next"; 5 | import { signOut } from "next-auth/react"; 6 | import Head from "next/head"; 7 | import Router from "next/router"; 8 | import { useState } from "react"; 9 | import { useForm, type SubmitHandler } from "react-hook-form"; 10 | import { toast } from "react-hot-toast"; 11 | import { z } from "zod"; 12 | import type { NextPageWithLayout } from "../../_app"; 13 | 14 | // external imports 15 | import Button from "@/components/ui/Button"; 16 | import ConfirmationModal from "@/components/ConfirmationModal"; 17 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 18 | import ErrorScreen from "@/components/screens/ErrorScreen"; 19 | import LoadingScreen from "@/components/screens/LoadingScreen"; 20 | 21 | const schema = z.object({ 22 | name: z 23 | .string() 24 | .min(3, { message: "Name must be at least 3 charachters" }) 25 | .max(50, { message: "Name must be at most 50 charachters" }), 26 | email: z.string().email({ message: "Invalid email" }), 27 | phone: z 28 | .string() 29 | .min(10, { message: "Phone number must be at least 10 charachters" }) 30 | .max(11, { message: "Phone number must be at most 11 charachters" }), 31 | }); 32 | type Inputs = z.infer; 33 | 34 | const Update: NextPageWithLayout = () => { 35 | // get session query 36 | const sessionMutation = trpc.users.getSession.useQuery(); 37 | 38 | // update user mutation 39 | const updateUserMutation = trpc.users.update.useMutation({ 40 | onSuccess: async () => { 41 | sessionMutation.refetch(); 42 | toast.success("User updated!"); 43 | }, 44 | onError: async (e) => { 45 | toast.error(e.message); 46 | }, 47 | }); 48 | 49 | // delete user mutation 50 | const deleteUserMutation = trpc.users.delete.useMutation({ 51 | onSuccess: async () => { 52 | await Router.push("/app"); 53 | await signOut(); 54 | toast.success("User deleted!"); 55 | }, 56 | onError: async (err) => { 57 | toast.error(err.message); 58 | }, 59 | }); 60 | 61 | // react-hook-form 62 | const { 63 | register, 64 | handleSubmit, 65 | formState: { errors }, 66 | } = useForm({ resolver: zodResolver(schema) }); 67 | const onSubmit: SubmitHandler = async (data) => { 68 | await updateUserMutation.mutateAsync({ 69 | id: sessionMutation.data?.user?.id as string, 70 | ...data, 71 | }); 72 | }; 73 | 74 | // headless-ui modal 75 | const [isOpen, setIsOpen] = useState(false); 76 | 77 | if (sessionMutation.isLoading) { 78 | return ; 79 | } 80 | 81 | if (sessionMutation.isError) { 82 | return ; 83 | } 84 | 85 | return ( 86 | <> 87 | 88 | Change Name, E-mail, and Delete Account | Amzn Store 89 | 90 | 91 | { 99 | await deleteUserMutation.mutateAsync( 100 | sessionMutation.data?.user?.id as string 101 | ); 102 | setIsOpen(false); 103 | }} 104 | isLoading={deleteUserMutation.isLoading} 105 | /> 106 | 107 | 112 | 113 | 117 | Name 118 | 119 | 131 | {errors.name ? ( 132 | 133 | {errors.name.message} 134 | 135 | ) : null} 136 | 137 | 138 | 142 | Email 143 | 144 | 156 | {errors.email ? ( 157 | 158 | {errors.email.message} 159 | 160 | ) : null} 161 | 162 | 163 | 167 | Phone number 168 | 169 | 181 | {errors.phone ? ( 182 | 183 | {errors.phone.message} 184 | 185 | ) : null} 186 | 187 | 192 | {updateUserMutation.isLoading ? "Loading..." : "Update account"} 193 | 194 | 195 | setIsOpen(true)}> 196 | Delete account 197 | 198 | 199 | 200 | > 201 | ); 202 | }; 203 | 204 | export default Update; 205 | 206 | Update.getLayout = (page) => {page}; 207 | 208 | export const getServerSideProps: GetServerSideProps = async (ctx) => { 209 | const session = await getServerAuthSession({ 210 | req: ctx.req, 211 | res: ctx.res, 212 | }); 213 | 214 | if (!session) { 215 | return { 216 | redirect: { 217 | destination: "/api/auth/signin", 218 | permanent: false, 219 | }, 220 | }; 221 | } 222 | 223 | return { 224 | props: { 225 | session, 226 | }, 227 | }; 228 | }; 229 | -------------------------------------------------------------------------------- /src/pages/dashboard/products/add.tsx: -------------------------------------------------------------------------------- 1 | import { formatEnum } from "@/utils/format"; 2 | import { trpc } from "@/utils/trpc"; 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { PRODUCT_CATEGORY } from "@prisma/client"; 5 | import { useIsMutating } from "@tanstack/react-query"; 6 | import Head from "next/head"; 7 | import { useEffect, useState } from "react"; 8 | import { useForm, type SubmitHandler } from "react-hook-form"; 9 | import { toast } from "react-hot-toast"; 10 | import { z } from "zod"; 11 | import type { NextPageWithLayout } from "../../_app"; 12 | 13 | // external imports 14 | import Button from "@/components/ui/Button"; 15 | import CustomDropzone from "@/components/ui/FileInput"; 16 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 17 | 18 | const schema = z.object({ 19 | name: z.string().min(3), 20 | price: z.number().min(0), 21 | category: z.nativeEnum(PRODUCT_CATEGORY), 22 | description: z.string().min(3), 23 | image: z.unknown().refine((v) => v instanceof File, { 24 | message: "Expected File, received unknown", 25 | }), 26 | rating: z.number().min(0).max(5), 27 | }); 28 | type Inputs = z.infer; 29 | 30 | const AddProduct: NextPageWithLayout = () => { 31 | const [preview, setPreview] = useState(); 32 | 33 | // add product mutation 34 | const addProductMutation = trpc.admin.products.create.useMutation({ 35 | onSuccess: async () => { 36 | toast.success("Product added!"); 37 | reset(); 38 | setPreview(undefined); 39 | }, 40 | onError: async (err) => { 41 | toast.error(err.message); 42 | }, 43 | }); 44 | 45 | // react-hook-form 46 | const { 47 | register, 48 | handleSubmit, 49 | formState: { errors }, 50 | setValue, 51 | reset, 52 | } = useForm({ resolver: zodResolver(schema) }); 53 | const onSubmit: SubmitHandler = async (data) => { 54 | const reader = new FileReader(); 55 | reader.readAsDataURL(data.image as File); 56 | reader.onload = async () => { 57 | const base64 = reader.result; 58 | await addProductMutation.mutateAsync({ 59 | ...data, 60 | image: base64 as string, 61 | }); 62 | }; 63 | }; 64 | 65 | // refetch products query 66 | const uitls = trpc.useContext(); 67 | const number = useIsMutating(); 68 | useEffect(() => { 69 | if (number === 0) { 70 | uitls.products.get.invalidate(); 71 | } 72 | }, [number, uitls]); 73 | 74 | return ( 75 | <> 76 | 77 | Add Product | Amzn Store 78 | 79 | 80 | 81 | 86 | 87 | 91 | Product name 92 | 93 | 100 | {errors.name ? ( 101 | 102 | {errors.name.message} 103 | 104 | ) : null} 105 | 106 | 107 | 111 | Product price 112 | 113 | 124 | {errors.price ? ( 125 | 126 | {errors.price.message} 127 | 128 | ) : null} 129 | 130 | 131 | 135 | Product category 136 | 137 | 142 | 143 | Select category 144 | 145 | {Object.values(PRODUCT_CATEGORY).map((category) => ( 146 | 147 | {formatEnum(category)} 148 | 149 | ))} 150 | 151 | {errors.category ? ( 152 | 153 | {errors.category.message} 154 | 155 | ) : null} 156 | 157 | 158 | 162 | Product description 163 | 164 | 172 | {errors.description ? ( 173 | 174 | {errors.description.message} 175 | 176 | ) : null} 177 | 178 | 179 | 183 | Product image 184 | 185 | 186 | id="add-product-image" 187 | name="image" 188 | setValue={setValue} 189 | preview={preview} 190 | setPreview={setPreview} 191 | /> 192 | {errors.image ? ( 193 | 194 | {errors.image.message} 195 | 196 | ) : null} 197 | 198 | 199 | 203 | Product ratings 204 | 205 | 213 | {errors.rating ? ( 214 | 215 | {errors.rating.message} 216 | 217 | ) : null} 218 | 219 | 224 | {addProductMutation.isLoading ? "Loading..." : "Add product"} 225 | 226 | 227 | 228 | 229 | > 230 | ); 231 | }; 232 | 233 | export default AddProduct; 234 | 235 | AddProduct.getLayout = (page) => {page}; 236 | -------------------------------------------------------------------------------- /src/pages/app/orders/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPageWithLayout } from "@/pages/_app"; 2 | import type { OrderItemWithProduct, OrderWithItems } from "@/types/globals"; 3 | import { formatCurrency, formatEnum } from "@/utils/format"; 4 | import { trpc } from "@/utils/trpc"; 5 | import { Tab } from "@headlessui/react"; 6 | import { useIsMutating } from "@tanstack/react-query"; 7 | import dayjs from "dayjs"; 8 | import { useSession } from "next-auth/react"; 9 | import Head from "next/head"; 10 | import Image from "next/image"; 11 | import Link from "next/link"; 12 | import Router from "next/router"; 13 | import { useEffect, useState } from "react"; 14 | import { toast } from "react-hot-toast"; 15 | 16 | // external imports 17 | import Button from "@/components/ui/Button"; 18 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 19 | import ErrorScreen from "@/components/screens/ErrorScreen"; 20 | import LoadingScreen from "@/components/screens/LoadingScreen"; 21 | 22 | const Orders: NextPageWithLayout = () => { 23 | // redirect to signin page if unauthenticated 24 | const { status } = useSession(); 25 | useEffect(() => { 26 | if (status === "unauthenticated") { 27 | Router.push("/api/auth/signin"); 28 | } 29 | }, [status]); 30 | 31 | // get queries 32 | const utils = trpc.useContext(); 33 | const ordersQuery = trpc.orders.get.useQuery(); 34 | const archivedOrdersQuery = trpc.orders.getArchived.useQuery(); 35 | 36 | // refetch queries 37 | const number = useIsMutating(); 38 | useEffect(() => { 39 | if (number === 0) { 40 | utils.orders.get.invalidate(); 41 | utils.orders.getArchived.invalidate(); 42 | } 43 | }, [number, utils]); 44 | 45 | // headlessui tab 46 | const [selectedIndex, setSelectedIndex] = useState(0); 47 | const tabs = [{ name: "Orders" }, { name: "Archived orders" }]; 48 | 49 | // set tab index based on query 50 | useEffect(() => { 51 | if (Router.query.tab === "archived") { 52 | setSelectedIndex(1); 53 | } 54 | }, []); 55 | 56 | if (ordersQuery.isLoading || archivedOrdersQuery.isLoading) { 57 | return ; 58 | } 59 | 60 | if (ordersQuery.isError) { 61 | return ; 62 | } 63 | 64 | if (archivedOrdersQuery.isError) { 65 | return ; 66 | } 67 | 68 | if ( 69 | ordersQuery.data?.length === 0 && 70 | archivedOrdersQuery.data?.length === 0 71 | ) { 72 | return ( 73 | 74 | 75 | You have no orders 76 | 77 | 78 | ); 79 | } 80 | 81 | return ( 82 | <> 83 | 84 | Orders | Amzn Store 85 | 86 | 87 | 88 | 89 | Your Orders 90 | 91 | 92 | 96 | 97 | {tabs.map((tab) => ( 98 | 102 | {tab.name} 103 | 104 | ))} 105 | 106 | 107 | 108 | {ordersQuery.data?.every( 109 | (order) => order.items.length === 0 110 | ) ? ( 111 | 112 | 113 | You have no unarchived orders 114 | 115 | 116 | ) : ( 117 | 118 | )} 119 | 120 | 121 | {archivedOrdersQuery.data?.every( 122 | (order) => order.items.length === 0 123 | ) ? ( 124 | 125 | 126 | You have no archived orders 127 | 128 | 129 | ) : ( 130 | 131 | )} 132 | 133 | 134 | 135 | 136 | 137 | 138 | > 139 | ); 140 | }; 141 | 142 | export default Orders; 143 | 144 | Orders.getLayout = (page) => {page}; 145 | 146 | // GroupedOrders 147 | const GroupedOrders = ({ data }: { data: OrderWithItems[] }) => { 148 | return ( 149 | 150 | {data 151 | .filter((order) => order.items.length > 0) 152 | .map((order) => ( 153 | 154 | 155 | 156 | 157 | Order Placed: 158 | 159 | {dayjs(order.createdAt).format("DD MMM YYYY")}, 160 | 161 | 162 | 163 | Total: 164 | 165 | {order.items.reduce((acc, item) => acc + item.quantity, 0)} 166 | 167 | 168 | 169 | 170 | 171 | Go to order 172 | 173 | 174 | 175 | 176 | {order.items.map((item) => ( 177 | 178 | ))} 179 | 180 | 181 | ))} 182 | 183 | ); 184 | }; 185 | 186 | // Item 187 | const Item = ({ item }: { item: OrderItemWithProduct }) => { 188 | // update item mutation 189 | const updateItemMutation = trpc.orders.updateItem.useMutation({ 190 | onSuccess: async () => { 191 | toast.success(item.archived ? "Item unarchived!" : "Item archived!"); 192 | }, 193 | onError: async (err) => { 194 | toast.error(err.message); 195 | }, 196 | }); 197 | 198 | return ( 199 | 200 | 201 | 209 | 210 | 211 | {item.product.name} 212 | 213 | 214 | {formatEnum(item.product.category)} 215 | 216 | 217 | 218 | {`${item.quantity} x ${formatCurrency( 219 | item.product.price, 220 | "USD" 221 | )} = ${formatCurrency( 222 | item.product.price * item.quantity, 223 | "USD" 224 | )}`} 225 | 226 | 227 | 228 | 229 | 230 | 231 | 235 | Go to prduct 236 | 237 | 238 | { 242 | updateItemMutation.mutateAsync({ 243 | id: item.id, 244 | archived: item.archived, 245 | }); 246 | }} 247 | disabled={updateItemMutation.isLoading} 248 | > 249 | {updateItemMutation.isLoading 250 | ? item.archived 251 | ? "Unarchiving..." 252 | : "Archiving..." 253 | : item.archived 254 | ? "Unarchive" 255 | : "Archive"} 256 | 257 | 258 | 259 | ); 260 | }; 261 | -------------------------------------------------------------------------------- /src/components/Cart.tsx: -------------------------------------------------------------------------------- 1 | import { useCartStore } from "@/stores/cart"; 2 | import { formatCurrency, formatEnum } from "@/utils/format"; 3 | import { trpc } from "@/utils/trpc"; 4 | import { STRIPE_SUBSCRIPTION_STATUS, type Product } from "@prisma/client"; 5 | import { signIn, useSession } from "next-auth/react"; 6 | import Image from "next/image"; 7 | import Link from "next/link"; 8 | import { useState } from "react"; 9 | import { toast } from "react-hot-toast"; 10 | 11 | // external imports 12 | import Button from "./ui/Button"; 13 | 14 | const Cart = ({ products }: { products: Product[] }) => { 15 | const { status } = useSession(); 16 | 17 | const totalPrice = products.reduce( 18 | (acc, product) => acc + product.price * product.quantity, 19 | 0 20 | ); 21 | const totalQuantity = products.reduce( 22 | (acc, product) => acc + product.quantity, 23 | 0 24 | ); 25 | 26 | // cart store 27 | const cartStore = useCartStore((state) => ({ 28 | removeProducts: state.removeProducts, 29 | })); 30 | 31 | // add order mutation 32 | const addOrderMutation = trpc.orders.create.useMutation({ 33 | onSuccess: async () => { 34 | cartStore.removeProducts(products.map((product) => product.id)); 35 | toast.success("Products added to your order!"); 36 | }, 37 | onError: async (err) => { 38 | toast.error(err.message); 39 | }, 40 | }); 41 | 42 | // subscription status query 43 | const subscriptionStatusQuery = trpc.users.getSubscriptionStatus.useQuery( 44 | undefined, 45 | { 46 | enabled: status === "authenticated", 47 | } 48 | ); 49 | 50 | return ( 51 | 52 | {products.length <= 0 ? ( 53 | 54 | 55 | Your Amzn Cart is empty. 56 | 57 | 58 | Your Shopping Cart lives to serve. Give it purpose — fill it with 59 | groceries, clothing, household supplies, electronics, and more. 60 | Continue shopping on the{" "} 61 | 62 | Amzn-web.com homepage 63 | 64 | , learn about {`today's`} 65 | deals, or visit your Wish List. 66 | 67 | 68 | ) : ( 69 | 70 | 71 | 72 | 73 | 74 | Shopping Cart 75 | 76 | { 79 | cartStore.removeProducts( 80 | products.map((product) => product.id) 81 | ); 82 | toast.success("Cart cleared!"); 83 | }} 84 | > 85 | Remove all products 86 | 87 | 88 | 89 | Price 90 | 91 | 92 | 93 | {products.map((product) => ( 94 | 95 | ))} 96 | 97 | 98 | Subtotal ({totalQuantity} {totalQuantity > 1 ? "items" : "item"}) 99 | :{" "} 100 | 101 | {formatCurrency(totalPrice, "USD")} 102 | 103 | 104 | 105 | 106 | 107 | Subtotal ({totalQuantity} {totalQuantity > 1 ? "items" : "item"}) 108 | :{" "} 109 | 110 | {formatCurrency(totalPrice, "USD")} 111 | 112 | 113 | { 117 | status === "unauthenticated" 118 | ? signIn() 119 | : addOrderMutation.mutateAsync( 120 | products.map((product) => ({ 121 | productId: product.id, 122 | productQuantity: product.quantity, 123 | })) 124 | ); 125 | }} 126 | disabled={addOrderMutation.isLoading} 127 | > 128 | {addOrderMutation.isLoading ? ( 129 | 130 | 138 | 142 | 146 | 147 | Loading... 148 | 149 | ) : ( 150 | "Proceed to checkout" 151 | )} 152 | 153 | 162 | {subscriptionStatusQuery.data === 163 | STRIPE_SUBSCRIPTION_STATUS.active 164 | ? "Manage prime" 165 | : "Get prime"} 166 | 167 | 168 | 169 | )} 170 | 171 | ); 172 | }; 173 | 174 | export default Cart; 175 | 176 | const ProductCard = ({ product }: { product: Product }) => { 177 | const [selectedQuantity, setSelectedQuantity] = useState(product.quantity); 178 | 179 | // zustand 180 | const cartStore = useCartStore((state) => ({ 181 | removeProduct: state.removeProduct, 182 | setQuantity: state.setQuantity, 183 | })); 184 | 185 | return ( 186 | 187 | 188 | 196 | 197 | 198 | {product.name} 199 | 200 | 201 | {product.price ? formatCurrency(product.price, "USD") : "-"} 202 | 203 | 204 | {formatEnum(product.category)} 205 | 206 | 207 | { 213 | setSelectedQuantity(Number(e.target.value)); 214 | cartStore.setQuantity(product.id, Number(e.target.value)); 215 | }} 216 | > 217 | {Array.from({ length: 10 }, (_, i) => i + 1).map((quantity) => ( 218 | 219 | {quantity} 220 | 221 | ))} 222 | 223 | cartStore.removeProduct(product.id)} 227 | > 228 | Delete 229 | 230 | 231 | 232 | 233 | 234 | {product.price ? formatCurrency(product.price, "USD") : "-"} 235 | 236 | 237 | ); 238 | }; 239 | -------------------------------------------------------------------------------- /src/pages/dashboard/users/[userId].tsx: -------------------------------------------------------------------------------- 1 | import { formatEnum } from "@/utils/format"; 2 | import { trpc } from "@/utils/trpc"; 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { USER_ROLE } from "@prisma/client"; 5 | import { useIsMutating } from "@tanstack/react-query"; 6 | import Head from "next/head"; 7 | import Router from "next/router"; 8 | import { useEffect } from "react"; 9 | import { useForm, type SubmitHandler } from "react-hook-form"; 10 | import { toast } from "react-hot-toast"; 11 | import { z } from "zod"; 12 | import type { NextPageWithLayout } from "../../_app"; 13 | 14 | // external imports 15 | import Button from "@/components/ui/Button"; 16 | import DefaultLayout from "@/components/layouts/DefaultLayout"; 17 | import ErrorScreen from "@/components/screens/ErrorScreen"; 18 | import LoadingScreen from "@/components/screens/LoadingScreen"; 19 | import { 20 | ArrowLeftCircleIcon, 21 | ArrowRightCircleIcon, 22 | } from "@heroicons/react/20/solid"; 23 | 24 | const schema = z.object({ 25 | name: z.string().min(3).max(50), 26 | email: z.string().email(), 27 | phone: z.string().min(10).max(11), 28 | }); 29 | type Inputs = z.infer; 30 | 31 | const UpdateUser: NextPageWithLayout = () => { 32 | const userId = Router.query.userId as string; 33 | 34 | // get user query 35 | const userQuery = trpc.admin.users.getOne.useQuery(userId, { 36 | enabled: Boolean(userId), 37 | }); 38 | 39 | // update user mutation 40 | const updateUserMutation = trpc.admin.users.update.useMutation({ 41 | onSuccess: async () => { 42 | toast.success("User updated!"); 43 | }, 44 | onError: async (err) => { 45 | toast.error(err.message); 46 | }, 47 | }); 48 | 49 | // update role mutation 50 | const updateRoleMutation = trpc.admin.users.updateRole.useMutation({ 51 | onSuccess: async () => { 52 | toast.success("User role update!"); 53 | }, 54 | onError: async (err) => { 55 | toast.error(err.message); 56 | }, 57 | }); 58 | 59 | // update status mutation 60 | const updateStatusMutation = trpc.admin.users.updateStatus.useMutation({ 61 | onSuccess: async () => { 62 | toast.success("User status updated!"); 63 | }, 64 | onError: async (err) => { 65 | toast.error(err.message); 66 | }, 67 | }); 68 | 69 | // delete user mutation 70 | const deleteUserMutation = trpc.admin.users.delete.useMutation({ 71 | onSuccess: async () => { 72 | toast.success("User deleted!"); 73 | Router.push("/dashboard/users"); 74 | }, 75 | onError: async (err) => { 76 | toast.error(err.message); 77 | }, 78 | }); 79 | 80 | // react-hook-form 81 | const { 82 | register, 83 | handleSubmit, 84 | formState: { errors }, 85 | reset, 86 | } = useForm({ resolver: zodResolver(schema) }); 87 | const onSubmit: SubmitHandler = async (data) => { 88 | await updateUserMutation.mutateAsync({ id: userId, ...data }); 89 | }; 90 | 91 | // prev user mutation 92 | const prevUserMutation = trpc.admin.users.prev.useMutation({ 93 | onSuccess: async (data) => { 94 | if (!data) { 95 | return toast.error("No previous user!"); 96 | } 97 | await Router.push(`/dashboard/users/${data.id}`); 98 | reset(); 99 | }, 100 | onError: async (err) => { 101 | toast.error(err.message); 102 | }, 103 | }); 104 | 105 | // next user mutation 106 | const nextUserMutation = trpc.admin.users.next.useMutation({ 107 | onSuccess: async (data) => { 108 | if (!data) { 109 | return toast.error("No next user!"); 110 | } 111 | await Router.push(`/dashboard/users/${data.id}`); 112 | reset(); 113 | }, 114 | onError: async (err) => { 115 | toast.error(err.message); 116 | }, 117 | }); 118 | 119 | // refetch user query 120 | const utils = trpc.useContext(); 121 | const number = useIsMutating(); 122 | useEffect(() => { 123 | if (number === 0) { 124 | utils.admin.users.getOne.invalidate(userId); 125 | } 126 | }, [number, userId, utils]); 127 | 128 | if (userQuery.isLoading) { 129 | return ; 130 | } 131 | 132 | if (userQuery.isError) { 133 | return ; 134 | } 135 | 136 | return ( 137 | <> 138 | 139 | Update User | Amzn Store 140 | 141 | 142 | 143 | 144 | Router.push("/dashboard/users")} 148 | > 149 | 153 | 154 | 155 | prevUserMutation.mutateAsync(userId)} 158 | > 159 | 163 | 164 | nextUserMutation.mutateAsync(userId)} 167 | > 168 | 172 | 173 | 174 | 175 | 176 | 181 | 182 | 186 | Name 187 | 188 | 195 | {errors.name ? ( 196 | 197 | {errors.name.message} 198 | 199 | ) : null} 200 | 201 | 202 | 206 | Email 207 | 208 | 215 | {errors.email ? ( 216 | 217 | {errors.email.message} 218 | 219 | ) : null} 220 | 221 | 222 | 226 | Phone 227 | 228 | 235 | {errors.phone ? ( 236 | 237 | {errors.phone.message} 238 | 239 | ) : null} 240 | 241 | 246 | {updateUserMutation.isLoading ? "Loading..." : "Update user"} 247 | 248 | 249 | 250 | 254 | Role 255 | 256 | { 260 | updateRoleMutation.mutateAsync({ 261 | id: userId, 262 | role: e.target.value as USER_ROLE, 263 | }); 264 | }} 265 | defaultValue={userQuery.data?.role} 266 | > 267 | 268 | Select role 269 | 270 | {Object.values(USER_ROLE).map((role) => ( 271 | 272 | {formatEnum(role)} 273 | 274 | ))} 275 | 276 | 277 | 281 | updateStatusMutation.mutateAsync({ 282 | id: userId, 283 | status: !userQuery.data?.active, 284 | }) 285 | } 286 | disabled={updateStatusMutation.isLoading} 287 | > 288 | {updateStatusMutation.isLoading 289 | ? "Loading..." 290 | : userQuery.data?.active 291 | ? "Deactivate user" 292 | : "Activate user"} 293 | 294 | deleteUserMutation.mutateAsync(userId)} 298 | disabled={deleteUserMutation.isLoading} 299 | > 300 | {deleteUserMutation.isLoading ? "Loading..." : "Delete user"} 301 | 302 | 303 | 304 | 305 | > 306 | ); 307 | }; 308 | 309 | export default UpdateUser; 310 | 311 | UpdateUser.getLayout = (page) => {page}; 312 | --------------------------------------------------------------------------------
Drop the files here ...
Drag {`'n'`} drop image here, or click to select image
57 | {productQuery.data.description} 58 |
60 | {formatCurrency(productQuery.data.price, "USD")} 61 |
{description}
90 | {product.description ?? "-"} 91 |
94 | {formatCurrency(product.price, "USD")} 95 |
88 | Thank you for being a Prime member 89 |
110 | Get unlimited free two-day shipping, ad-free music, exclusive 111 | deals, and more 112 |
133 | {errors.name.message} 134 |
158 | {errors.email.message} 159 |
183 | {errors.phone.message} 184 |
102 | {errors.name.message} 103 |
126 | {errors.price.message} 127 |
153 | {errors.category.message} 154 |
174 | {errors.description.message} 175 |
194 | {errors.image.message} 195 |
215 | {errors.rating.message} 216 |
58 | Your Shopping Cart lives to serve. Give it purpose — fill it with 59 | groceries, clothing, household supplies, electronics, and more. 60 | Continue shopping on the{" "} 61 | 62 | Amzn-web.com homepage 63 | 64 | , learn about {`today's`} 65 | deals, or visit your Wish List. 66 |
197 | {errors.name.message} 198 |
217 | {errors.email.message} 218 |
237 | {errors.phone.message} 238 |