Jack commited on
Commit ·
1067b6f
0
Parent(s):
Initial release
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .env +10 -0
- .gitattributes +35 -0
- Dockerfile +64 -0
- LICENSE +21 -0
- README.md +9 -0
- README_info.md +28 -0
- app/(storefront)/(main)/cart/components/checkout-button.tsx +43 -0
- app/(storefront)/(main)/cart/layout.tsx +6 -0
- app/(storefront)/(main)/cart/loading.tsx +27 -0
- app/(storefront)/(main)/cart/page.tsx +99 -0
- app/(storefront)/(main)/layout.tsx +25 -0
- app/(storefront)/(main)/loading.tsx +28 -0
- app/(storefront)/(main)/page.tsx +166 -0
- app/(storefront)/(main)/product/[productId]/layout.tsx +10 -0
- app/(storefront)/(main)/product/[productId]/loading.tsx +20 -0
- app/(storefront)/(main)/product/[productId]/page.tsx +114 -0
- app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/layout.tsx +12 -0
- app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/loading.tsx +19 -0
- app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/page.tsx +91 -0
- app/(storefront)/(main)/products/@modal/default.ts +3 -0
- app/(storefront)/(main)/products/layout.tsx +13 -0
- app/(storefront)/(main)/products/loading.tsx +17 -0
- app/(storefront)/(main)/products/page.tsx +82 -0
- app/(storefront)/(main)/quickview/product/[productId]/page.tsx +8 -0
- app/(storefront)/checkout/[storeSlug]/error.tsx +26 -0
- app/(storefront)/checkout/[storeSlug]/order-confirmation/components/verification.tsx +45 -0
- app/(storefront)/checkout/[storeSlug]/order-confirmation/page.tsx +144 -0
- app/(storefront)/checkout/[storeSlug]/page.tsx +108 -0
- app/(storefront)/checkout/components/checkout-form.tsx +148 -0
- app/(storefront)/checkout/components/checkout-wrapper.tsx +142 -0
- app/(storefront)/checkout/components/order-summary-accordion.tsx +28 -0
- app/(storefront)/checkout/layout.tsx +16 -0
- app/(storefront)/components/feature-banner.tsx +15 -0
- app/account/buying/purchases/components/columns.tsx +77 -0
- app/account/buying/purchases/components/data-table.tsx +182 -0
- app/account/buying/purchases/page.tsx +55 -0
- app/account/layout.tsx +81 -0
- app/account/page.tsx +12 -0
- app/account/selling/(orders)/abandoned-carts/components/columns.tsx +45 -0
- app/account/selling/(orders)/abandoned-carts/components/data-table.tsx +166 -0
- app/account/selling/(orders)/abandoned-carts/page.tsx +45 -0
- app/account/selling/(orders)/layout.tsx +29 -0
- app/account/selling/(orders)/order/[orderId]/page.tsx +120 -0
- app/account/selling/(orders)/order/error.tsx +22 -0
- app/account/selling/(orders)/orders/components/columns.tsx +91 -0
- app/account/selling/(orders)/orders/components/data-table.tsx +180 -0
- app/account/selling/(orders)/orders/page.tsx +48 -0
- app/account/selling/layout.tsx +18 -0
- app/account/selling/payments/components/create-connected-account.tsx +46 -0
- 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's Terrific Toys</p>
|
| 115 |
+
<p>
|
| 116 |
+
Top seller of the month! Tim'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's next?</Heading>
|
| 78 |
+
<p>
|
| 79 |
+
Our warehouse team is busy preparing your order. You'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 |
+
}
|