Jack commited on
Commit
1067b6f
·
0 Parent(s):

Initial release

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env +10 -0
  2. .gitattributes +35 -0
  3. Dockerfile +64 -0
  4. LICENSE +21 -0
  5. README.md +9 -0
  6. README_info.md +28 -0
  7. app/(storefront)/(main)/cart/components/checkout-button.tsx +43 -0
  8. app/(storefront)/(main)/cart/layout.tsx +6 -0
  9. app/(storefront)/(main)/cart/loading.tsx +27 -0
  10. app/(storefront)/(main)/cart/page.tsx +99 -0
  11. app/(storefront)/(main)/layout.tsx +25 -0
  12. app/(storefront)/(main)/loading.tsx +28 -0
  13. app/(storefront)/(main)/page.tsx +166 -0
  14. app/(storefront)/(main)/product/[productId]/layout.tsx +10 -0
  15. app/(storefront)/(main)/product/[productId]/loading.tsx +20 -0
  16. app/(storefront)/(main)/product/[productId]/page.tsx +114 -0
  17. app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/layout.tsx +12 -0
  18. app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/loading.tsx +19 -0
  19. app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/page.tsx +91 -0
  20. app/(storefront)/(main)/products/@modal/default.ts +3 -0
  21. app/(storefront)/(main)/products/layout.tsx +13 -0
  22. app/(storefront)/(main)/products/loading.tsx +17 -0
  23. app/(storefront)/(main)/products/page.tsx +82 -0
  24. app/(storefront)/(main)/quickview/product/[productId]/page.tsx +8 -0
  25. app/(storefront)/checkout/[storeSlug]/error.tsx +26 -0
  26. app/(storefront)/checkout/[storeSlug]/order-confirmation/components/verification.tsx +45 -0
  27. app/(storefront)/checkout/[storeSlug]/order-confirmation/page.tsx +144 -0
  28. app/(storefront)/checkout/[storeSlug]/page.tsx +108 -0
  29. app/(storefront)/checkout/components/checkout-form.tsx +148 -0
  30. app/(storefront)/checkout/components/checkout-wrapper.tsx +142 -0
  31. app/(storefront)/checkout/components/order-summary-accordion.tsx +28 -0
  32. app/(storefront)/checkout/layout.tsx +16 -0
  33. app/(storefront)/components/feature-banner.tsx +15 -0
  34. app/account/buying/purchases/components/columns.tsx +77 -0
  35. app/account/buying/purchases/components/data-table.tsx +182 -0
  36. app/account/buying/purchases/page.tsx +55 -0
  37. app/account/layout.tsx +81 -0
  38. app/account/page.tsx +12 -0
  39. app/account/selling/(orders)/abandoned-carts/components/columns.tsx +45 -0
  40. app/account/selling/(orders)/abandoned-carts/components/data-table.tsx +166 -0
  41. app/account/selling/(orders)/abandoned-carts/page.tsx +45 -0
  42. app/account/selling/(orders)/layout.tsx +29 -0
  43. app/account/selling/(orders)/order/[orderId]/page.tsx +120 -0
  44. app/account/selling/(orders)/order/error.tsx +22 -0
  45. app/account/selling/(orders)/orders/components/columns.tsx +91 -0
  46. app/account/selling/(orders)/orders/components/data-table.tsx +180 -0
  47. app/account/selling/(orders)/orders/page.tsx +48 -0
  48. app/account/selling/layout.tsx +18 -0
  49. app/account/selling/payments/components/create-connected-account.tsx +46 -0
  50. app/account/selling/payments/loading.tsx +11 -0
.env ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # DATABASE_URL="mysql://app:app@db:3306/onestopshop"
2
+ DATABASE_URL="mysql://avnadmin:AVNS_Xh3_qhmmzjH035K034U@mysql-2387c7fc-jacksongcsdev-5b75.g.aivencloud.com:23824/defaultdb"
3
+
4
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_cmVuZXdpbmctZmx5LTYwLmNsZXJrLmFjY291bnRzLmRldiQ
5
+ CLERK_SECRET_KEY=sk_test_MGPXL0QrkTNMDWQNLVfv5xCBDhAySngBb5gzjAMAQR
6
+
7
+ NEXT_PUBLIC_APP_URL=http://localhost:3000/
8
+
9
+ UPLOADTHING_SECRET=""
10
+ UPLOADTHING_APP_ID=""
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---------- Base image ----------
2
+ FROM node:20-alpine AS base
3
+
4
+ # Needed for some native deps
5
+ RUN apk add --no-cache libc6-compat python3 make g++
6
+
7
+ WORKDIR /app
8
+
9
+ # ---------- Dependencies (prod) ----------
10
+ FROM base AS deps
11
+
12
+ WORKDIR /app
13
+
14
+ # Copy only package manifests
15
+ COPY package.json package-lock.json ./
16
+
17
+ # Install prod deps (no dev) – use npm install, not npm ci
18
+ RUN npm install --omit=dev --legacy-peer-deps --no-audit --no-fund
19
+
20
+ # ---------- Build (dev + tooling) ----------
21
+ FROM base AS builder
22
+
23
+ WORKDIR /app
24
+
25
+ # Copy manifests and install full deps (including dev)
26
+ COPY package.json package-lock.json ./
27
+ RUN npm install --legacy-peer-deps --no-audit --no-fund
28
+
29
+ # Copy the rest of the source
30
+ COPY . .
31
+
32
+ # Ensure migrations-folder exists so COPY in the next stage never fails
33
+ # Try to generate Drizzle migrations; if it fails, don't break the build
34
+ RUN mkdir -p migrations-folder && \
35
+ npx drizzle-kit generate || echo "Skipping drizzle-kit generate step"
36
+
37
+ # Build the Next.js app
38
+ RUN npm run build
39
+
40
+ # ---------- Runtime ----------
41
+ FROM base AS runner
42
+
43
+ WORKDIR /app
44
+ ENV NODE_ENV=production
45
+
46
+ # Copy runtime deps from deps stage
47
+ COPY --from=deps /app/node_modules ./node_modules
48
+
49
+ # Copy built app + configs
50
+ COPY --from=builder /app/package.json ./package.json
51
+ COPY --from=builder /app/.next ./.next
52
+ COPY --from=builder /app/public ./public
53
+ COPY --from=builder /app/styles ./styles
54
+ COPY --from=builder /app/next.config.js ./next.config.js
55
+ COPY --from=builder /app/tailwind.config.js ./tailwind.config.js
56
+ COPY --from=builder /app/postcss.config.js ./postcss.config.js
57
+ COPY --from=builder /app/drizzle.config.json ./drizzle.config.json
58
+ COPY --from=builder /app/migrations-folder ./migrations-folder
59
+
60
+ # ⭐ REQUIRED ⭐
61
+ COPY --from=builder /app/db ./db
62
+
63
+ EXPOSE 3000
64
+ CMD ["npm", "start"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2023 @jackblatch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: WebArena-Amazon
3
+ emoji: 🛒
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 3000
8
+ pinned: false
9
+ ---
README_info.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## OneStopShop Setup Guide
2
+
3
+ ### 1) Clone the repo
4
+ ```
5
+ git clone https://github.com/JackSong88/webarena-jbb-magento.git
6
+ cd webarena-jbb-magento
7
+ ```
8
+
9
+ ### 2) Create .env file
10
+ - Create a [Clerk](https://clerk.com) application for authentication and copy keys into `.env`
11
+ ```
12
+ DATABASE_URL="mysql://app:app@db:3306/onestopshop"
13
+
14
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxxxx
15
+ CLERK_SECRET_KEY=sk_test_xxxxx
16
+
17
+ NEXT_PUBLIC_APP_URL=http://localhost:3000/
18
+
19
+ UPLOADTHING_SECRET=""
20
+ UPLOADTHING_APP_ID=""
21
+ ```
22
+
23
+ ### 3) Run the environment
24
+ - Start the environment through docker:
25
+ ```
26
+ docker compose up
27
+ ```
28
+ - It will automatically seed the database with stores and products and when ready, can be accessed through `http://localhost:3000/` or the user specific `NEXT_PUBLIC_APP_URL`.
app/(storefront)/(main)/cart/components/checkout-button.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { toast } from "@/components/ui/use-toast";
5
+ import { routes } from "@/lib/routes";
6
+ import { getStoreSlug } from "@/server-actions/store-details";
7
+ import { Loader2, Lock } from "lucide-react";
8
+ import { useRouter } from "next/navigation";
9
+ import { useState } from "react";
10
+
11
+ export const CheckoutButton = (props: { storeId: number }) => {
12
+ const [isLoading, setIsLoading] = useState(false);
13
+ const router = useRouter();
14
+
15
+ return (
16
+ <Button
17
+ size="sm"
18
+ className="ml-auto flex items-center gap-2 justify-center"
19
+ onClick={() => {
20
+ setIsLoading(true);
21
+ getStoreSlug(props.storeId)
22
+ .then((slug) => {
23
+ router.push(`${routes.checkout}/${slug}`);
24
+ })
25
+ .catch(() => {
26
+ toast({
27
+ title: "Sorry, an error occurred.",
28
+ description: "Something went wrong. Please try again later.",
29
+ });
30
+ })
31
+ .finally(() => setIsLoading(false));
32
+ }}
33
+ disabled={isLoading}
34
+ >
35
+ {isLoading ? (
36
+ <Loader2 size={16} className="animate-spin" />
37
+ ) : (
38
+ <Lock size={16} />
39
+ )}
40
+ <p>Checkout</p>
41
+ </Button>
42
+ );
43
+ };
app/(storefront)/(main)/cart/layout.tsx ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { ContentWrapper } from "@/components/content-wrapper";
2
+ import { PropsWithChildren } from "react";
3
+
4
+ export default function Layout(props: PropsWithChildren) {
5
+ return <ContentWrapper>{props.children}</ContentWrapper>;
6
+ }
app/(storefront)/(main)/cart/loading.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LoadingSkeleton } from "@/components/ui/loading-skeleton";
2
+ import { cookies } from "next/headers";
3
+
4
+ export default function Loading() {
5
+ return (
6
+ <>
7
+ {/* {!!cookies().has("cartItems") && ( */}
8
+ <div>
9
+ <div className="mb-4 flex items-end justify-between">
10
+ <LoadingSkeleton className="w-48 h-12" />
11
+ <LoadingSkeleton className="w-36 h-8" />
12
+ </div>
13
+ <div className="grid grid-cols-9 gap-24">
14
+ <div className="col-span-6 flex flex-col gap-4">
15
+ {Array.from(Array(6)).map((_, i) => (
16
+ <LoadingSkeleton className="w-full h-12" key={i} />
17
+ ))}
18
+ </div>
19
+ <div className="col-span-3">
20
+ <LoadingSkeleton className="w-full h-96" />
21
+ </div>
22
+ </div>
23
+ </div>
24
+ {/* )} */}
25
+ </>
26
+ );
27
+ }
app/(storefront)/(main)/cart/page.tsx ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CartLineItems } from "@/components/storefront/cart-line-items";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Heading } from "@/components/ui/heading";
4
+ import { currencyFormatter } from "@/lib/currency";
5
+ import { routes } from "@/lib/routes";
6
+ import { getCart } from "@/server-actions/get-cart-details";
7
+ import { ChevronRight } from "lucide-react";
8
+ import { cookies } from "next/headers";
9
+ import Link from "next/link";
10
+ import { CheckoutButton } from "./components/checkout-button";
11
+
12
+ export default async function Cart() {
13
+ const cartId = cookies().get("cartId")?.value;
14
+ const { cartItems, uniqueStoreIds, cartItemDetails } = await getCart(
15
+ Number(cartId)
16
+ );
17
+
18
+ if (isNaN(Number(cartId)) || !cartItems.length) {
19
+ return (
20
+ <div className="mt-4 gap-4 rounded-md border-2 border-dashed border-gray-200 p-6 text-center h-[200px] flex items-center justify-center flex-col">
21
+ <Heading size="h3">Your cart is empty</Heading>
22
+ <Link href={routes.products}>
23
+ <Button>Start shopping</Button>
24
+ </Link>
25
+ </div>
26
+ );
27
+ }
28
+
29
+ return (
30
+ <div className="flex flex-col gap-6">
31
+ <div className="flex items-center justify-between">
32
+ <Heading size="h2">Cart</Heading>
33
+ <Link href={routes.products}>
34
+ <Button
35
+ variant="link"
36
+ className="flex items-end justify-center m-0 p-0 text-muted-foreground"
37
+ >
38
+ <p>Continue shopping</p>
39
+ <ChevronRight size={16} />
40
+ </Button>
41
+ </Link>
42
+ </div>
43
+ <div className="lg:grid lg:grid-cols-9 lg:gap-6 flex flex-col-reverse gap-6">
44
+ <div className="col-span-6 flex flex-col gap-8">
45
+ {uniqueStoreIds.map((storeId, i) => (
46
+ <div
47
+ key={i}
48
+ className="bg-secondary border border-border p-6 rounded-md"
49
+ >
50
+ <Heading size="h4">
51
+ {
52
+ cartItemDetails?.find((item) => item.storeId === storeId)
53
+ ?.storeName
54
+ }
55
+ </Heading>
56
+ <CartLineItems
57
+ variant="cart"
58
+ cartItems={cartItems}
59
+ products={
60
+ cartItemDetails?.filter((item) => item.storeId === storeId) ??
61
+ []
62
+ }
63
+ />
64
+ </div>
65
+ ))}
66
+ </div>
67
+ <div className="bg-secondary col-span-3 rounded-md border border-border p-6 h-fit flex flex-col gap-4">
68
+ <Heading size="h3">Cart Summary</Heading>
69
+ {uniqueStoreIds.map((storeId, i) => (
70
+ <div
71
+ key={i}
72
+ className="flex items-center border-b border-border pb-2 gap-4 flex-nowrap overflow-auto"
73
+ >
74
+ <p className="font-semibold">
75
+ {
76
+ cartItemDetails?.find((item) => item.storeId === storeId)
77
+ ?.storeName
78
+ }
79
+ </p>
80
+ <p>
81
+ {currencyFormatter(
82
+ cartItemDetails
83
+ .filter((item) => item.storeId === storeId)
84
+ .reduce((accum, curr) => {
85
+ const quantityInCart = cartItems.find(
86
+ (item) => item.id === curr.id
87
+ )?.qty;
88
+ return accum + Number(curr.price) * (quantityInCart ?? 0);
89
+ }, 0)
90
+ )}
91
+ </p>
92
+ <CheckoutButton storeId={storeId} />
93
+ </div>
94
+ ))}
95
+ </div>
96
+ </div>
97
+ </div>
98
+ );
99
+ }
app/(storefront)/(main)/layout.tsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NavBar } from "@/components/navbar";
2
+ import "../../../styles/globals.css";
3
+ import { Footer } from "@/components/footer";
4
+ import React from "react";
5
+ import { FloatingStar } from "@/components/floating-star";
6
+
7
+ export const metadata = {
8
+ title: "ShopSmart - Multi-store shopping",
9
+ description: "Shop smart, live better with ShopSmart.",
10
+ };
11
+
12
+ export default async function StorefrontLayout({
13
+ children,
14
+ }: {
15
+ children: React.ReactNode;
16
+ }) {
17
+ return (
18
+ <div className="min-h-screen w-full flex flex-col">
19
+ <FloatingStar />
20
+ <NavBar showSecondAnnouncementBar={true} />
21
+ <div className="h-full flex-1 mb-8">{children}</div>
22
+ <Footer />
23
+ </div>
24
+ );
25
+ }
app/(storefront)/(main)/loading.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ContentWrapper } from "@/components/content-wrapper";
2
+ import { LoadingSkeleton } from "@/components/ui/loading-skeleton";
3
+
4
+ export default function Loading() {
5
+ return (
6
+ <div>
7
+ <LoadingSkeleton className="w-full h-[500px]" />
8
+ <div className="flex gap-2 items-center justify-center mt-2 mb-6">
9
+ {Array.from(Array(3)).map((_, i) => (
10
+ <LoadingSkeleton className="w-3 h-3 rounded-full" key={i} />
11
+ ))}
12
+ </div>
13
+ <div className="flex items-center justify-center gap-2 mt-12">
14
+ <LoadingSkeleton className="w-24 h-8" />
15
+ <LoadingSkeleton className="w-24 h-8" />
16
+ </div>
17
+ <div className="flex flex-col gap-2 items-center justify-center mt-12">
18
+ <LoadingSkeleton className="w-1/2 md:w-1/3 h-12" />
19
+ <LoadingSkeleton className="w-1/3 md:w-1/4 h-10" />
20
+ </div>
21
+ <ContentWrapper className="sm:grid sm:grid-cols-2 md:grid-cols-4 gap-6 mt-8 flex flex-col">
22
+ {Array.from(Array(4)).map((_, i) => (
23
+ <LoadingSkeleton className="w-full h-48" key={i} />
24
+ ))}
25
+ </ContentWrapper>
26
+ </div>
27
+ );
28
+ }
app/(storefront)/(main)/page.tsx ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ContentWrapper } from "@/components/content-wrapper";
2
+ import { SlideShow } from "@/components/slideshow";
3
+ import { Heading } from "@/components/ui/heading";
4
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
5
+ import { db } from "@/db/db";
6
+ import { products, stores } from "@/db/schema";
7
+ import { eq } from "drizzle-orm";
8
+ import { PropsWithChildren } from "react";
9
+ import { ProductAndStore } from "./products/page";
10
+ import { ProductCard } from "@/components/storefront/product-card";
11
+ import { Button } from "@/components/ui/button";
12
+ import { routes } from "@/lib/routes";
13
+ import Link from "next/link";
14
+ import { FeatureBanner } from "../components/feature-banner";
15
+ import {
16
+ AlarmClock,
17
+ DollarSign,
18
+ FastForward,
19
+ Phone,
20
+ Truck,
21
+ User,
22
+ Wind,
23
+ } from "lucide-react";
24
+ import { Input } from "@/components/ui/input";
25
+ import { TextInputWithLabel } from "@/components/text-input-with-label";
26
+
27
+ export default async function Home() {
28
+ const storeAndProduct = (await db
29
+ .select({
30
+ product: products,
31
+ store: {
32
+ id: stores.id,
33
+ name: stores.name,
34
+ slug: stores.slug,
35
+ },
36
+ })
37
+ .from(products)
38
+ .leftJoin(stores, eq(products.storeId, stores.id))
39
+ .limit(8)) as ProductAndStore[];
40
+
41
+ return (
42
+ <div>
43
+ <SlideShow />
44
+ <ContentWrapper>
45
+ <Tabs defaultValue="for-buyers">
46
+ <div className="flex items-center justify-center mt-2 mb-8">
47
+ <TabsList>
48
+ <TabsTrigger value="for-buyers">For Buyers</TabsTrigger>
49
+ <TabsTrigger value="for-sellers">For Sellers</TabsTrigger>
50
+ </TabsList>
51
+ </div>
52
+ <TabsContent value="for-sellers">
53
+ <HomePageLayout
54
+ heading={<Heading size="h1">Sell online with ease.</Heading>}
55
+ subheading={
56
+ <Heading size="h2">
57
+ Access our global marketplace and sell your <br /> products to
58
+ over 1 million visitors.
59
+ </Heading>
60
+ }
61
+ >
62
+ <div className="md:grid md:grid-cols-3 gap-4 flex flex-col mt-12">
63
+ <FeatureBanner
64
+ heading="No monthly fees"
65
+ subheading="Fugit voluptates nihil ex et voluptas dignissimos blanditiis. Consectetur velit pariatur nihil quis nihil similique voluptatum in. Et nostrum ipsam quo magni. Velit et odit dolores."
66
+ icon={<DollarSign size={32} />}
67
+ />
68
+ <FeatureBanner
69
+ heading="Access to millions of buyers"
70
+ subheading="Fugit voluptates nihil ex et voluptas dignissimos blanditiis. Consectetur velit pariatur nihil quis nihil similique voluptatum in. Et nostrum ipsam quo magni. Velit et odit dolores."
71
+ icon={<User size={32} />}
72
+ />
73
+ <FeatureBanner
74
+ heading="Quick and easy setup"
75
+ subheading="Fugit voluptates nihil ex et voluptas dignissimos blanditiis. Consectetur velit pariatur nihil quis nihil similique voluptatum in. Et nostrum ipsam quo magni. Velit et odit dolores."
76
+ icon={<AlarmClock size={32} />}
77
+ />
78
+ </div>
79
+ <div className="flex items-center justify-center mt-12">
80
+ <Link href={routes.signUp}>
81
+ <Button size="lg">Create account</Button>
82
+ </Link>
83
+ </div>
84
+ </HomePageLayout>
85
+ </TabsContent>
86
+ <TabsContent value="for-buyers">
87
+ <HomePageLayout
88
+ heading={<Heading size="h1">Online shopping made easy.</Heading>}
89
+ subheading={
90
+ <Heading size="h2">
91
+ Shop hundreds of products from sellers worldwide.
92
+ </Heading>
93
+ }
94
+ >
95
+ <Heading size="h3">Top Picks</Heading>
96
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 overflow-auto mt-4">
97
+ {storeAndProduct.map((item) => (
98
+ <ProductCard
99
+ key={item.product.id}
100
+ storeAndProduct={item}
101
+ hideButtonActions={true}
102
+ />
103
+ ))}
104
+ </div>
105
+ <div className="mt-12 grid place-content-center">
106
+ <Link href={routes.products}>
107
+ <Button variant="default">View All Products</Button>
108
+ </Link>
109
+ </div>
110
+ <div className="bg-blue-900 text-white w-full p-12 rounded-md mt-12 flex items-center flex-col gap-2 justify-center text-center">
111
+ <p className="uppercase tracking-wide text-sm font-medium">
112
+ Featured seller
113
+ </p>
114
+ <p className="text-3xl font-bold">Tim&apos;s Terrific Toys</p>
115
+ <p>
116
+ Top seller of the month! Tim&apos;s Toys has been selling toys
117
+ for 10 years and is a top rated seller on the platform.
118
+ </p>
119
+ <Link
120
+ href={routes.products + "?seller=tims-toys"}
121
+ className="mt-6"
122
+ >
123
+ <Button variant="secondary">Explore seller</Button>
124
+ </Link>
125
+ </div>
126
+ <div className="md:grid md:grid-cols-3 gap-4 flex flex-col mt-12">
127
+ <FeatureBanner
128
+ heading="Free Shipping"
129
+ subheading="Free shipping on all orders over $50"
130
+ icon={<Truck size={32} />}
131
+ />
132
+ <FeatureBanner
133
+ heading="24/7 Customer Support"
134
+ subheading="Have a question? Get in touch."
135
+ icon={<Phone size={32} />}
136
+ />
137
+ <FeatureBanner
138
+ heading="Best prices"
139
+ subheading="We offer the best prices on the market."
140
+ icon={<DollarSign size={32} />}
141
+ />
142
+ </div>
143
+ </HomePageLayout>
144
+ </TabsContent>
145
+ </Tabs>
146
+ </ContentWrapper>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ const HomePageLayout = (
152
+ props: PropsWithChildren<{
153
+ heading: React.ReactNode;
154
+ subheading: React.ReactNode;
155
+ }>
156
+ ) => {
157
+ return (
158
+ <>
159
+ <div className="flex flex-col items-center justify-center gap-2 text-center mb-12 pt-2">
160
+ {props.heading}
161
+ <div className="text-slate-600">{props.subheading}</div>
162
+ </div>
163
+ {props.children}
164
+ </>
165
+ );
166
+ };
app/(storefront)/(main)/product/[productId]/layout.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ContentWrapper } from "@/components/content-wrapper";
2
+ import { type PropsWithChildren } from "react";
3
+
4
+ export default function Layout(props: PropsWithChildren) {
5
+ return (
6
+ <ContentWrapper className="max-w-7xl m-x-auto mt-8">
7
+ {props.children}
8
+ </ContentWrapper>
9
+ );
10
+ }
app/(storefront)/(main)/product/[productId]/loading.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LoadingSkeleton } from "@/components/ui/loading-skeleton";
2
+
3
+ export default function Loading() {
4
+ return (
5
+ <div className="md:grid md:grid-cols-9 gap-8 w-full flex flex-col">
6
+ <LoadingSkeleton className="w-full col-span-4 h-80" />
7
+ <div className="flex flex-col gap-4 col-span-5">
8
+ <LoadingSkeleton className="w-56 h-8" />
9
+ <LoadingSkeleton className="w-36 h-4" />
10
+ <LoadingSkeleton className="w-24 h-8" />
11
+ <div className="flex gap-2">
12
+ {Array.from(Array(2)).map((_, i) => (
13
+ <LoadingSkeleton.Button key={i} />
14
+ ))}
15
+ </div>
16
+ <LoadingSkeleton className="w-96 h-16" />
17
+ </div>
18
+ </div>
19
+ );
20
+ }
app/(storefront)/(main)/product/[productId]/page.tsx ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ParagraphFormatter } from "@/components/paragraph-formatter";
2
+ import { ProductForm } from "@/components/storefront/product-form";
3
+ import { FeatureIcons } from "@/components/storefront/feature-icons";
4
+ import { Heading } from "@/components/ui/heading";
5
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
6
+ import { Text } from "@/components/ui/text";
7
+ import { db } from "@/db/db";
8
+ import { Product, products, stores } from "@/db/schema";
9
+ import { currencyFormatter } from "@/lib/currency";
10
+ import { eq } from "drizzle-orm";
11
+ import Image from "next/image";
12
+ import Link from "next/link";
13
+ import { productsQueryParams, routes } from "@/lib/routes";
14
+ import { ProductImage } from "@/components/product-image";
15
+ import { addToCart } from "@/server-actions/add-to-cart";
16
+
17
+ export default async function StorefrontProductDetails(props: {
18
+ params: { productId: string };
19
+ }) {
20
+ const product = (await db
21
+ .select()
22
+ .from(products)
23
+ .where(eq(products.id, Number(props.params.productId)))
24
+ .then((res) => {
25
+ if (res.length === 0) throw new Error("Product not found");
26
+ return res[0];
27
+ })
28
+ .catch(() => {
29
+ throw new Error("Product not found");
30
+ })) as Omit<Product, "images"> & {
31
+ images: { id: string; url: string; alt: string }[];
32
+ };
33
+
34
+ const store = await db
35
+ .select({
36
+ name: stores.name,
37
+ description: stores.description,
38
+ slug: stores.slug,
39
+ })
40
+ .from(stores)
41
+ .where(eq(stores.id, Number(product.storeId)))
42
+ .then((res) => res[0])
43
+ .catch(() => {
44
+ throw new Error("Store not found");
45
+ });
46
+
47
+ return (
48
+ <div className="flex flex-col gap-8">
49
+ <div className="flex flex-col items-center md:items-start justify-start md:grid md:grid-cols-9 gap-8">
50
+ <div className="col-span-4 w-full">
51
+ <ProductImage
52
+ src={product.images[0]?.url}
53
+ alt={product.images[0]?.alt}
54
+ height="h-96"
55
+ width="w-full"
56
+ />
57
+ {product.images.length > 1 && (
58
+ <>
59
+ <div className="flex items-center justify-start gap-2 mt-2 overflow-auto flex-nowrap">
60
+ {product.images.slice(1).map((image) => (
61
+ <div key={image.id} className="relative h-24 w-24">
62
+ <Image
63
+ src={image.url}
64
+ alt={image.alt}
65
+ fill
66
+ className="object-cover h-24 w-24"
67
+ />
68
+ </div>
69
+ ))}
70
+ </div>
71
+ </>
72
+ )}
73
+ </div>
74
+ <div className="md:col-span-5 w-full">
75
+ <Heading size="h2">{product.name}</Heading>
76
+ <Text className="text-sm mt-2">
77
+ Sold by{" "}
78
+ <Link
79
+ href={`${routes.products}?${productsQueryParams.seller}${store.slug}`}
80
+ >
81
+ <span className="text-muted-foreground hover:underline">
82
+ {store.name}
83
+ </span>
84
+ </Link>
85
+ </Text>
86
+ <Text className="text-xl my-4">
87
+ {currencyFormatter(Number(product.price))}
88
+ </Text>
89
+ <ProductForm
90
+ addToCartAction={addToCart}
91
+ productName={product.name}
92
+ availableInventory={product.inventory}
93
+ productId={product.id}
94
+ />
95
+ <FeatureIcons className="mt-8" />
96
+ </div>
97
+ </div>
98
+ <Tabs defaultValue="product">
99
+ <div className="overflow-auto">
100
+ <TabsList>
101
+ <TabsTrigger value="product">Product Description</TabsTrigger>
102
+ <TabsTrigger value="seller">About the Seller</TabsTrigger>
103
+ </TabsList>
104
+ </div>
105
+ <TabsContent value="product" className="pt-2">
106
+ <ParagraphFormatter paragraphs={product.description} />
107
+ </TabsContent>
108
+ <TabsContent value="seller" className="pt-2">
109
+ {store.description}
110
+ </TabsContent>
111
+ </Tabs>
112
+ </div>
113
+ );
114
+ }
app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/layout.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type PropsWithChildren } from "react";
2
+ import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog";
3
+
4
+ export default function Layout(props: PropsWithChildren) {
5
+ return (
6
+ <AlertDialog defaultOpen>
7
+ <AlertDialogContent className="max-w-2xl min-w-xl max-h-[500px] overflow-x-auto">
8
+ {props.children}
9
+ </AlertDialogContent>
10
+ </AlertDialog>
11
+ );
12
+ }
app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/loading.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LoadingSkeleton } from "@/components/ui/loading-skeleton";
2
+
3
+ export default function Loading() {
4
+ return (
5
+ <div className="grid grid-cols-2 gap-6 w-full">
6
+ <LoadingSkeleton className="w-full h-48" />
7
+ <div className="flex flex-col gap-4 col-span-1">
8
+ <LoadingSkeleton className="w-56 h-8" />
9
+ <LoadingSkeleton className="w-24 h-8" />
10
+ <LoadingSkeleton.Button />
11
+ <div className="flex flex-col gap-2 w-full">
12
+ <LoadingSkeleton className="w-full h-5" />
13
+ <LoadingSkeleton className="w-[90%] h-5" />
14
+ <LoadingSkeleton className="w-[70%] h-5" />
15
+ </div>
16
+ </div>
17
+ </div>
18
+ );
19
+ }
app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/page.tsx ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog";
2
+ import { ParagraphFormatter } from "@/components/paragraph-formatter";
3
+ import { Heading } from "@/components/ui/heading";
4
+ import { Text } from "@/components/ui/text";
5
+ import { db } from "@/db/db";
6
+ import { Product, products } from "@/db/schema";
7
+ import { currencyFormatter } from "@/lib/currency";
8
+ import { eq } from "drizzle-orm";
9
+ import { ImageOff } from "lucide-react";
10
+ import Image from "next/image";
11
+ import { Button } from "@/components/ui/button";
12
+ import { QuickViewModalWrapper } from "@/components/storefront/quickview-modal-wrapper";
13
+ import Link from "next/link";
14
+ import { routes } from "@/lib/routes";
15
+
16
+ export default async function StorefrontProductQuickView(props: {
17
+ params: { productId: string };
18
+ }) {
19
+ const product = (await db
20
+ .select()
21
+ .from(products)
22
+ .where(eq(products.id, Number(props.params.productId)))
23
+ .then((res) => {
24
+ if (res.length === 0) throw new Error("Product not found");
25
+ return res[0];
26
+ })
27
+ .catch(() => {
28
+ throw new Error("Product not found");
29
+ })) as Omit<Product, "images"> & {
30
+ images: { id: string; url: string; alt: string }[];
31
+ };
32
+
33
+ return (
34
+ <QuickViewModalWrapper>
35
+ <div className="flex flex-col gap-8">
36
+ <div className="flex flex-col items-center md:items-start justify-start md:grid md:grid-cols-9 gap-8">
37
+ <div className="col-span-4 w-full">
38
+ {product.images.length > 0 ? (
39
+ <>
40
+ <div className="relative h-48 w-full">
41
+ <Image
42
+ src={product.images[0].url}
43
+ alt={product.images[0].alt}
44
+ fill
45
+ className="object-cover h-48 w-full"
46
+ />
47
+ </div>
48
+ <div className="flex items-center justify-start gap-2 mt-2 overflow-auto flex-nowrap">
49
+ {product.images.slice(1).map((image) => (
50
+ <div key={image.id} className="relative h-24 w-24">
51
+ <Image
52
+ src={image.url}
53
+ alt={image.alt}
54
+ fill
55
+ className="object-cover h-24 w-24"
56
+ />
57
+ </div>
58
+ ))}
59
+ </div>
60
+ </>
61
+ ) : (
62
+ <div className="h-96 w-full bg-secondary flex justify-center items-center">
63
+ <ImageOff />
64
+ </div>
65
+ )}
66
+ </div>
67
+ <div className="md:col-span-5 w-full flex flex-col gap-2">
68
+ <Heading size="h3">{product.name}</Heading>
69
+ <Text className="text-lg">
70
+ {currencyFormatter(Number(product.price))}
71
+ </Text>
72
+ <div className="my-2 flex items-start flex-col gap-2 justify-center">
73
+ <Link href={`${routes.product}/${props.params.productId}`}>
74
+ <Button variant="default" className="w-fit" size="sm">
75
+ View Product
76
+ </Button>
77
+ </Link>
78
+ {!product.inventory && (
79
+ <Text appearance="secondary">Sold out</Text>
80
+ )}
81
+ </div>
82
+ <ParagraphFormatter
83
+ className="text-sm"
84
+ paragraphs={product.description}
85
+ />
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </QuickViewModalWrapper>
90
+ );
91
+ }
app/(storefront)/(main)/products/@modal/default.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export default function Default() {
2
+ return null;
3
+ }
app/(storefront)/(main)/products/layout.tsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ContentWrapper } from "@/components/content-wrapper";
2
+ import { PropsWithChildren } from "react";
3
+
4
+ export default function Layout(
5
+ props: PropsWithChildren<{ modal: React.ReactNode }>
6
+ ) {
7
+ return (
8
+ <ContentWrapper>
9
+ {props.children}
10
+ {props.modal}
11
+ </ContentWrapper>
12
+ );
13
+ }
app/(storefront)/(main)/products/loading.tsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LoadingSkeleton } from "@/components/ui/loading-skeleton";
2
+
3
+ export default function Loading() {
4
+ return (
5
+ <div>
6
+ <LoadingSkeleton.CollectionHeaderWrapper />
7
+ <div className="md:grid md:grid-cols-12 gap-12 mt-8">
8
+ <LoadingSkeleton className="hidden md:block w-full md:col-span-3 md:h-full" />
9
+ <div className="md:col-span-9 sm:grid md:grid-cols-3 gap-8 sm:grid-cols-2 flex flex-col">
10
+ {Array.from(Array(12)).map((_, i) => (
11
+ <LoadingSkeleton key={i} className="w-full h-48" />
12
+ ))}
13
+ </div>
14
+ </div>
15
+ </div>
16
+ );
17
+ }
app/(storefront)/(main)/products/page.tsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CollectionBody } from "@/components/storefront/collection-body";
2
+ import { CollectionHeaderWrapper } from "@/components/storefront/collection-header-wrapper";
3
+ import { CollectionPagePagination } from "@/components/storefront/collection-page-pagination";
4
+ import { db } from "@/db/db";
5
+ import { Product, Store, stores } from "@/db/schema";
6
+ import { products } from "@/db/schema";
7
+ import { eq, inArray } from "drizzle-orm";
8
+
9
+ export type ProductAndStore = {
10
+ product: Omit<Product, "images"> & {
11
+ images: { id: string; url: string; alt: string }[];
12
+ };
13
+ store: Omit<Store, "description" | "industry">;
14
+ };
15
+
16
+ const PRODUCTS_PER_PAGE = 6;
17
+
18
+ export default async function StorefrontProductsPage(context: {
19
+ params: { slug: string };
20
+ searchParams: { page: string; seller: string };
21
+ }) {
22
+ const storeAndProduct = (await db
23
+ .select({
24
+ product: products,
25
+ store: {
26
+ id: stores.id,
27
+ name: stores.name,
28
+ slug: stores.slug,
29
+ },
30
+ })
31
+ .from(products)
32
+ .where(() => {
33
+ if (
34
+ context.searchParams.seller === undefined ||
35
+ context.searchParams.seller === ""
36
+ )
37
+ return;
38
+ return inArray(stores.slug, context.searchParams.seller.split("_"));
39
+ })
40
+ .leftJoin(stores, eq(products.storeId, stores.id))
41
+ .limit(PRODUCTS_PER_PAGE)
42
+ .offset(
43
+ !isNaN(Number(context.searchParams.page))
44
+ ? (Number(context.searchParams.page) - 1) * PRODUCTS_PER_PAGE
45
+ : 0
46
+ )) as ProductAndStore[];
47
+
48
+ return (
49
+ <div>
50
+ <CollectionHeaderWrapper heading="Products">
51
+ <p>
52
+ Browse all products from our marketplace sellers – groceries, tech,
53
+ fashion and home essentials in one place.
54
+ </p>
55
+ <p>
56
+ Filter by Featured Sellers to see items from a specific store, or use
57
+ the categories to discover something new.
58
+ </p>
59
+
60
+ </CollectionHeaderWrapper>
61
+ <CollectionBody
62
+ storeAndProduct={storeAndProduct}
63
+ activeSellers={await getActiveSellers()}
64
+ >
65
+ <CollectionPagePagination
66
+ productsPerPage={PRODUCTS_PER_PAGE}
67
+ sellerParams={context.searchParams.seller as string}
68
+ />
69
+ </CollectionBody>
70
+ </div>
71
+ );
72
+ }
73
+
74
+ const getActiveSellers = async () => {
75
+ return await db
76
+ .select({
77
+ id: stores.id,
78
+ name: stores.name,
79
+ slug: stores.slug,
80
+ })
81
+ .from(stores);
82
+ };
app/(storefront)/(main)/quickview/product/[productId]/page.tsx ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { routes } from "@/lib/routes";
2
+ import { redirect } from "next/navigation";
3
+
4
+ export default function QuickViewPage(context: {
5
+ params: { productId: string };
6
+ }) {
7
+ redirect(`${routes.product}/${context.params.productId}`);
8
+ }
app/(storefront)/checkout/[storeSlug]/error.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { Heading } from "@/components/ui/heading";
5
+ import { routes } from "@/lib/routes";
6
+ import Link from "next/link";
7
+
8
+ export default function CheckoutError(props: {
9
+ error: Error;
10
+ reset: () => void;
11
+ }) {
12
+ return (
13
+ <div className="flex items-center justify-center flex-col mt-24">
14
+ <Heading size="h2">Sorry, an error occured loading this page.</Heading>
15
+ <p className="mt-2">
16
+ Please try again, or contact our customer support team if this issue
17
+ persists.
18
+ </p>
19
+ <div className="flex gap-2 items-center mt-4">
20
+ <Link href={routes.cart}>
21
+ <Button>Return to Cart</Button>
22
+ </Link>
23
+ </div>
24
+ </div>
25
+ );
26
+ }
app/(storefront)/checkout/[storeSlug]/order-confirmation/components/verification.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { TextInputWithLabel } from "@/components/text-input-with-label";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { useRouter } from "next/navigation";
7
+ import { useState } from "react";
8
+
9
+ export const Verification = () => {
10
+ const [formValues, setFormValues] = useState({
11
+ postcode: "",
12
+ });
13
+ const router = useRouter();
14
+
15
+ return (
16
+ <form
17
+ className="flex flex-col gap-2 md:w-1/2 bg-secondary border border-border rounded-md p-4"
18
+ onSubmit={(e) => {
19
+ e.preventDefault();
20
+ router.push(
21
+ window.location.href.split("&delivery_postal_code=")[0] +
22
+ "&delivery_postal_code=" +
23
+ formValues.postcode.split(" ").join("")
24
+ );
25
+ }}
26
+ >
27
+ <div className="md:grid flex flex-col grid-cols-12 gap-4">
28
+ <div className="md:col-span-8">
29
+ <TextInputWithLabel
30
+ label="Enter delivery postcode"
31
+ id="postcode"
32
+ type="text"
33
+ state={formValues}
34
+ setState={setFormValues}
35
+ />
36
+ </div>
37
+ <div className="flex w-full items-end md:col-span-4">
38
+ <Button className="w-full" type="submit">
39
+ Submit
40
+ </Button>
41
+ </div>
42
+ </div>
43
+ </form>
44
+ );
45
+ };
app/(storefront)/checkout/[storeSlug]/order-confirmation/page.tsx ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Heading } from "@/components/ui/heading";
2
+ import { getPaymentIntentDetails } from "@/server-actions/stripe/payment";
3
+ import { Verification } from "./components/verification";
4
+ import { db } from "@/db/db";
5
+ import { stores } from "@/db/schema";
6
+ import { eq } from "drizzle-orm";
7
+ import { CheckoutItem, OrderItemDetails } from "@/lib/types";
8
+ import { Check } from "lucide-react";
9
+ import { OrderLineItems } from "@/components/order-line-items";
10
+ import { getDetailsOfProductsOrdered } from "@/server-actions/orders";
11
+ import { currencyFormatter } from "@/lib/currency";
12
+
13
+ const getSellerName = async (storeSlug: string) => {
14
+ return await db
15
+ .select({
16
+ name: stores.name,
17
+ })
18
+ .from(stores)
19
+ .where(eq(stores.slug, storeSlug));
20
+ };
21
+
22
+ export default async function OrderConfirmation({
23
+ params,
24
+ searchParams,
25
+ }: {
26
+ params: {
27
+ storeSlug: string;
28
+ };
29
+ searchParams: {
30
+ payment_intent: string;
31
+ payment_intent_client_secret: string;
32
+ redirect_status: "success";
33
+ delivery_postal_code: string;
34
+ };
35
+ }) {
36
+ const { paymentDetails, isVerified } = await getPaymentIntentDetails({
37
+ paymentIntentId: searchParams.payment_intent,
38
+ storeSlug: params.storeSlug,
39
+ deliveryPostalCode: searchParams.delivery_postal_code,
40
+ });
41
+
42
+ const checkoutItems = JSON.parse(
43
+ paymentDetails?.metadata?.items ?? "[]"
44
+ ) as CheckoutItem[];
45
+
46
+ let products: OrderItemDetails[] = [];
47
+ let sellerDetails;
48
+ if (isVerified) {
49
+ sellerDetails = (await getSellerName(params.storeSlug))[0];
50
+ products = await getDetailsOfProductsOrdered(checkoutItems);
51
+ }
52
+
53
+ return (
54
+ <div className="mt-8">
55
+ {isVerified ? (
56
+ <div>
57
+ <Heading size="h2">
58
+ <div className="flex md:flex-row flex-col items-start md:items-center justify-start gap-4 md:gap-2">
59
+ <div className="border-2 border-green-600 text-green-600 bg-transparent rounded-full h-10 w-10 flex items-center justify-center">
60
+ <Check className="text-green-600" size={26} />
61
+ </div>
62
+ <span>
63
+ Thanks for your order,{" "}
64
+ <span className="capitalize">
65
+ {paymentDetails?.shipping?.name?.split(" ")[0]}
66
+ </span>
67
+ !
68
+ </span>
69
+ </div>
70
+ </Heading>
71
+ <p className="text-muted-foreground mt-4">
72
+ Your payment confirmation ID is #
73
+ {searchParams.payment_intent.slice(3)}
74
+ </p>
75
+ <div className="flex flex-col gap-4 mt-8">
76
+ <div className="p-6 bg-secondary border border-border rounded-md">
77
+ <Heading size="h3">What&apos;s next?</Heading>
78
+ <p>
79
+ Our warehouse team is busy preparing your order. You&apos;ll
80
+ receive an email once your order ships.
81
+ </p>
82
+ </div>
83
+ <div className="lg:grid grid-cols-2 gap-4 flex flex-col">
84
+ <div className="p-6 bg-secondary border border-border rounded-md sm:grid grid-cols-3 flex flex-col gap-4">
85
+ <div className="sm:col-span-2">
86
+ <div className="mb-2">
87
+ <Heading size="h4">Shipping Address</Heading>
88
+ </div>
89
+ <p>{paymentDetails?.shipping?.name}</p>
90
+ <p className="mb-3">{paymentDetails?.receipt_email}</p>
91
+ <p>{paymentDetails?.shipping?.address?.line1}</p>
92
+ <p>{paymentDetails?.shipping?.address?.line2}</p>
93
+ <p>
94
+ {paymentDetails?.shipping?.address?.city},{" "}
95
+ {paymentDetails?.shipping?.address?.postal_code}
96
+ </p>
97
+ <p>
98
+ {paymentDetails?.shipping?.address?.state},{" "}
99
+ {paymentDetails?.shipping?.address?.country}
100
+ </p>
101
+ </div>
102
+ <div>
103
+ <div className="mb-2">
104
+ <Heading size="h4">Seller Details</Heading>
105
+ </div>
106
+ <p>{sellerDetails?.name}</p>
107
+ </div>
108
+ </div>
109
+ <div className="p-6 border border-border bg-secondary rounded-md">
110
+ <div className="mb-2">
111
+ <Heading size="h4">Order Details</Heading>
112
+ </div>
113
+ <OrderLineItems
114
+ checkoutItems={checkoutItems}
115
+ products={products}
116
+ />
117
+ <div className="border-y border-slate-200 py-2 px-2 mx-1 mt-2 flex items-center gap-2">
118
+ <Heading size="h4">Order Total: </Heading>
119
+ <p className="scroll-m-20 text-xl tracking-tight">
120
+ {currencyFormatter(
121
+ checkoutItems.reduce(
122
+ (acc, curr) => acc + curr.price * curr.qty,
123
+ 0
124
+ )
125
+ )}
126
+ </p>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ ) : (
133
+ <div>
134
+ <Heading size="h2">Thanks for your order!</Heading>
135
+ <p className="mb-4 mt-2">
136
+ Please enter your delivery postcode below to view your order
137
+ details.
138
+ </p>
139
+ <Verification />
140
+ </div>
141
+ )}
142
+ </div>
143
+ );
144
+ }
app/(storefront)/checkout/[storeSlug]/page.tsx ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createPaymentIntent } from "@/server-actions/stripe/payment";
2
+ import CheckoutWrapper from "../components/checkout-wrapper";
3
+ import { cookies } from "next/headers";
4
+ import { getCart } from "@/server-actions/get-cart-details";
5
+ import { db } from "@/db/db";
6
+ import { payments, products, stores } from "@/db/schema";
7
+ import { eq } from "drizzle-orm";
8
+ import { CheckoutItem } from "@/lib/types";
9
+ import { CartLineItems } from "@/components/storefront/cart-line-items";
10
+ import { InfoCard } from "@/components/admin/info-card";
11
+ import { AlertCircle } from "lucide-react";
12
+ import { Button } from "@/components/ui/button";
13
+ import { routes } from "@/lib/routes";
14
+ import Link from "next/link";
15
+ import { hasConnectedStripeAccount } from "@/server-actions/stripe/account";
16
+
17
+ export default async function Page({
18
+ params,
19
+ }: {
20
+ params: { storeSlug: string };
21
+ }) {
22
+ const cartId = cookies().get("cartId")?.value;
23
+ const { cartItems, cartItemDetails } = await getCart(Number(cartId));
24
+
25
+ const store = await db
26
+ .select({
27
+ storeId: stores.id,
28
+ stripeAccountId: payments.stripeAccountId,
29
+ })
30
+ .from(stores)
31
+ .leftJoin(payments, eq(payments.storeId, stores.id))
32
+ .where(eq(stores.slug, params.storeSlug));
33
+
34
+ const storeId = Number(store[0].storeId);
35
+ const storeStripeAccountId = store[0].stripeAccountId;
36
+
37
+ const storeProducts = await db
38
+ .select({
39
+ id: products.id,
40
+ price: products.price,
41
+ })
42
+ .from(products)
43
+ .leftJoin(stores, eq(products.storeId, stores.id))
44
+ .where(eq(stores.id, storeId));
45
+
46
+ // @TODO: check if items from this store are in the cart
47
+
48
+ const detailsOfProductsInCart = cartItems
49
+ .map((item) => {
50
+ const product = storeProducts.find((p) => p.id === item.id);
51
+ const priceAsNumber = Number(product?.price);
52
+ if (!product || isNaN(priceAsNumber)) return undefined;
53
+ return {
54
+ id: item.id,
55
+ price: priceAsNumber,
56
+ qty: item.qty,
57
+ };
58
+ })
59
+ .filter(Boolean) as CheckoutItem[];
60
+
61
+ if (
62
+ !storeStripeAccountId ||
63
+ !(await hasConnectedStripeAccount(storeId, true))
64
+ ) {
65
+ return (
66
+ <InfoCard
67
+ heading="Online payments not setup"
68
+ subheading="This seller does not have online payments setup yet. Please contact the seller directly to submit your order."
69
+ icon={<AlertCircle size={24} />}
70
+ button={
71
+ <Link href={routes.cart}>
72
+ <Button>Return to cart</Button>
73
+ </Link>
74
+ }
75
+ />
76
+ );
77
+ }
78
+
79
+ if (
80
+ !storeProducts.length ||
81
+ isNaN(storeId) ||
82
+ !detailsOfProductsInCart.length
83
+ )
84
+ throw new Error("Store not found");
85
+
86
+ const paymentIntent = createPaymentIntent({
87
+ items: detailsOfProductsInCart,
88
+ storeId,
89
+ });
90
+
91
+ // providing the paymntIntent to the CheckoutWrapper to work around Nextjs bug with authentication not passed to server actions when called in client component
92
+ return (
93
+ <CheckoutWrapper
94
+ detailsOfProductsInCart={detailsOfProductsInCart}
95
+ paymentIntent={paymentIntent}
96
+ storeStripeAccountId={storeStripeAccountId}
97
+ cartLineItems={
98
+ <CartLineItems
99
+ variant="checkout"
100
+ cartItems={cartItems}
101
+ products={
102
+ cartItemDetails?.filter((item) => item.storeId === storeId) ?? []
103
+ }
104
+ />
105
+ }
106
+ />
107
+ );
108
+ }
app/(storefront)/checkout/components/checkout-form.tsx ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { Heading } from "@/components/ui/heading";
5
+ import { routes } from "@/lib/routes";
6
+ import { StripeCheckoutFormDetails } from "@/lib/types";
7
+ // https://stripe.com/docs/payments/quickstart
8
+
9
+ import {
10
+ PaymentElement,
11
+ LinkAuthenticationElement,
12
+ useStripe,
13
+ useElements,
14
+ AddressElement,
15
+ } from "@stripe/react-stripe-js";
16
+ import {
17
+ StripeAddressElementChangeEvent,
18
+ StripeLinkAuthenticationElementChangeEvent,
19
+ StripePaymentElementOptions,
20
+ } from "@stripe/stripe-js";
21
+ import { AlertCircle, Loader2 } from "lucide-react";
22
+ import { useParams } from "next/navigation";
23
+ import { FormEvent, useEffect, useState } from "react";
24
+
25
+ export default function CheckoutForm() {
26
+ const { storeSlug } = useParams();
27
+ const stripe = useStripe();
28
+ const elements = useElements();
29
+
30
+ const [email, setEmail] = useState("");
31
+ const [message, setMessage] = useState<null | string>(null);
32
+ const [isLoading, setIsLoading] = useState(false);
33
+
34
+ useEffect(() => {
35
+ if (!stripe) {
36
+ return;
37
+ }
38
+
39
+ const clientSecret = new URLSearchParams(window.location.search).get(
40
+ "payment_intent_client_secret"
41
+ );
42
+
43
+ if (!clientSecret) {
44
+ return;
45
+ }
46
+
47
+ stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }) => {
48
+ switch (paymentIntent?.status) {
49
+ case "succeeded":
50
+ setMessage("Payment succeeded!");
51
+ break;
52
+ case "processing":
53
+ setMessage("Your payment is processing.");
54
+ break;
55
+ case "requires_payment_method":
56
+ setMessage("Your payment was not successful, please try again.");
57
+ break;
58
+ default:
59
+ setMessage("Something went wrong.");
60
+ break;
61
+ }
62
+ });
63
+ }, [stripe]);
64
+
65
+ const handleSubmit = async (e: FormEvent) => {
66
+ e.preventDefault();
67
+
68
+ if (!stripe || !elements) {
69
+ // Stripe.js hasn't yet loaded.
70
+ // Make sure to disable form submission until Stripe.js has loaded.
71
+ return;
72
+ }
73
+
74
+ setIsLoading(true);
75
+
76
+ const { error } = await stripe.confirmPayment({
77
+ elements,
78
+ confirmParams: {
79
+ // Make sure to change this to your payment completion page
80
+ return_url: `${process.env.NEXT_PUBLIC_APP_URL}/${routes.checkout}/${storeSlug}/${routes.orderConfirmation}`,
81
+ receipt_email: email,
82
+ },
83
+ });
84
+
85
+ // This point will only be reached if there is an immediate error when
86
+ // confirming the payment. Otherwise, your customer will be redirected to
87
+ // your `return_url`. For some payment methods like iDEAL, your customer will
88
+ // be redirected to an intermediate site first to authorize the payment, then
89
+ // redirected to the `return_url`.
90
+ if (error.type === "card_error" || error.type === "validation_error") {
91
+ setMessage(error.message ?? "An unexpected error occurred.");
92
+ } else {
93
+ setMessage("An unexpected error occurred.");
94
+ }
95
+
96
+ setIsLoading(false);
97
+ };
98
+
99
+ const paymentElementOptions = {
100
+ layout: "tabs",
101
+ } as StripePaymentElementOptions;
102
+
103
+ return (
104
+ <form
105
+ id="payment-form"
106
+ onSubmit={handleSubmit}
107
+ className="flex flex-col gap-6"
108
+ >
109
+ {/* Show any error or success messages */}
110
+ {message && (
111
+ <div
112
+ id="payment-message"
113
+ className="bg-red-100 border border-red-600 text-red-800 rounded-md p-2 flex items-center justify-start gap-2"
114
+ >
115
+ <AlertCircle />
116
+ <p> {message}</p>
117
+ </div>
118
+ )}
119
+ <div className="flex flex-col gap-2 bg-secondary border-border border rounded-md md:p-6 p-4 md:pb-7 pb-5">
120
+ <Heading size="h4">Contact Info</Heading>
121
+ <LinkAuthenticationElement
122
+ id="link-authentication-element"
123
+ onChange={(e: StripeLinkAuthenticationElementChangeEvent) =>
124
+ setEmail(e.value.email)
125
+ }
126
+ />
127
+ </div>
128
+ <div className="flex flex-col gap-2 bg-secondary border-border border rounded-md md:p-6 p-4 md:pb-7 pb-5">
129
+ <Heading size="h4">Shipping</Heading>
130
+ <AddressElement options={{ mode: "shipping" }} />
131
+ </div>
132
+ <div className="flex flex-col gap-2 bg-secondary border-border border rounded-md md:p-6 p-4 md:pb-7 pb-5">
133
+ <Heading size="h4">Payment</Heading>
134
+ <PaymentElement id="payment-element" options={paymentElementOptions} />
135
+ </div>
136
+ <Button
137
+ disabled={isLoading || !stripe || !elements}
138
+ id="submit"
139
+ className="w-fit"
140
+ >
141
+ <div className="flex items-center justify-center gap-2">
142
+ {!!isLoading && <Loader2 size={18} className="animate-spin" />}
143
+ <p>Pay Now</p>
144
+ </div>
145
+ </Button>
146
+ </form>
147
+ );
148
+ }
app/(storefront)/checkout/components/checkout-wrapper.tsx ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { StripeElementsOptions, loadStripe } from "@stripe/stripe-js";
4
+ import { Elements } from "@stripe/react-stripe-js";
5
+ import { useEffect, useMemo, useState } from "react";
6
+ import CheckoutForm from "./checkout-form";
7
+ import { ChevronRight } from "lucide-react";
8
+ import { StarSVG } from "@/components/icons/star";
9
+ import { routes } from "@/lib/routes";
10
+ import Link from "next/link";
11
+ import { Button } from "@/components/ui/button";
12
+ import { Heading } from "@/components/ui/heading";
13
+ import { OrderSummaryAccordion } from "./order-summary-accordion";
14
+ import { FeatureIcons } from "@/components/storefront/feature-icons";
15
+ import { CheckoutItem } from "@/lib/types";
16
+ import { currencyFormatter } from "@/lib/currency";
17
+
18
+ export default function CheckoutWrapper(props: {
19
+ paymentIntent: Promise<{ clientSecret: string | null } | undefined>;
20
+ detailsOfProductsInCart: CheckoutItem[];
21
+ storeStripeAccountId: string;
22
+ cartLineItems: React.ReactNode;
23
+ }) {
24
+ const [clientSecret, setClientSecret] = useState("");
25
+ const stripePromise = useMemo(
26
+ () =>
27
+ loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, {
28
+ stripeAccount: props.storeStripeAccountId,
29
+ }),
30
+ [props.storeStripeAccountId]
31
+ );
32
+
33
+ useEffect(() => {
34
+ let error;
35
+ props.paymentIntent.then((data) => {
36
+ if (!data || !data.clientSecret) {
37
+ error = true;
38
+ return;
39
+ }
40
+ setClientSecret(data.clientSecret);
41
+ });
42
+ if (error) throw new Error("Payment intent not found");
43
+ }, [props.paymentIntent]);
44
+
45
+ const options = {
46
+ clientSecret,
47
+ appearance: {
48
+ theme: "stripe",
49
+ },
50
+ } as StripeElementsOptions;
51
+
52
+ const orderTotal = useMemo(() => {
53
+ return currencyFormatter(
54
+ props.detailsOfProductsInCart.reduce(
55
+ (acc, item) => acc + item.price * item.qty,
56
+ 0
57
+ )
58
+ );
59
+ }, [props.detailsOfProductsInCart]);
60
+
61
+ return (
62
+ <div>
63
+ <Heading size="h2">Checkout</Heading>
64
+ <div className="text-muted-foreground flex items-center justify-start gap-1">
65
+ <Link href={routes.cart}>
66
+ <Button variant="link" className="p-0 text-muted-foreground">
67
+ Cart
68
+ </Button>
69
+ </Link>
70
+ <ChevronRight size={16} />
71
+ <Button
72
+ variant="link"
73
+ className="p-0 text-muted-foreground hover:no-underline hover:cursor-auto"
74
+ >
75
+ Checkout
76
+ </Button>
77
+ </div>
78
+ {clientSecret && (
79
+ <div>
80
+ <div className="lg:grid lg:grid-cols-12 lg:gap-8 mt-4 flex flex-col-reverse gap-6">
81
+ <div className="col-span-7">
82
+ <Elements options={options} stripe={stripePromise}>
83
+ <CheckoutForm />
84
+ </Elements>
85
+ </div>
86
+ <div className="col-span-5">
87
+ <div className="bg-secondary rounded-lg lg:p-6 h-fit border-border border p-1 px-4 lg:mb-8">
88
+ <div className="hidden lg:flex flex-col gap-2">
89
+ <Heading size="h4">Order Summary</Heading>
90
+ {props.cartLineItems}
91
+ <OrderTotalRow total={orderTotal} />
92
+ </div>
93
+ <OrderSummaryAccordion
94
+ title="Order Summary"
95
+ className="lg:hidden"
96
+ >
97
+ {props.cartLineItems}
98
+ <OrderTotalRow total={orderTotal} />
99
+ </OrderSummaryAccordion>
100
+ </div>
101
+ <div className="lg:hidden bg-secondary border border-border p-5 pt-8 mt-8 rounded-md">
102
+ <TrustBadges />
103
+ </div>
104
+ <div className="hidden lg:block">
105
+ <TrustBadges />
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ )}
111
+ </div>
112
+ );
113
+ }
114
+
115
+ const TrustBadges = () => {
116
+ return (
117
+ <div className="flex items-center justify-center flex-col gap-6">
118
+ <div className="flex flex-col gap-2 items-center justify-center">
119
+ <p className="text-lg font-semibold text-center">
120
+ Hundreds of happy customers worldwide
121
+ </p>
122
+ <div className="flex items-center justify-center gap-1">
123
+ {Array.from(Array(5)).map((_, i) => (
124
+ <div className="max-w-2" key={i}>
125
+ <StarSVG />
126
+ </div>
127
+ ))}
128
+ </div>
129
+ </div>
130
+ <FeatureIcons />
131
+ </div>
132
+ );
133
+ };
134
+
135
+ const OrderTotalRow = (props: { total: string }) => {
136
+ return (
137
+ <div className="flex items-center justify-between p-4 py-2 border-y border-slate-200">
138
+ <Heading size="h4">Total</Heading>
139
+ <p className="text-lg font-semibold">{props.total}</p>
140
+ </div>
141
+ );
142
+ };
app/(storefront)/checkout/components/order-summary-accordion.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Accordion,
3
+ AccordionContent,
4
+ AccordionItem,
5
+ AccordionTrigger,
6
+ } from "@/components/ui/accordion";
7
+ import { cn } from "@/lib/utils";
8
+ import { PropsWithChildren } from "react";
9
+
10
+ export function OrderSummaryAccordion(
11
+ props: PropsWithChildren<{
12
+ title: string;
13
+ className?: string;
14
+ }>
15
+ ) {
16
+ return (
17
+ <Accordion
18
+ type="single"
19
+ collapsible
20
+ className={cn("w-full", props.className)}
21
+ >
22
+ <AccordionItem value="item-1" className="border-none">
23
+ <AccordionTrigger>{props.title}</AccordionTrigger>
24
+ <AccordionContent>{props.children}</AccordionContent>
25
+ </AccordionItem>
26
+ </Accordion>
27
+ );
28
+ }
app/(storefront)/checkout/layout.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ContentWrapper } from "@/components/content-wrapper";
2
+ import { Logo } from "@/components/logo";
3
+ import { PropsWithChildren } from "react";
4
+
5
+ export default function Layout(props: PropsWithChildren) {
6
+ return (
7
+ <div>
8
+ <header className="bg-primary text-white">
9
+ <ContentWrapper className="flex items-center justify-center">
10
+ <Logo />
11
+ </ContentWrapper>
12
+ </header>
13
+ <ContentWrapper>{props.children}</ContentWrapper>
14
+ </div>
15
+ );
16
+ }
app/(storefront)/components/feature-banner.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const FeatureBanner = (props: {
2
+ heading: string;
3
+ subheading: string;
4
+ icon: React.ReactNode;
5
+ }) => {
6
+ return (
7
+ <div className="p-6 bg-secondary border border-border rounded-md flex flex-col gap-3">
8
+ <div>{props.icon}</div>
9
+ <div>
10
+ <p className="font-semibold text-xl">{props.heading}</p>
11
+ <p>{props.subheading}</p>
12
+ </div>
13
+ </div>
14
+ );
15
+ };
app/account/buying/purchases/components/columns.tsx ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { Button } from "@/components/ui/button";
3
+ import { StatusLabel } from "@/components/ui/status-label";
4
+ import { currencyFormatter } from "@/lib/currency";
5
+ import { BuyersOrderTable, CheckoutItem } from "@/lib/types";
6
+ import { convertSecondsToDate, formatOrderNumber } from "@/lib/utils";
7
+ import { ColumnDef } from "@tanstack/react-table";
8
+ import { formatRelative } from "date-fns";
9
+ import { ArrowUpDown } from "lucide-react";
10
+
11
+ export const columns: ColumnDef<BuyersOrderTable>[] = [
12
+ {
13
+ accessorKey: "id",
14
+ header: ({ column }) => {
15
+ return (
16
+ <Button
17
+ variant="ghost"
18
+ onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
19
+ >
20
+ Order Number
21
+ <ArrowUpDown className="ml-2 h-4 w-4" />
22
+ </Button>
23
+ );
24
+ },
25
+ cell: ({ row }) => {
26
+ const id = row.getValue("id");
27
+ return <p className="px-4">{formatOrderNumber(Number(id))}</p>;
28
+ },
29
+ },
30
+ {
31
+ accessorKey: "sellerName",
32
+ header: "Seller",
33
+ },
34
+ {
35
+ accessorKey: "total",
36
+ header: "Total",
37
+ cell: ({ row }) => currencyFormatter(Number(row.getValue("total"))),
38
+ },
39
+ {
40
+ accessorKey: "items",
41
+ header: "Items",
42
+ cell: ({ row }) => {
43
+ const items = JSON.parse(row.getValue("items")) as CheckoutItem[];
44
+ const total = items.reduce((acc, item) => acc + Number(item.qty), 0);
45
+ return (
46
+ <p>
47
+ {total} {`${total > 1 ? "items" : "item"}`}
48
+ </p>
49
+ );
50
+ },
51
+ },
52
+ {
53
+ accessorKey: "stripePaymentIntentStatus",
54
+ header: "Payment Status",
55
+ cell: ({ row }) => {
56
+ const status = row.getValue("stripePaymentIntentStatus") as string;
57
+ return (
58
+ <StatusLabel status={status === "succeeded" ? "success" : "error"}>
59
+ {status}
60
+ </StatusLabel>
61
+ );
62
+ },
63
+ },
64
+ {
65
+ accessorKey: "createdAt",
66
+ header: "Date ordered",
67
+ cell: ({ row }) => {
68
+ const createdSeconds = parseFloat(row.getValue("createdAt"));
69
+ if (!createdSeconds) return;
70
+ const relativeDate = formatRelative(
71
+ convertSecondsToDate(createdSeconds),
72
+ new Date()
73
+ );
74
+ return relativeDate[0].toUpperCase() + relativeDate.slice(1);
75
+ },
76
+ },
77
+ ];
app/account/buying/purchases/components/data-table.tsx ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import {
4
+ ColumnDef,
5
+ ColumnFiltersState,
6
+ SortingState,
7
+ VisibilityState,
8
+ flexRender,
9
+ getCoreRowModel,
10
+ getFilteredRowModel,
11
+ getPaginationRowModel,
12
+ getSortedRowModel,
13
+ useReactTable,
14
+ } from "@tanstack/react-table";
15
+
16
+ import {
17
+ Table,
18
+ TableBody,
19
+ TableCell,
20
+ TableHead,
21
+ TableHeader,
22
+ TableRow,
23
+ } from "@/components/ui/table";
24
+ import { Button } from "@/components/ui/button";
25
+ import {
26
+ DropdownMenu,
27
+ DropdownMenuCheckboxItem,
28
+ DropdownMenuContent,
29
+ DropdownMenuTrigger,
30
+ } from "@/components/ui/dropdown-menu";
31
+ import { useState } from "react";
32
+ import { Input } from "@/components/ui/input";
33
+ import { Settings2 } from "lucide-react";
34
+
35
+ interface DataTableProps<TData, TValue> {
36
+ columns: ColumnDef<TData, TValue>[];
37
+ data: TData[];
38
+ }
39
+
40
+ export function DataTable<TData, TValue>({
41
+ columns,
42
+ data,
43
+ }: DataTableProps<TData, TValue>) {
44
+ const [sorting, setSorting] = useState<SortingState>([]);
45
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
46
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
47
+
48
+ const table = useReactTable({
49
+ data,
50
+ columns,
51
+ getCoreRowModel: getCoreRowModel(),
52
+ getPaginationRowModel: getPaginationRowModel(),
53
+ onColumnVisibilityChange: setColumnVisibility,
54
+ onSortingChange: setSorting,
55
+ getSortedRowModel: getSortedRowModel(),
56
+ onColumnFiltersChange: setColumnFilters,
57
+ getFilteredRowModel: getFilteredRowModel(),
58
+ state: {
59
+ sorting,
60
+ columnFilters,
61
+ columnVisibility,
62
+ },
63
+ });
64
+
65
+ return (
66
+ <div>
67
+ <div className="flex items-center justify-start gap-2">
68
+ <DropdownMenu>
69
+ <DropdownMenuTrigger asChild>
70
+ <Button variant="outline" className="ml-auto">
71
+ <Settings2 size={16} />
72
+ </Button>
73
+ </DropdownMenuTrigger>
74
+ <DropdownMenuContent align="end">
75
+ {table
76
+ .getAllColumns()
77
+ .filter((column) => column.getCanHide())
78
+ .map((column) => {
79
+ const headerValue = column.columnDef.header;
80
+ return (
81
+ <DropdownMenuCheckboxItem
82
+ key={column.id}
83
+ className="capitalize"
84
+ checked={column.getIsVisible()}
85
+ onCheckedChange={(value) =>
86
+ column.toggleVisibility(!!value)
87
+ }
88
+ >
89
+ {typeof headerValue === "string"
90
+ ? headerValue
91
+ : column.id === "id"
92
+ ? "Order Number"
93
+ : column.id}
94
+ </DropdownMenuCheckboxItem>
95
+ );
96
+ })}
97
+ </DropdownMenuContent>
98
+ </DropdownMenu>
99
+ <div className="flex items-center py-4 w-full">
100
+ <Input
101
+ placeholder="Search by seller"
102
+ value={
103
+ (table.getColumn("sellerName")?.getFilterValue() as string) ?? ""
104
+ }
105
+ onChange={(event) =>
106
+ table.getColumn("sellerName")?.setFilterValue(event.target.value)
107
+ }
108
+ className="max-w-sm"
109
+ />
110
+ </div>
111
+ </div>
112
+ <div className="rounded-md border">
113
+ <Table>
114
+ <TableHeader className="bg-secondary">
115
+ {table.getHeaderGroups().map((headerGroup) => (
116
+ <TableRow key={headerGroup.id}>
117
+ {headerGroup.headers.map((header) => {
118
+ return (
119
+ <TableHead key={header.id}>
120
+ {header.isPlaceholder
121
+ ? null
122
+ : flexRender(
123
+ header.column.columnDef.header,
124
+ header.getContext()
125
+ )}
126
+ </TableHead>
127
+ );
128
+ })}
129
+ </TableRow>
130
+ ))}
131
+ </TableHeader>
132
+ <TableBody>
133
+ {table.getRowModel().rows?.length ? (
134
+ table.getRowModel().rows.map((row) => (
135
+ <TableRow
136
+ key={row.id}
137
+ data-state={row.getIsSelected() && "selected"}
138
+ >
139
+ {row.getVisibleCells().map((cell) => (
140
+ <TableCell key={cell.id}>
141
+ {flexRender(
142
+ cell.column.columnDef.cell,
143
+ cell.getContext()
144
+ )}
145
+ </TableCell>
146
+ ))}
147
+ </TableRow>
148
+ ))
149
+ ) : (
150
+ <TableRow>
151
+ <TableCell
152
+ colSpan={columns.length}
153
+ className="h-24 text-center"
154
+ >
155
+ No results.
156
+ </TableCell>
157
+ </TableRow>
158
+ )}
159
+ </TableBody>
160
+ </Table>
161
+ </div>
162
+ <div className="flex items-center justify-end space-x-2 py-4">
163
+ <Button
164
+ variant="outline"
165
+ size="sm"
166
+ onClick={() => table.previousPage()}
167
+ disabled={!table.getCanPreviousPage()}
168
+ >
169
+ Previous
170
+ </Button>
171
+ <Button
172
+ variant="outline"
173
+ size="sm"
174
+ onClick={() => table.nextPage()}
175
+ disabled={!table.getCanNextPage()}
176
+ >
177
+ Next
178
+ </Button>
179
+ </div>
180
+ </div>
181
+ );
182
+ }
app/account/buying/purchases/page.tsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BuyersOrderTable } from "@/lib/types";
2
+ import { DataTable } from "./components/data-table";
3
+ import { columns } from "./components/columns";
4
+ import { db } from "@/db/db";
5
+ import { orders, stores } from "@/db/schema";
6
+ import { eq } from "drizzle-orm";
7
+ import { InfoCard } from "@/components/admin/info-card";
8
+ import { Box } from "lucide-react";
9
+ import { HeadingAndSubheading } from "@/components/admin/heading-and-subheading";
10
+ import { currentUser } from "@clerk/nextjs";
11
+
12
+ async function getData(): Promise<BuyersOrderTable[]> {
13
+ const user = await currentUser();
14
+ const userEmailAddress = user?.emailAddresses[0].emailAddress;
15
+ if (!userEmailAddress) return [];
16
+ const storeOrders = await db
17
+ .select({
18
+ id: orders.prettyOrderId,
19
+ sellerName: stores.name,
20
+ items: orders.items,
21
+ total: orders.total,
22
+ stripePaymentIntentStatus: orders.stripePaymentIntentStatus,
23
+ createdAt: orders.createdAt,
24
+ })
25
+ .from(orders)
26
+ .leftJoin(stores, eq(orders.storeId, stores.id))
27
+ .where(eq(orders.email, userEmailAddress));
28
+ return (storeOrders as BuyersOrderTable[]).sort(
29
+ (a, b) => b.createdAt - a.createdAt
30
+ );
31
+ }
32
+
33
+ export default async function OrdersPage() {
34
+ const data = await getData();
35
+
36
+ return (
37
+ <div>
38
+ <div className="mb-6">
39
+ <HeadingAndSubheading
40
+ heading="Your purchases"
41
+ subheading="View and manage purchases you've made"
42
+ />
43
+ </div>
44
+ {data.length > 0 ? (
45
+ <DataTable columns={columns} data={data} />
46
+ ) : (
47
+ <InfoCard
48
+ heading="No orders"
49
+ subheading="You haven't placed any orders yet."
50
+ icon={<Box size={30} />}
51
+ />
52
+ )}
53
+ </div>
54
+ );
55
+ }
app/account/layout.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // app/account/layout.tsx
2
+ export const dynamic = "force-dynamic";
3
+ export const revalidate = 0; // optional, but makes intent clear
4
+
5
+
6
+ import { ContentWrapper } from "@/components/content-wrapper";
7
+ import { Footer } from "@/components/footer";
8
+ import { NavBar } from "@/components/navbar";
9
+ import { Heading } from "@/components/ui/heading";
10
+ import { PropsWithChildren } from "react";
11
+ import { SignedIn, SignedOut, UserButton } from "@clerk/nextjs";
12
+ import SignInWrapper from "@/components/sign-in";
13
+ import { singleLevelNestedRoutes } from "@/lib/routes";
14
+ import { MenuItems, SecondaryMenu } from "@/components/secondary-menu";
15
+ import { Line } from "@/components/line";
16
+ import { PaymentConnectionStatus } from "@/components/admin/payment-connection-status";
17
+
18
+ export default async function AdminLayout({ children }: PropsWithChildren) {
19
+ return (
20
+ <div className="min-h-screen w-full flex flex-col">
21
+ <NavBar showSecondAnnouncementBar={false} />
22
+
23
+ <div>
24
+ <div className="bg-secondary py-2 md:px-6 border-b border-border">
25
+ <ContentWrapper className="flex items-center justify-between">
26
+ <Heading size="h2">Your Account</Heading>
27
+ <div className="p-[1px] bg-gray-400 rounded-full">
28
+ <UserButton afterSignOutUrl={process.env.NEXT_PUBLIC_APP_URL} />
29
+ </div>
30
+ </ContentWrapper>
31
+ </div>
32
+ <div>
33
+ <ContentWrapper className="w-full py-2 flex items-center justify-between">
34
+ <SecondaryMenu menuItems={menuItems} />
35
+ <PaymentConnectionStatus />
36
+ </ContentWrapper>
37
+ </div>
38
+ <Line />
39
+ </div>
40
+
41
+ <ContentWrapper className="w-full flex items-start flex-col flex-1 mb-8">
42
+ <SignedIn>
43
+ <div className="w-full">{children}</div>
44
+ </SignedIn>
45
+ <SignedOut>
46
+ <SignInWrapper />
47
+ </SignedOut>
48
+ </ContentWrapper>
49
+
50
+ <Footer />
51
+ </div>
52
+ );
53
+ }
54
+
55
+ const menuItems: MenuItems = [
56
+ {
57
+ name: "Profile",
58
+ href: singleLevelNestedRoutes.account.profile,
59
+ group: "selling",
60
+ },
61
+ {
62
+ name: "Products",
63
+ href: singleLevelNestedRoutes.account.products,
64
+ group: "selling",
65
+ },
66
+ {
67
+ name: "Orders",
68
+ href: singleLevelNestedRoutes.account.orders,
69
+ group: "selling",
70
+ },
71
+ {
72
+ name: "Payments",
73
+ href: singleLevelNestedRoutes.account.payments,
74
+ group: "selling",
75
+ },
76
+ {
77
+ name: "Your purchases",
78
+ href: singleLevelNestedRoutes.account["your-purchases"],
79
+ group: "buying",
80
+ },
81
+ ];
app/account/page.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HeadingAndSubheading } from "@/components/admin/heading-and-subheading";
2
+
3
+ export default function Account() {
4
+ return (
5
+ <>
6
+ <HeadingAndSubheading
7
+ heading="Account"
8
+ subheading="Manage your account"
9
+ />
10
+ </>
11
+ );
12
+ }
app/account/selling/(orders)/abandoned-carts/components/columns.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { formatRelative } from "date-fns";
3
+ import { currencyFormatter } from "@/lib/currency";
4
+ import { convertSecondsToDate } from "@/lib/utils";
5
+ import { ColumnDef } from "@tanstack/react-table";
6
+
7
+ // This type is used to define the shape of our data.
8
+ // You can use a Zod schema here if you want.
9
+ export type Payment = {
10
+ id: string;
11
+ amount: number;
12
+ created: number;
13
+ cartId: number;
14
+ };
15
+
16
+ export const columns: ColumnDef<Payment>[] = [
17
+ {
18
+ accessorKey: "id",
19
+ header: "Checkout ID",
20
+ },
21
+ {
22
+ accessorKey: "amount",
23
+ header: "Amount",
24
+ cell: ({ row }) => {
25
+ const amount = parseFloat(row.getValue("amount"));
26
+ return currencyFormatter(amount);
27
+ },
28
+ },
29
+ {
30
+ accessorKey: "created",
31
+ header: "Created At",
32
+ cell: ({ row }) => {
33
+ const createdSeconds = parseFloat(row.getValue("created"));
34
+ const relativeDate = formatRelative(
35
+ convertSecondsToDate(createdSeconds),
36
+ new Date()
37
+ );
38
+ return relativeDate[0].toUpperCase() + relativeDate.slice(1);
39
+ },
40
+ },
41
+ {
42
+ accessorKey: "cartId",
43
+ header: "Cart ID",
44
+ },
45
+ ];
app/account/selling/(orders)/abandoned-carts/components/data-table.tsx ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import {
4
+ ColumnDef,
5
+ flexRender,
6
+ getCoreRowModel,
7
+ useReactTable,
8
+ } from "@tanstack/react-table";
9
+
10
+ import {
11
+ Table,
12
+ TableBody,
13
+ TableCell,
14
+ TableHead,
15
+ TableHeader,
16
+ TableRow,
17
+ } from "@/components/ui/table";
18
+ import { Button } from "@/components/ui/button";
19
+ import { useState } from "react";
20
+ import { Payment } from "./columns";
21
+ import { Loader2 } from "lucide-react";
22
+
23
+ interface DataTableProps<TData, TValue> {
24
+ columns: ColumnDef<TData, TValue>[];
25
+ data: TData[];
26
+ getPaymentIntents: ({
27
+ startingAfterPaymentId,
28
+ beforePaymentId,
29
+ }: {
30
+ startingAfterPaymentId?: string | undefined;
31
+ beforePaymentId?: string | undefined;
32
+ }) => Promise<{ paymentIntents: any; hasMore: any }>;
33
+ lastPaymentIntentInInitialFetchId: string;
34
+ initialFetchHasNextPage: boolean;
35
+ }
36
+
37
+ export function DataTable<TData, TValue>(props: DataTableProps<TData, TValue>) {
38
+ const [newData, setNewData] = useState<Payment[]>(props.data as Payment[]);
39
+ const [hasNextPage, setHasNextPage] = useState(props.initialFetchHasNextPage);
40
+ const [isLoadingNewPage, setIsLoadingNewPage] = useState({
41
+ previous: false,
42
+ next: false,
43
+ });
44
+ const [pageIndex, setPageIndex] = useState(1);
45
+
46
+ const table = useReactTable({
47
+ data: newData as TData[],
48
+ columns: props.columns,
49
+ getCoreRowModel: getCoreRowModel(),
50
+ manualPagination: true,
51
+ });
52
+
53
+ return (
54
+ <div>
55
+ <div className="rounded-md border">
56
+ <Table>
57
+ <TableHeader className="bg-secondary">
58
+ {table.getHeaderGroups().map((headerGroup) => (
59
+ <TableRow key={headerGroup.id}>
60
+ {headerGroup.headers.map((header) => {
61
+ return (
62
+ <TableHead key={header.id}>
63
+ {header.isPlaceholder
64
+ ? null
65
+ : flexRender(
66
+ header.column.columnDef.header,
67
+ header.getContext()
68
+ )}
69
+ </TableHead>
70
+ );
71
+ })}
72
+ </TableRow>
73
+ ))}
74
+ </TableHeader>
75
+ <TableBody>
76
+ {table.getRowModel().rows?.length ? (
77
+ table.getRowModel().rows.map((row) => (
78
+ <TableRow
79
+ key={row.id}
80
+ data-state={row.getIsSelected() && "selected"}
81
+ >
82
+ {row.getVisibleCells().map((cell) => (
83
+ <TableCell key={cell.id}>
84
+ {flexRender(
85
+ cell.column.columnDef.cell,
86
+ cell.getContext()
87
+ )}
88
+ </TableCell>
89
+ ))}
90
+ </TableRow>
91
+ ))
92
+ ) : (
93
+ <TableRow>
94
+ <TableCell
95
+ colSpan={props.columns.length}
96
+ className="h-24 text-center"
97
+ >
98
+ No results.
99
+ </TableCell>
100
+ </TableRow>
101
+ )}
102
+ </TableBody>
103
+ </Table>
104
+ </div>
105
+ <div className="flex items-center justify-between gap-2">
106
+ <p className="text-sm text-muted-foreground">
107
+ Page {pageIndex <= 0 ? 1 : pageIndex} of{" "}
108
+ {hasNextPage ? "many" : pageIndex}
109
+ </p>
110
+ <div className="flex items-center justify-end space-x-2 py-4">
111
+ <Button
112
+ variant="outline"
113
+ size="sm"
114
+ onClick={async () => {
115
+ setIsLoadingNewPage({ previous: true, next: false });
116
+ const currentNewestPaymentIntent = newData.at(0);
117
+ const { paymentIntents } = await props.getPaymentIntents({
118
+ beforePaymentId: currentNewestPaymentIntent?.id,
119
+ });
120
+ setNewData(paymentIntents);
121
+ setIsLoadingNewPage({ previous: false, next: false });
122
+ setPageIndex((prev) => prev - 1);
123
+ }}
124
+ disabled={
125
+ props.lastPaymentIntentInInitialFetchId === newData.at(-1)?.id ||
126
+ isLoadingNewPage.previous ||
127
+ isLoadingNewPage.next
128
+ }
129
+ className="flex items-center justify-center gap-1"
130
+ >
131
+ <p>Previous</p>
132
+ {isLoadingNewPage.previous && (
133
+ <Loader2 size={14} className="animate-spin" />
134
+ )}
135
+ </Button>
136
+ <Button
137
+ variant="outline"
138
+ size="sm"
139
+ onClick={async () => {
140
+ setIsLoadingNewPage({ previous: false, next: true });
141
+ const currentOldestPaymentIntent = newData.at(-1);
142
+ const { paymentIntents, hasMore } = await props.getPaymentIntents(
143
+ {
144
+ startingAfterPaymentId: currentOldestPaymentIntent?.id,
145
+ }
146
+ );
147
+ setHasNextPage(hasMore);
148
+ setNewData(paymentIntents);
149
+ setIsLoadingNewPage({ previous: false, next: false });
150
+ setPageIndex((prev) => prev + 1);
151
+ }}
152
+ disabled={
153
+ !hasNextPage || isLoadingNewPage.previous || isLoadingNewPage.next
154
+ }
155
+ className="flex items-center justify-center gap-1"
156
+ >
157
+ <p>Next</p>
158
+ {isLoadingNewPage.next && (
159
+ <Loader2 size={14} className="animate-spin" />
160
+ )}
161
+ </Button>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ );
166
+ }
app/account/selling/(orders)/abandoned-carts/page.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getPaymentIntents } from "@/server-actions/stripe/payment";
2
+ import { Payment, columns } from "./components/columns";
3
+ import { DataTable } from "./components/data-table";
4
+ import { InfoCard } from "@/components/admin/info-card";
5
+ import { ShoppingCart } from "lucide-react";
6
+ import { Heading } from "@/components/ui/heading";
7
+
8
+ async function getData(): Promise<{
9
+ paymentIntents: Payment[];
10
+ hasMore: boolean;
11
+ }> {
12
+ return await getPaymentIntents({});
13
+ }
14
+
15
+ export default async function OrdersPage() {
16
+ const data = await getData();
17
+ const lastPaymentIntentInInitialFetch = data.paymentIntents.at(-1) as Payment;
18
+
19
+ return (
20
+ <div>
21
+ <div className="mb-4">
22
+ <Heading size="h4">Abondoned carts</Heading>
23
+ </div>
24
+ {!data.paymentIntents.length ? (
25
+ <InfoCard
26
+ heading="You don't have any abandoned carts yet"
27
+ subheading="Check back later once shoppers have started checking out"
28
+ icon={<ShoppingCart size={36} className="text-gray-600" />}
29
+ />
30
+ ) : (
31
+ <div className="w-full">
32
+ <DataTable
33
+ columns={columns}
34
+ data={data.paymentIntents}
35
+ initialFetchHasNextPage={data.hasMore}
36
+ lastPaymentIntentInInitialFetchId={
37
+ lastPaymentIntentInInitialFetch.id
38
+ }
39
+ getPaymentIntents={getPaymentIntents}
40
+ />
41
+ </div>
42
+ )}
43
+ </div>
44
+ );
45
+ }
app/account/selling/(orders)/layout.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HeadingAndSubheading } from "@/components/admin/heading-and-subheading";
2
+ import { Button } from "@/components/ui/button";
3
+ import { singleLevelNestedRoutes } from "@/lib/routes";
4
+ import Link from "next/link";
5
+ import { PropsWithChildren } from "react";
6
+
7
+ export default function OrdersLayout(props: PropsWithChildren) {
8
+ return (
9
+ <>
10
+ <HeadingAndSubheading
11
+ heading="Orders"
12
+ subheading="View and manage your orders"
13
+ />
14
+ <div className="flex items-center justify-start gap-4 -mt-4">
15
+ <Link href={singleLevelNestedRoutes.account.orders}>
16
+ <Button variant="link" className="p-0">
17
+ All Orders
18
+ </Button>
19
+ </Link>
20
+ <Link href={singleLevelNestedRoutes.account["abandoned-carts"]}>
21
+ <Button variant="link" className="p-0">
22
+ Abandoned Carts
23
+ </Button>
24
+ </Link>
25
+ </div>
26
+ {props.children}
27
+ </>
28
+ );
29
+ }
app/account/selling/(orders)/order/[orderId]/page.tsx ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HeadingAndSubheading } from "@/components/admin/heading-and-subheading";
2
+ import { OrderLineItems } from "@/components/order-line-items";
3
+ import { Heading } from "@/components/ui/heading";
4
+ import { StatusLabel } from "@/components/ui/status-label";
5
+ import { db } from "@/db/db";
6
+ import { addresses, orders } from "@/db/schema";
7
+ import { currencyFormatter } from "@/lib/currency";
8
+ import { CheckoutItem } from "@/lib/types";
9
+ import {
10
+ convertDateToRelativeTime,
11
+ convertSecondsToDate,
12
+ formatOrderNumber,
13
+ removeOrderNumberFormatting,
14
+ } from "@/lib/utils";
15
+ import { getDetailsOfProductsOrdered } from "@/server-actions/orders";
16
+ import { getStoreId } from "@/server-actions/store-details";
17
+ import { and, eq } from "drizzle-orm";
18
+
19
+ export default async function OrderDetailPage(context: {
20
+ params: { orderId: string };
21
+ }) {
22
+ const storeId = await getStoreId();
23
+ if (!storeId || isNaN(Number(context.params.orderId))) {
24
+ throw new Error("Store ID not found");
25
+ }
26
+ const orderDetails = await db
27
+ .select({
28
+ order: orders,
29
+ address: addresses,
30
+ })
31
+ .from(orders)
32
+ .leftJoin(addresses, eq(orders.addressId, addresses.id))
33
+ .where(
34
+ and(
35
+ eq(
36
+ orders.prettyOrderId,
37
+ removeOrderNumberFormatting(Number(context.params.orderId))
38
+ ),
39
+ eq(orders.storeId, Number(storeId))
40
+ )
41
+ );
42
+
43
+ const record = orderDetails[0];
44
+ const checkoutItems = JSON.parse(
45
+ (record.order.items as string) ?? "[]"
46
+ ) as CheckoutItem[];
47
+ const products = await getDetailsOfProductsOrdered(checkoutItems);
48
+ const totalItems = checkoutItems.reduce((acc, curr) => acc + curr.qty, 0);
49
+
50
+ return (
51
+ <div className="flex flex-col gap-4">
52
+ <div className="bg-secondary border border-border p-6 rounded-md">
53
+ <HeadingAndSubheading
54
+ heading={`Order ${formatOrderNumber(
55
+ record.order?.prettyOrderId ?? 0
56
+ )}`}
57
+ subheading={`${
58
+ !record.order?.createdAt
59
+ ? ""
60
+ : convertDateToRelativeTime(
61
+ convertSecondsToDate(Number(record.order?.createdAt))
62
+ )
63
+ }`}
64
+ />
65
+ </div>
66
+ <div className="md:grid grid-cols-12 flex flex-col gap-4">
67
+ <div className="bg-secondary border border-border rounded-md p-4 py-6 md:col-span-8 h-fit">
68
+ <Heading size="h4">
69
+ <span className="px-2">Items ({totalItems})</span>
70
+ </Heading>
71
+ <OrderLineItems products={products} checkoutItems={checkoutItems} />
72
+ <div className="mt-4 flex items-center justify-start gap-2 px-2">
73
+ <Heading size="h4">
74
+ Total paid:{" "}
75
+ {isNaN(Number(record.order.total))
76
+ ? ""
77
+ : currencyFormatter(Number(record.order.total))}
78
+ </Heading>
79
+ </div>
80
+ </div>
81
+ <div className="md:col-span-4 flex flex-col gap-4">
82
+ <div className="bg-secondary border border-border rounded-md p-4">
83
+ <Heading size="h4">Customer</Heading>
84
+ <div className="mt-2">
85
+ <p>{record.order.name}</p>
86
+ <p>{record.order.email}</p>
87
+ </div>
88
+ </div>
89
+ <div className="bg-secondary border border-border rounded-md p-4">
90
+ <Heading size="h4">Shipping Details</Heading>
91
+ <div className="mt-2">
92
+ <p>{record.address?.line1}</p>
93
+ <p>{record.address?.line2}</p>
94
+ <p>
95
+ {record.address?.city}, {record.address?.postal_code}
96
+ </p>
97
+ <p>
98
+ {record.address?.state}, {record.address?.country}
99
+ </p>
100
+ </div>
101
+ </div>
102
+ <div className="bg-secondary border border-border rounded-md p-4">
103
+ <Heading size="h4">Payment</Heading>
104
+ <div className="mt-2">
105
+ <StatusLabel
106
+ status={
107
+ record.order?.stripePaymentIntentStatus === "succeeded"
108
+ ? "success"
109
+ : "error"
110
+ }
111
+ >
112
+ {record.order?.stripePaymentIntentStatus}
113
+ </StatusLabel>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ }
app/account/selling/(orders)/order/error.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { InfoCard } from "@/components/admin/info-card";
4
+ import { Button } from "@/components/ui/button";
5
+ import { singleLevelNestedRoutes } from "@/lib/routes";
6
+ import { Box } from "lucide-react";
7
+ import Link from "next/link";
8
+
9
+ export default function Error() {
10
+ return (
11
+ <InfoCard
12
+ heading="Sorry, an error occured loading this order."
13
+ subheading="This order either does not exist or cannot be retrieved at this time."
14
+ icon={<Box size={32} />}
15
+ button={
16
+ <Link href={singleLevelNestedRoutes.account.orders}>
17
+ <Button>View all orders</Button>
18
+ </Link>
19
+ }
20
+ />
21
+ );
22
+ }
app/account/selling/(orders)/orders/components/columns.tsx ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { Button } from "@/components/ui/button";
3
+ import { StatusLabel } from "@/components/ui/status-label";
4
+ import { currencyFormatter } from "@/lib/currency";
5
+ import { secondLevelNestedRoutes } from "@/lib/routes";
6
+ import { CheckoutItem } from "@/lib/types";
7
+
8
+ import { OrdersTable } from "@/lib/types";
9
+ import { convertSecondsToDate, formatOrderNumber } from "@/lib/utils";
10
+ import { ColumnDef } from "@tanstack/react-table";
11
+ import { formatRelative } from "date-fns";
12
+ import { ArrowUpDown } from "lucide-react";
13
+ import Link from "next/link";
14
+
15
+ export const columns: ColumnDef<OrdersTable>[] = [
16
+ {
17
+ accessorKey: "id",
18
+ header: ({ column }) => {
19
+ return (
20
+ <Button
21
+ variant="ghost"
22
+ onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
23
+ >
24
+ Order Number
25
+ <ArrowUpDown className="ml-2 h-4 w-4" />
26
+ </Button>
27
+ );
28
+ },
29
+ cell: ({ row }) => {
30
+ const id = row.getValue("id");
31
+ return (
32
+ <Link
33
+ href={`${secondLevelNestedRoutes.order.base}/${formatOrderNumber(
34
+ Number(id) as number
35
+ ).slice(1)}`}
36
+ >
37
+ <Button variant="link" className="m-0 h-fit py-0 font-semibold">
38
+ {formatOrderNumber(Number(id))}
39
+ </Button>
40
+ </Link>
41
+ );
42
+ },
43
+ },
44
+ {
45
+ accessorKey: "name",
46
+ header: "Customer",
47
+ },
48
+ {
49
+ accessorKey: "total",
50
+ header: "Total",
51
+ cell: ({ row }) => currencyFormatter(Number(row.getValue("total"))),
52
+ },
53
+ {
54
+ accessorKey: "items",
55
+ header: "Items",
56
+ cell: ({ row }) => {
57
+ const items = JSON.parse(row.getValue("items")) as CheckoutItem[];
58
+ const total = items.reduce((acc, item) => acc + Number(item.qty), 0);
59
+ return (
60
+ <p>
61
+ {total} {`${total > 1 ? "items" : "item"}`}
62
+ </p>
63
+ );
64
+ },
65
+ },
66
+ {
67
+ accessorKey: "stripePaymentIntentStatus",
68
+ header: "Payment Status",
69
+ cell: ({ row }) => {
70
+ const status = row.getValue("stripePaymentIntentStatus") as string;
71
+ return (
72
+ <StatusLabel status={status === "succeeded" ? "success" : "error"}>
73
+ {status}
74
+ </StatusLabel>
75
+ );
76
+ },
77
+ },
78
+ {
79
+ accessorKey: "createdAt",
80
+ header: "Date ordered",
81
+ cell: ({ row }) => {
82
+ const createdSeconds = parseFloat(row.getValue("createdAt"));
83
+ if (!createdSeconds) return;
84
+ const relativeDate = formatRelative(
85
+ convertSecondsToDate(createdSeconds),
86
+ new Date()
87
+ );
88
+ return relativeDate[0].toUpperCase() + relativeDate.slice(1);
89
+ },
90
+ },
91
+ ];
app/account/selling/(orders)/orders/components/data-table.tsx ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import {
4
+ ColumnDef,
5
+ ColumnFiltersState,
6
+ SortingState,
7
+ VisibilityState,
8
+ flexRender,
9
+ getCoreRowModel,
10
+ getFilteredRowModel,
11
+ getPaginationRowModel,
12
+ getSortedRowModel,
13
+ useReactTable,
14
+ } from "@tanstack/react-table";
15
+
16
+ import {
17
+ Table,
18
+ TableBody,
19
+ TableCell,
20
+ TableHead,
21
+ TableHeader,
22
+ TableRow,
23
+ } from "@/components/ui/table";
24
+ import { Button } from "@/components/ui/button";
25
+ import {
26
+ DropdownMenu,
27
+ DropdownMenuCheckboxItem,
28
+ DropdownMenuContent,
29
+ DropdownMenuTrigger,
30
+ } from "@/components/ui/dropdown-menu";
31
+ import { useState } from "react";
32
+ import { Input } from "@/components/ui/input";
33
+ import { Settings2 } from "lucide-react";
34
+
35
+ interface DataTableProps<TData, TValue> {
36
+ columns: ColumnDef<TData, TValue>[];
37
+ data: TData[];
38
+ }
39
+
40
+ export function DataTable<TData, TValue>({
41
+ columns,
42
+ data,
43
+ }: DataTableProps<TData, TValue>) {
44
+ const [sorting, setSorting] = useState<SortingState>([]);
45
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
46
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
47
+
48
+ const table = useReactTable({
49
+ data,
50
+ columns,
51
+ getCoreRowModel: getCoreRowModel(),
52
+ getPaginationRowModel: getPaginationRowModel(),
53
+ onColumnVisibilityChange: setColumnVisibility,
54
+ onSortingChange: setSorting,
55
+ getSortedRowModel: getSortedRowModel(),
56
+ onColumnFiltersChange: setColumnFilters,
57
+ getFilteredRowModel: getFilteredRowModel(),
58
+ state: {
59
+ sorting,
60
+ columnFilters,
61
+ columnVisibility,
62
+ },
63
+ });
64
+
65
+ return (
66
+ <div>
67
+ <div className="flex items-center justify-start gap-2">
68
+ <DropdownMenu>
69
+ <DropdownMenuTrigger asChild>
70
+ <Button variant="outline" className="ml-auto">
71
+ <Settings2 size={16} />
72
+ </Button>
73
+ </DropdownMenuTrigger>
74
+ <DropdownMenuContent align="end">
75
+ {table
76
+ .getAllColumns()
77
+ .filter((column) => column.getCanHide())
78
+ .map((column) => {
79
+ const headerValue = column.columnDef.header;
80
+ return (
81
+ <DropdownMenuCheckboxItem
82
+ key={column.id}
83
+ className="capitalize"
84
+ checked={column.getIsVisible()}
85
+ onCheckedChange={(value) =>
86
+ column.toggleVisibility(!!value)
87
+ }
88
+ >
89
+ {typeof headerValue === "string"
90
+ ? headerValue
91
+ : column.id === "id"
92
+ ? "Order Number"
93
+ : column.id}
94
+ </DropdownMenuCheckboxItem>
95
+ );
96
+ })}
97
+ </DropdownMenuContent>
98
+ </DropdownMenu>
99
+ <div className="flex items-center py-4 w-full">
100
+ <Input
101
+ placeholder="Filter customers"
102
+ value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
103
+ onChange={(event) =>
104
+ table.getColumn("name")?.setFilterValue(event.target.value)
105
+ }
106
+ className="max-w-sm"
107
+ />
108
+ </div>
109
+ </div>
110
+ <div className="rounded-md border">
111
+ <Table>
112
+ <TableHeader className="bg-secondary">
113
+ {table.getHeaderGroups().map((headerGroup) => (
114
+ <TableRow key={headerGroup.id}>
115
+ {headerGroup.headers.map((header) => {
116
+ return (
117
+ <TableHead key={header.id}>
118
+ {header.isPlaceholder
119
+ ? null
120
+ : flexRender(
121
+ header.column.columnDef.header,
122
+ header.getContext()
123
+ )}
124
+ </TableHead>
125
+ );
126
+ })}
127
+ </TableRow>
128
+ ))}
129
+ </TableHeader>
130
+ <TableBody>
131
+ {table.getRowModel().rows?.length ? (
132
+ table.getRowModel().rows.map((row) => (
133
+ <TableRow
134
+ key={row.id}
135
+ data-state={row.getIsSelected() && "selected"}
136
+ >
137
+ {row.getVisibleCells().map((cell) => (
138
+ <TableCell key={cell.id}>
139
+ {flexRender(
140
+ cell.column.columnDef.cell,
141
+ cell.getContext()
142
+ )}
143
+ </TableCell>
144
+ ))}
145
+ </TableRow>
146
+ ))
147
+ ) : (
148
+ <TableRow>
149
+ <TableCell
150
+ colSpan={columns.length}
151
+ className="h-24 text-center"
152
+ >
153
+ No results.
154
+ </TableCell>
155
+ </TableRow>
156
+ )}
157
+ </TableBody>
158
+ </Table>
159
+ </div>
160
+ <div className="flex items-center justify-end space-x-2 py-4">
161
+ <Button
162
+ variant="outline"
163
+ size="sm"
164
+ onClick={() => table.previousPage()}
165
+ disabled={!table.getCanPreviousPage()}
166
+ >
167
+ Previous
168
+ </Button>
169
+ <Button
170
+ variant="outline"
171
+ size="sm"
172
+ onClick={() => table.nextPage()}
173
+ disabled={!table.getCanNextPage()}
174
+ >
175
+ Next
176
+ </Button>
177
+ </div>
178
+ </div>
179
+ );
180
+ }
app/account/selling/(orders)/orders/page.tsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { OrdersTable } from "@/lib/types";
2
+ import { columns } from "./components/columns";
3
+ import { DataTable } from "./components/data-table";
4
+ import { db } from "@/db/db";
5
+ import { orders } from "@/db/schema";
6
+ import { eq } from "drizzle-orm";
7
+ import { getStoreId } from "@/server-actions/store-details";
8
+ import { InfoCard } from "@/components/admin/info-card";
9
+ import { Box } from "lucide-react";
10
+ import { Heading } from "@/components/ui/heading";
11
+
12
+ async function getData(): Promise<OrdersTable[]> {
13
+ const storeId = await getStoreId();
14
+ if (isNaN(Number(storeId))) return [];
15
+ const storeOrders = await db
16
+ .select({
17
+ id: orders.prettyOrderId,
18
+ name: orders.name,
19
+ items: orders.items,
20
+ total: orders.total,
21
+ stripePaymentIntentStatus: orders.stripePaymentIntentStatus,
22
+ createdAt: orders.createdAt,
23
+ })
24
+ .from(orders)
25
+ .where(eq(orders.storeId, Number(storeId)));
26
+ return (storeOrders as OrdersTable[]).sort(
27
+ (a, b) => b.createdAt - a.createdAt
28
+ );
29
+ }
30
+
31
+ export default async function OrdersPage() {
32
+ const data = await getData();
33
+
34
+ return (
35
+ <div>
36
+ <Heading size="h4">All orders</Heading>
37
+ {data.length > 0 ? (
38
+ <DataTable columns={columns} data={data} />
39
+ ) : (
40
+ <InfoCard
41
+ heading="No orders"
42
+ subheading="You don't have any orders yet."
43
+ icon={<Box size={30} />}
44
+ />
45
+ )}
46
+ </div>
47
+ );
48
+ }
app/account/selling/layout.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CreateNewStore } from "@/components/admin/create-new-store";
2
+ import { createStore } from "@/server-actions/store";
3
+ import { currentUser } from "@clerk/nextjs";
4
+ import { PropsWithChildren } from "react";
5
+
6
+ export default async function SellerLayout(props: PropsWithChildren) {
7
+ const user = await currentUser();
8
+
9
+ return (
10
+ <>
11
+ {user?.privateMetadata?.storeId ? (
12
+ <div className="flex flex-col gap-4">{props.children}</div>
13
+ ) : (
14
+ <CreateNewStore createStore={createStore} />
15
+ )}
16
+ </>
17
+ );
18
+ }
app/account/selling/payments/components/create-connected-account.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { toast } from "@/components/ui/use-toast";
5
+ import { Loader2, Lock } from "lucide-react";
6
+ import { useState } from "react";
7
+
8
+ export const CreateConnectedAccount = (props: {
9
+ createAccountLink: () => Promise<string | undefined>;
10
+ }) => {
11
+ const [isLoading, setIsLoading] = useState(false);
12
+
13
+ return (
14
+ <form>
15
+ <Button
16
+ disabled={isLoading}
17
+ size="sm"
18
+ className="flex gap-2"
19
+ onClick={() => {
20
+ setIsLoading(true);
21
+ props
22
+ .createAccountLink()
23
+ .then((url) => {
24
+ if (url === undefined) {
25
+ throw new Error("No url returned from Stripe");
26
+ }
27
+ window.location.href = url;
28
+ setIsLoading(false);
29
+ })
30
+ .catch((err) => {
31
+ setIsLoading(false);
32
+ console.log("Error occured creating Stripe connect account", err);
33
+ toast({
34
+ title: "Sorry, an error occurred",
35
+ description:
36
+ "An error occured creating your Stripe connect account. Please try again later.",
37
+ });
38
+ });
39
+ }}
40
+ >
41
+ {isLoading ? <Loader2 className="animate-spin" /> : <Lock size={16} />}
42
+ <span>Connect with Stripe</span>
43
+ </Button>
44
+ </form>
45
+ );
46
+ };
app/account/selling/payments/loading.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LoadingSkeleton } from "@/components/ui/loading-skeleton";
2
+
3
+ export default function Loading() {
4
+ return (
5
+ <div className="flex flex-col gap-2">
6
+ <LoadingSkeleton.HeadingAndSubheading />
7
+ <LoadingSkeleton className="w-full h-12 mt-2" />
8
+ <LoadingSkeleton className="w-full h-36" />
9
+ </div>
10
+ );
11
+ }