diff --git a/.env b/.env new file mode 100644 index 0000000000000000000000000000000000000000..a2c6d0a27d83e2c8706d15af4adb8eb67e8e109b --- /dev/null +++ b/.env @@ -0,0 +1,10 @@ +# DATABASE_URL="mysql://app:app@db:3306/onestopshop" +DATABASE_URL="mysql://avnadmin:AVNS_Xh3_qhmmzjH035K034U@mysql-2387c7fc-jacksongcsdev-5b75.g.aivencloud.com:23824/defaultdb" + +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_cmVuZXdpbmctZmx5LTYwLmNsZXJrLmFjY291bnRzLmRldiQ +CLERK_SECRET_KEY=sk_test_MGPXL0QrkTNMDWQNLVfv5xCBDhAySngBb5gzjAMAQR + +NEXT_PUBLIC_APP_URL=http://localhost:3000/ + +UPLOADTHING_SECRET="" +UPLOADTHING_APP_ID="" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..a6344aac8c09253b3b630fb776ae94478aa0275b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,35 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..f8d11889a8824a3a1985f5356cfd0eb53af25049 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +# ---------- Base image ---------- +FROM node:20-alpine AS base + +# Needed for some native deps +RUN apk add --no-cache libc6-compat python3 make g++ + +WORKDIR /app + +# ---------- Dependencies (prod) ---------- +FROM base AS deps + +WORKDIR /app + +# Copy only package manifests +COPY package.json package-lock.json ./ + +# Install prod deps (no dev) – use npm install, not npm ci +RUN npm install --omit=dev --legacy-peer-deps --no-audit --no-fund + +# ---------- Build (dev + tooling) ---------- +FROM base AS builder + +WORKDIR /app + +# Copy manifests and install full deps (including dev) +COPY package.json package-lock.json ./ +RUN npm install --legacy-peer-deps --no-audit --no-fund + +# Copy the rest of the source +COPY . . + +# Ensure migrations-folder exists so COPY in the next stage never fails +# Try to generate Drizzle migrations; if it fails, don't break the build +RUN mkdir -p migrations-folder && \ + npx drizzle-kit generate || echo "Skipping drizzle-kit generate step" + +# Build the Next.js app +RUN npm run build + +# ---------- Runtime ---------- +FROM base AS runner + +WORKDIR /app +ENV NODE_ENV=production + +# Copy runtime deps from deps stage +COPY --from=deps /app/node_modules ./node_modules + +# Copy built app + configs +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/styles ./styles +COPY --from=builder /app/next.config.js ./next.config.js +COPY --from=builder /app/tailwind.config.js ./tailwind.config.js +COPY --from=builder /app/postcss.config.js ./postcss.config.js +COPY --from=builder /app/drizzle.config.json ./drizzle.config.json +COPY --from=builder /app/migrations-folder ./migrations-folder + +# ⭐ REQUIRED ⭐ +COPY --from=builder /app/db ./db + +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..9f14b732100ea98cfe841a4efd74297c01cfec6a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 @jackblatch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6f308244341715e42f7ebedcf581f37b980ba597 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +--- +title: WebArena-Amazon +emoji: 🛒 +colorFrom: blue +colorTo: purple +sdk: docker +app_port: 3000 +pinned: false +--- \ No newline at end of file diff --git a/README_info.md b/README_info.md new file mode 100644 index 0000000000000000000000000000000000000000..475b1d2444b51a7a69e94596f2d2822efc573d3a --- /dev/null +++ b/README_info.md @@ -0,0 +1,28 @@ +## OneStopShop Setup Guide + +### 1) Clone the repo +``` +git clone https://github.com/JackSong88/webarena-jbb-magento.git +cd webarena-jbb-magento +``` + +### 2) Create .env file +- Create a [Clerk](https://clerk.com) application for authentication and copy keys into `.env` +``` +DATABASE_URL="mysql://app:app@db:3306/onestopshop" + +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxxxx +CLERK_SECRET_KEY=sk_test_xxxxx + +NEXT_PUBLIC_APP_URL=http://localhost:3000/ + +UPLOADTHING_SECRET="" +UPLOADTHING_APP_ID="" +``` + +### 3) Run the environment +- Start the environment through docker: +``` +docker compose up +``` +- 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`. \ No newline at end of file diff --git a/app/(storefront)/(main)/cart/components/checkout-button.tsx b/app/(storefront)/(main)/cart/components/checkout-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0a4ed78ae2ebb4c2383b68d3f0d4b679568cf065 --- /dev/null +++ b/app/(storefront)/(main)/cart/components/checkout-button.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/use-toast"; +import { routes } from "@/lib/routes"; +import { getStoreSlug } from "@/server-actions/store-details"; +import { Loader2, Lock } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export const CheckoutButton = (props: { storeId: number }) => { + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + return ( + + ); +}; diff --git a/app/(storefront)/(main)/cart/layout.tsx b/app/(storefront)/(main)/cart/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..53eec1ea6a9cc4e2e932a5fca3f2ca229c982ac0 --- /dev/null +++ b/app/(storefront)/(main)/cart/layout.tsx @@ -0,0 +1,6 @@ +import { ContentWrapper } from "@/components/content-wrapper"; +import { PropsWithChildren } from "react"; + +export default function Layout(props: PropsWithChildren) { + return {props.children}; +} diff --git a/app/(storefront)/(main)/cart/loading.tsx b/app/(storefront)/(main)/cart/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..945adbba09e402e30ad4c6c1c04fd3ab8ddf828e --- /dev/null +++ b/app/(storefront)/(main)/cart/loading.tsx @@ -0,0 +1,27 @@ +import { LoadingSkeleton } from "@/components/ui/loading-skeleton"; +import { cookies } from "next/headers"; + +export default function Loading() { + return ( + <> + {/* {!!cookies().has("cartItems") && ( */} +
+
+ + +
+
+
+ {Array.from(Array(6)).map((_, i) => ( + + ))} +
+
+ +
+
+
+ {/* )} */} + + ); +} diff --git a/app/(storefront)/(main)/cart/page.tsx b/app/(storefront)/(main)/cart/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b7e5ca9ae90cd2214f0c5f4dbd0936d11ee8ef5a --- /dev/null +++ b/app/(storefront)/(main)/cart/page.tsx @@ -0,0 +1,99 @@ +import { CartLineItems } from "@/components/storefront/cart-line-items"; +import { Button } from "@/components/ui/button"; +import { Heading } from "@/components/ui/heading"; +import { currencyFormatter } from "@/lib/currency"; +import { routes } from "@/lib/routes"; +import { getCart } from "@/server-actions/get-cart-details"; +import { ChevronRight } from "lucide-react"; +import { cookies } from "next/headers"; +import Link from "next/link"; +import { CheckoutButton } from "./components/checkout-button"; + +export default async function Cart() { + const cartId = cookies().get("cartId")?.value; + const { cartItems, uniqueStoreIds, cartItemDetails } = await getCart( + Number(cartId) + ); + + if (isNaN(Number(cartId)) || !cartItems.length) { + return ( +
+ Your cart is empty + + + +
+ ); + } + + return ( +
+
+ Cart + + + +
+
+
+ {uniqueStoreIds.map((storeId, i) => ( +
+ + { + cartItemDetails?.find((item) => item.storeId === storeId) + ?.storeName + } + + item.storeId === storeId) ?? + [] + } + /> +
+ ))} +
+
+ Cart Summary + {uniqueStoreIds.map((storeId, i) => ( +
+

+ { + cartItemDetails?.find((item) => item.storeId === storeId) + ?.storeName + } +

+

+ {currencyFormatter( + cartItemDetails + .filter((item) => item.storeId === storeId) + .reduce((accum, curr) => { + const quantityInCart = cartItems.find( + (item) => item.id === curr.id + )?.qty; + return accum + Number(curr.price) * (quantityInCart ?? 0); + }, 0) + )} +

+ +
+ ))} +
+
+
+ ); +} diff --git a/app/(storefront)/(main)/layout.tsx b/app/(storefront)/(main)/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c48a8f3a3021211e971bedcaf9cbf833e4115eda --- /dev/null +++ b/app/(storefront)/(main)/layout.tsx @@ -0,0 +1,25 @@ +import { NavBar } from "@/components/navbar"; +import "../../../styles/globals.css"; +import { Footer } from "@/components/footer"; +import React from "react"; +import { FloatingStar } from "@/components/floating-star"; + +export const metadata = { + title: "ShopSmart - Multi-store shopping", + description: "Shop smart, live better with ShopSmart.", +}; + +export default async function StorefrontLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ + +
{children}
+
+ ); +} diff --git a/app/(storefront)/(main)/loading.tsx b/app/(storefront)/(main)/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bd97f6aed900d510697c74159cabf0af5f100cf8 --- /dev/null +++ b/app/(storefront)/(main)/loading.tsx @@ -0,0 +1,28 @@ +import { ContentWrapper } from "@/components/content-wrapper"; +import { LoadingSkeleton } from "@/components/ui/loading-skeleton"; + +export default function Loading() { + return ( +
+ +
+ {Array.from(Array(3)).map((_, i) => ( + + ))} +
+
+ + +
+
+ + +
+ + {Array.from(Array(4)).map((_, i) => ( + + ))} + +
+ ); +} diff --git a/app/(storefront)/(main)/page.tsx b/app/(storefront)/(main)/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..06704aee3bcc18c1dfbe96d75059e47b915b4d9d --- /dev/null +++ b/app/(storefront)/(main)/page.tsx @@ -0,0 +1,166 @@ +import { ContentWrapper } from "@/components/content-wrapper"; +import { SlideShow } from "@/components/slideshow"; +import { Heading } from "@/components/ui/heading"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { db } from "@/db/db"; +import { products, stores } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { PropsWithChildren } from "react"; +import { ProductAndStore } from "./products/page"; +import { ProductCard } from "@/components/storefront/product-card"; +import { Button } from "@/components/ui/button"; +import { routes } from "@/lib/routes"; +import Link from "next/link"; +import { FeatureBanner } from "../components/feature-banner"; +import { + AlarmClock, + DollarSign, + FastForward, + Phone, + Truck, + User, + Wind, +} from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { TextInputWithLabel } from "@/components/text-input-with-label"; + +export default async function Home() { + const storeAndProduct = (await db + .select({ + product: products, + store: { + id: stores.id, + name: stores.name, + slug: stores.slug, + }, + }) + .from(products) + .leftJoin(stores, eq(products.storeId, stores.id)) + .limit(8)) as ProductAndStore[]; + + return ( +
+ + + +
+ + For Buyers + For Sellers + +
+ + Sell online with ease.} + subheading={ + + Access our global marketplace and sell your
products to + over 1 million visitors. +
+ } + > +
+ } + /> + } + /> + } + /> +
+
+ + + +
+
+
+ + Online shopping made easy.} + subheading={ + + Shop hundreds of products from sellers worldwide. + + } + > + Top Picks +
+ {storeAndProduct.map((item) => ( + + ))} +
+
+ + + +
+
+

+ Featured seller +

+

Tim's Terrific Toys

+

+ Top seller of the month! Tim's Toys has been selling toys + for 10 years and is a top rated seller on the platform. +

+ + + +
+
+ } + /> + } + /> + } + /> +
+
+
+
+
+
+ ); +} + +const HomePageLayout = ( + props: PropsWithChildren<{ + heading: React.ReactNode; + subheading: React.ReactNode; + }> +) => { + return ( + <> +
+ {props.heading} +
{props.subheading}
+
+ {props.children} + + ); +}; diff --git a/app/(storefront)/(main)/product/[productId]/layout.tsx b/app/(storefront)/(main)/product/[productId]/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1c117ae7d2773ed2eb7927f10e0fdbea02e86d5d --- /dev/null +++ b/app/(storefront)/(main)/product/[productId]/layout.tsx @@ -0,0 +1,10 @@ +import { ContentWrapper } from "@/components/content-wrapper"; +import { type PropsWithChildren } from "react"; + +export default function Layout(props: PropsWithChildren) { + return ( + + {props.children} + + ); +} diff --git a/app/(storefront)/(main)/product/[productId]/loading.tsx b/app/(storefront)/(main)/product/[productId]/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..15265f773e524a873f3b958717e30be6829d040f --- /dev/null +++ b/app/(storefront)/(main)/product/[productId]/loading.tsx @@ -0,0 +1,20 @@ +import { LoadingSkeleton } from "@/components/ui/loading-skeleton"; + +export default function Loading() { + return ( +
+ +
+ + + +
+ {Array.from(Array(2)).map((_, i) => ( + + ))} +
+ +
+
+ ); +} diff --git a/app/(storefront)/(main)/product/[productId]/page.tsx b/app/(storefront)/(main)/product/[productId]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c3c5c8a66fda28c63422cd124f438a2729f1254e --- /dev/null +++ b/app/(storefront)/(main)/product/[productId]/page.tsx @@ -0,0 +1,114 @@ +import { ParagraphFormatter } from "@/components/paragraph-formatter"; +import { ProductForm } from "@/components/storefront/product-form"; +import { FeatureIcons } from "@/components/storefront/feature-icons"; +import { Heading } from "@/components/ui/heading"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Text } from "@/components/ui/text"; +import { db } from "@/db/db"; +import { Product, products, stores } from "@/db/schema"; +import { currencyFormatter } from "@/lib/currency"; +import { eq } from "drizzle-orm"; +import Image from "next/image"; +import Link from "next/link"; +import { productsQueryParams, routes } from "@/lib/routes"; +import { ProductImage } from "@/components/product-image"; +import { addToCart } from "@/server-actions/add-to-cart"; + +export default async function StorefrontProductDetails(props: { + params: { productId: string }; +}) { + const product = (await db + .select() + .from(products) + .where(eq(products.id, Number(props.params.productId))) + .then((res) => { + if (res.length === 0) throw new Error("Product not found"); + return res[0]; + }) + .catch(() => { + throw new Error("Product not found"); + })) as Omit & { + images: { id: string; url: string; alt: string }[]; + }; + + const store = await db + .select({ + name: stores.name, + description: stores.description, + slug: stores.slug, + }) + .from(stores) + .where(eq(stores.id, Number(product.storeId))) + .then((res) => res[0]) + .catch(() => { + throw new Error("Store not found"); + }); + + return ( +
+
+
+ + {product.images.length > 1 && ( + <> +
+ {product.images.slice(1).map((image) => ( +
+ {image.alt} +
+ ))} +
+ + )} +
+
+ {product.name} + + Sold by{" "} + + + {store.name} + + + + + {currencyFormatter(Number(product.price))} + + + +
+
+ +
+ + Product Description + About the Seller + +
+ + + + + {store.description} + +
+
+ ); +} diff --git a/app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/layout.tsx b/app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1863981a3608ed04132646b9bcdf29c007d2a4e9 --- /dev/null +++ b/app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/layout.tsx @@ -0,0 +1,12 @@ +import { type PropsWithChildren } from "react"; +import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"; + +export default function Layout(props: PropsWithChildren) { + return ( + + + {props.children} + + + ); +} diff --git a/app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/loading.tsx b/app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4f8f553e03b0fd4809fd9a6e0e8257f8d99f1cd9 --- /dev/null +++ b/app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/loading.tsx @@ -0,0 +1,19 @@ +import { LoadingSkeleton } from "@/components/ui/loading-skeleton"; + +export default function Loading() { + return ( +
+ +
+ + + +
+ + + +
+
+
+ ); +} diff --git a/app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/page.tsx b/app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..099a5b4fac587b0614587a8e0f1e3d9d94db7002 --- /dev/null +++ b/app/(storefront)/(main)/products/@modal/(..)quickview/product/[productId]/page.tsx @@ -0,0 +1,91 @@ +import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"; +import { ParagraphFormatter } from "@/components/paragraph-formatter"; +import { Heading } from "@/components/ui/heading"; +import { Text } from "@/components/ui/text"; +import { db } from "@/db/db"; +import { Product, products } from "@/db/schema"; +import { currencyFormatter } from "@/lib/currency"; +import { eq } from "drizzle-orm"; +import { ImageOff } from "lucide-react"; +import Image from "next/image"; +import { Button } from "@/components/ui/button"; +import { QuickViewModalWrapper } from "@/components/storefront/quickview-modal-wrapper"; +import Link from "next/link"; +import { routes } from "@/lib/routes"; + +export default async function StorefrontProductQuickView(props: { + params: { productId: string }; +}) { + const product = (await db + .select() + .from(products) + .where(eq(products.id, Number(props.params.productId))) + .then((res) => { + if (res.length === 0) throw new Error("Product not found"); + return res[0]; + }) + .catch(() => { + throw new Error("Product not found"); + })) as Omit & { + images: { id: string; url: string; alt: string }[]; + }; + + return ( + +
+
+
+ {product.images.length > 0 ? ( + <> +
+ {product.images[0].alt} +
+
+ {product.images.slice(1).map((image) => ( +
+ {image.alt} +
+ ))} +
+ + ) : ( +
+ +
+ )} +
+
+ {product.name} + + {currencyFormatter(Number(product.price))} + +
+ + + + {!product.inventory && ( + Sold out + )} +
+ +
+
+
+
+ ); +} diff --git a/app/(storefront)/(main)/products/@modal/default.ts b/app/(storefront)/(main)/products/@modal/default.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ddf1b76fa5c3ce74b26a8c29c6c97bfb2273a43 --- /dev/null +++ b/app/(storefront)/(main)/products/@modal/default.ts @@ -0,0 +1,3 @@ +export default function Default() { + return null; +} diff --git a/app/(storefront)/(main)/products/layout.tsx b/app/(storefront)/(main)/products/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5d9ea52dcf593387d028beae9b25d284bf474fe4 --- /dev/null +++ b/app/(storefront)/(main)/products/layout.tsx @@ -0,0 +1,13 @@ +import { ContentWrapper } from "@/components/content-wrapper"; +import { PropsWithChildren } from "react"; + +export default function Layout( + props: PropsWithChildren<{ modal: React.ReactNode }> +) { + return ( + + {props.children} + {props.modal} + + ); +} diff --git a/app/(storefront)/(main)/products/loading.tsx b/app/(storefront)/(main)/products/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7316c400cba1adfbb144cf3bd4048b44fb9b0a0d --- /dev/null +++ b/app/(storefront)/(main)/products/loading.tsx @@ -0,0 +1,17 @@ +import { LoadingSkeleton } from "@/components/ui/loading-skeleton"; + +export default function Loading() { + return ( +
+ +
+ +
+ {Array.from(Array(12)).map((_, i) => ( + + ))} +
+
+
+ ); +} diff --git a/app/(storefront)/(main)/products/page.tsx b/app/(storefront)/(main)/products/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..22254ae22d95e46782fce9a48958b8acc24884c1 --- /dev/null +++ b/app/(storefront)/(main)/products/page.tsx @@ -0,0 +1,82 @@ +import { CollectionBody } from "@/components/storefront/collection-body"; +import { CollectionHeaderWrapper } from "@/components/storefront/collection-header-wrapper"; +import { CollectionPagePagination } from "@/components/storefront/collection-page-pagination"; +import { db } from "@/db/db"; +import { Product, Store, stores } from "@/db/schema"; +import { products } from "@/db/schema"; +import { eq, inArray } from "drizzle-orm"; + +export type ProductAndStore = { + product: Omit & { + images: { id: string; url: string; alt: string }[]; + }; + store: Omit; +}; + +const PRODUCTS_PER_PAGE = 6; + +export default async function StorefrontProductsPage(context: { + params: { slug: string }; + searchParams: { page: string; seller: string }; +}) { + const storeAndProduct = (await db + .select({ + product: products, + store: { + id: stores.id, + name: stores.name, + slug: stores.slug, + }, + }) + .from(products) + .where(() => { + if ( + context.searchParams.seller === undefined || + context.searchParams.seller === "" + ) + return; + return inArray(stores.slug, context.searchParams.seller.split("_")); + }) + .leftJoin(stores, eq(products.storeId, stores.id)) + .limit(PRODUCTS_PER_PAGE) + .offset( + !isNaN(Number(context.searchParams.page)) + ? (Number(context.searchParams.page) - 1) * PRODUCTS_PER_PAGE + : 0 + )) as ProductAndStore[]; + + return ( +
+ +

+ Browse all products from our marketplace sellers – groceries, tech, + fashion and home essentials in one place. +

+

+ Filter by Featured Sellers to see items from a specific store, or use + the categories to discover something new. +

+ +
+ + + +
+ ); +} + +const getActiveSellers = async () => { + return await db + .select({ + id: stores.id, + name: stores.name, + slug: stores.slug, + }) + .from(stores); +}; diff --git a/app/(storefront)/(main)/quickview/product/[productId]/page.tsx b/app/(storefront)/(main)/quickview/product/[productId]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..93f0651b45dbd6b1db5e85fa2084403b00547862 --- /dev/null +++ b/app/(storefront)/(main)/quickview/product/[productId]/page.tsx @@ -0,0 +1,8 @@ +import { routes } from "@/lib/routes"; +import { redirect } from "next/navigation"; + +export default function QuickViewPage(context: { + params: { productId: string }; +}) { + redirect(`${routes.product}/${context.params.productId}`); +} diff --git a/app/(storefront)/checkout/[storeSlug]/error.tsx b/app/(storefront)/checkout/[storeSlug]/error.tsx new file mode 100644 index 0000000000000000000000000000000000000000..98b1efbdd0de24d3ee5aec020bc1cba39345e56d --- /dev/null +++ b/app/(storefront)/checkout/[storeSlug]/error.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Heading } from "@/components/ui/heading"; +import { routes } from "@/lib/routes"; +import Link from "next/link"; + +export default function CheckoutError(props: { + error: Error; + reset: () => void; +}) { + return ( +
+ Sorry, an error occured loading this page. +

+ Please try again, or contact our customer support team if this issue + persists. +

+
+ + + +
+
+ ); +} diff --git a/app/(storefront)/checkout/[storeSlug]/order-confirmation/components/verification.tsx b/app/(storefront)/checkout/[storeSlug]/order-confirmation/components/verification.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0291e66d0ffd6aa4a58045549ec80c6824e9971d --- /dev/null +++ b/app/(storefront)/checkout/[storeSlug]/order-confirmation/components/verification.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { TextInputWithLabel } from "@/components/text-input-with-label"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export const Verification = () => { + const [formValues, setFormValues] = useState({ + postcode: "", + }); + const router = useRouter(); + + return ( +
{ + e.preventDefault(); + router.push( + window.location.href.split("&delivery_postal_code=")[0] + + "&delivery_postal_code=" + + formValues.postcode.split(" ").join("") + ); + }} + > +
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/app/(storefront)/checkout/[storeSlug]/order-confirmation/page.tsx b/app/(storefront)/checkout/[storeSlug]/order-confirmation/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e235d25080a983dd4b0423d64bf54270dbe0c129 --- /dev/null +++ b/app/(storefront)/checkout/[storeSlug]/order-confirmation/page.tsx @@ -0,0 +1,144 @@ +import { Heading } from "@/components/ui/heading"; +import { getPaymentIntentDetails } from "@/server-actions/stripe/payment"; +import { Verification } from "./components/verification"; +import { db } from "@/db/db"; +import { stores } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { CheckoutItem, OrderItemDetails } from "@/lib/types"; +import { Check } from "lucide-react"; +import { OrderLineItems } from "@/components/order-line-items"; +import { getDetailsOfProductsOrdered } from "@/server-actions/orders"; +import { currencyFormatter } from "@/lib/currency"; + +const getSellerName = async (storeSlug: string) => { + return await db + .select({ + name: stores.name, + }) + .from(stores) + .where(eq(stores.slug, storeSlug)); +}; + +export default async function OrderConfirmation({ + params, + searchParams, +}: { + params: { + storeSlug: string; + }; + searchParams: { + payment_intent: string; + payment_intent_client_secret: string; + redirect_status: "success"; + delivery_postal_code: string; + }; +}) { + const { paymentDetails, isVerified } = await getPaymentIntentDetails({ + paymentIntentId: searchParams.payment_intent, + storeSlug: params.storeSlug, + deliveryPostalCode: searchParams.delivery_postal_code, + }); + + const checkoutItems = JSON.parse( + paymentDetails?.metadata?.items ?? "[]" + ) as CheckoutItem[]; + + let products: OrderItemDetails[] = []; + let sellerDetails; + if (isVerified) { + sellerDetails = (await getSellerName(params.storeSlug))[0]; + products = await getDetailsOfProductsOrdered(checkoutItems); + } + + return ( +
+ {isVerified ? ( +
+ +
+
+ +
+ + Thanks for your order,{" "} + + {paymentDetails?.shipping?.name?.split(" ")[0]} + + ! + +
+
+

+ Your payment confirmation ID is # + {searchParams.payment_intent.slice(3)} +

+
+
+ What's next? +

+ Our warehouse team is busy preparing your order. You'll + receive an email once your order ships. +

+
+
+
+
+
+ Shipping Address +
+

{paymentDetails?.shipping?.name}

+

{paymentDetails?.receipt_email}

+

{paymentDetails?.shipping?.address?.line1}

+

{paymentDetails?.shipping?.address?.line2}

+

+ {paymentDetails?.shipping?.address?.city},{" "} + {paymentDetails?.shipping?.address?.postal_code} +

+

+ {paymentDetails?.shipping?.address?.state},{" "} + {paymentDetails?.shipping?.address?.country} +

+
+
+
+ Seller Details +
+

{sellerDetails?.name}

+
+
+
+
+ Order Details +
+ +
+ Order Total: +

+ {currencyFormatter( + checkoutItems.reduce( + (acc, curr) => acc + curr.price * curr.qty, + 0 + ) + )} +

+
+
+
+
+
+ ) : ( +
+ Thanks for your order! +

+ Please enter your delivery postcode below to view your order + details. +

+ +
+ )} +
+ ); +} diff --git a/app/(storefront)/checkout/[storeSlug]/page.tsx b/app/(storefront)/checkout/[storeSlug]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..416a69ca6ce083f2cad359775bf5feb883621e9c --- /dev/null +++ b/app/(storefront)/checkout/[storeSlug]/page.tsx @@ -0,0 +1,108 @@ +import { createPaymentIntent } from "@/server-actions/stripe/payment"; +import CheckoutWrapper from "../components/checkout-wrapper"; +import { cookies } from "next/headers"; +import { getCart } from "@/server-actions/get-cart-details"; +import { db } from "@/db/db"; +import { payments, products, stores } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { CheckoutItem } from "@/lib/types"; +import { CartLineItems } from "@/components/storefront/cart-line-items"; +import { InfoCard } from "@/components/admin/info-card"; +import { AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { routes } from "@/lib/routes"; +import Link from "next/link"; +import { hasConnectedStripeAccount } from "@/server-actions/stripe/account"; + +export default async function Page({ + params, +}: { + params: { storeSlug: string }; +}) { + const cartId = cookies().get("cartId")?.value; + const { cartItems, cartItemDetails } = await getCart(Number(cartId)); + + const store = await db + .select({ + storeId: stores.id, + stripeAccountId: payments.stripeAccountId, + }) + .from(stores) + .leftJoin(payments, eq(payments.storeId, stores.id)) + .where(eq(stores.slug, params.storeSlug)); + + const storeId = Number(store[0].storeId); + const storeStripeAccountId = store[0].stripeAccountId; + + const storeProducts = await db + .select({ + id: products.id, + price: products.price, + }) + .from(products) + .leftJoin(stores, eq(products.storeId, stores.id)) + .where(eq(stores.id, storeId)); + + // @TODO: check if items from this store are in the cart + + const detailsOfProductsInCart = cartItems + .map((item) => { + const product = storeProducts.find((p) => p.id === item.id); + const priceAsNumber = Number(product?.price); + if (!product || isNaN(priceAsNumber)) return undefined; + return { + id: item.id, + price: priceAsNumber, + qty: item.qty, + }; + }) + .filter(Boolean) as CheckoutItem[]; + + if ( + !storeStripeAccountId || + !(await hasConnectedStripeAccount(storeId, true)) + ) { + return ( + } + button={ + + + + } + /> + ); + } + + if ( + !storeProducts.length || + isNaN(storeId) || + !detailsOfProductsInCart.length + ) + throw new Error("Store not found"); + + const paymentIntent = createPaymentIntent({ + items: detailsOfProductsInCart, + storeId, + }); + + // providing the paymntIntent to the CheckoutWrapper to work around Nextjs bug with authentication not passed to server actions when called in client component + return ( + item.storeId === storeId) ?? [] + } + /> + } + /> + ); +} diff --git a/app/(storefront)/checkout/components/checkout-form.tsx b/app/(storefront)/checkout/components/checkout-form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..69a1f029b795916d40b7fda413a0c31be7095db6 --- /dev/null +++ b/app/(storefront)/checkout/components/checkout-form.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Heading } from "@/components/ui/heading"; +import { routes } from "@/lib/routes"; +import { StripeCheckoutFormDetails } from "@/lib/types"; +// https://stripe.com/docs/payments/quickstart + +import { + PaymentElement, + LinkAuthenticationElement, + useStripe, + useElements, + AddressElement, +} from "@stripe/react-stripe-js"; +import { + StripeAddressElementChangeEvent, + StripeLinkAuthenticationElementChangeEvent, + StripePaymentElementOptions, +} from "@stripe/stripe-js"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { useParams } from "next/navigation"; +import { FormEvent, useEffect, useState } from "react"; + +export default function CheckoutForm() { + const { storeSlug } = useParams(); + const stripe = useStripe(); + const elements = useElements(); + + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!stripe) { + return; + } + + const clientSecret = new URLSearchParams(window.location.search).get( + "payment_intent_client_secret" + ); + + if (!clientSecret) { + return; + } + + stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }) => { + switch (paymentIntent?.status) { + case "succeeded": + setMessage("Payment succeeded!"); + break; + case "processing": + setMessage("Your payment is processing."); + break; + case "requires_payment_method": + setMessage("Your payment was not successful, please try again."); + break; + default: + setMessage("Something went wrong."); + break; + } + }); + }, [stripe]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!stripe || !elements) { + // Stripe.js hasn't yet loaded. + // Make sure to disable form submission until Stripe.js has loaded. + return; + } + + setIsLoading(true); + + const { error } = await stripe.confirmPayment({ + elements, + confirmParams: { + // Make sure to change this to your payment completion page + return_url: `${process.env.NEXT_PUBLIC_APP_URL}/${routes.checkout}/${storeSlug}/${routes.orderConfirmation}`, + receipt_email: email, + }, + }); + + // This point will only be reached if there is an immediate error when + // confirming the payment. Otherwise, your customer will be redirected to + // your `return_url`. For some payment methods like iDEAL, your customer will + // be redirected to an intermediate site first to authorize the payment, then + // redirected to the `return_url`. + if (error.type === "card_error" || error.type === "validation_error") { + setMessage(error.message ?? "An unexpected error occurred."); + } else { + setMessage("An unexpected error occurred."); + } + + setIsLoading(false); + }; + + const paymentElementOptions = { + layout: "tabs", + } as StripePaymentElementOptions; + + return ( +
+ {/* Show any error or success messages */} + {message && ( +
+ +

{message}

+
+ )} +
+ Contact Info + + setEmail(e.value.email) + } + /> +
+
+ Shipping + +
+
+ Payment + +
+ +
+ ); +} diff --git a/app/(storefront)/checkout/components/checkout-wrapper.tsx b/app/(storefront)/checkout/components/checkout-wrapper.tsx new file mode 100644 index 0000000000000000000000000000000000000000..07958b5c664481394d463ea75c0144ed5ab24e09 --- /dev/null +++ b/app/(storefront)/checkout/components/checkout-wrapper.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { StripeElementsOptions, loadStripe } from "@stripe/stripe-js"; +import { Elements } from "@stripe/react-stripe-js"; +import { useEffect, useMemo, useState } from "react"; +import CheckoutForm from "./checkout-form"; +import { ChevronRight } from "lucide-react"; +import { StarSVG } from "@/components/icons/star"; +import { routes } from "@/lib/routes"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Heading } from "@/components/ui/heading"; +import { OrderSummaryAccordion } from "./order-summary-accordion"; +import { FeatureIcons } from "@/components/storefront/feature-icons"; +import { CheckoutItem } from "@/lib/types"; +import { currencyFormatter } from "@/lib/currency"; + +export default function CheckoutWrapper(props: { + paymentIntent: Promise<{ clientSecret: string | null } | undefined>; + detailsOfProductsInCart: CheckoutItem[]; + storeStripeAccountId: string; + cartLineItems: React.ReactNode; +}) { + const [clientSecret, setClientSecret] = useState(""); + const stripePromise = useMemo( + () => + loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, { + stripeAccount: props.storeStripeAccountId, + }), + [props.storeStripeAccountId] + ); + + useEffect(() => { + let error; + props.paymentIntent.then((data) => { + if (!data || !data.clientSecret) { + error = true; + return; + } + setClientSecret(data.clientSecret); + }); + if (error) throw new Error("Payment intent not found"); + }, [props.paymentIntent]); + + const options = { + clientSecret, + appearance: { + theme: "stripe", + }, + } as StripeElementsOptions; + + const orderTotal = useMemo(() => { + return currencyFormatter( + props.detailsOfProductsInCart.reduce( + (acc, item) => acc + item.price * item.qty, + 0 + ) + ); + }, [props.detailsOfProductsInCart]); + + return ( +
+ Checkout +
+ + + + + +
+ {clientSecret && ( +
+
+
+ + + +
+
+
+
+ Order Summary + {props.cartLineItems} + +
+ + {props.cartLineItems} + + +
+
+ +
+
+ +
+
+
+
+ )} +
+ ); +} + +const TrustBadges = () => { + return ( +
+
+

+ Hundreds of happy customers worldwide +

+
+ {Array.from(Array(5)).map((_, i) => ( +
+ +
+ ))} +
+
+ +
+ ); +}; + +const OrderTotalRow = (props: { total: string }) => { + return ( +
+ Total +

{props.total}

+
+ ); +}; diff --git a/app/(storefront)/checkout/components/order-summary-accordion.tsx b/app/(storefront)/checkout/components/order-summary-accordion.tsx new file mode 100644 index 0000000000000000000000000000000000000000..317f992cf1046c9f084c374c758edb0251cbd912 --- /dev/null +++ b/app/(storefront)/checkout/components/order-summary-accordion.tsx @@ -0,0 +1,28 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { cn } from "@/lib/utils"; +import { PropsWithChildren } from "react"; + +export function OrderSummaryAccordion( + props: PropsWithChildren<{ + title: string; + className?: string; + }> +) { + return ( + + + {props.title} + {props.children} + + + ); +} diff --git a/app/(storefront)/checkout/layout.tsx b/app/(storefront)/checkout/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..30f963bb5e546f7b0c0d2261a119a132444caf9b --- /dev/null +++ b/app/(storefront)/checkout/layout.tsx @@ -0,0 +1,16 @@ +import { ContentWrapper } from "@/components/content-wrapper"; +import { Logo } from "@/components/logo"; +import { PropsWithChildren } from "react"; + +export default function Layout(props: PropsWithChildren) { + return ( +
+
+ + + +
+ {props.children} +
+ ); +} diff --git a/app/(storefront)/components/feature-banner.tsx b/app/(storefront)/components/feature-banner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bafa2a7673b92cecec645f18a4c0bd8024011403 --- /dev/null +++ b/app/(storefront)/components/feature-banner.tsx @@ -0,0 +1,15 @@ +export const FeatureBanner = (props: { + heading: string; + subheading: string; + icon: React.ReactNode; +}) => { + return ( +
+
{props.icon}
+
+

{props.heading}

+

{props.subheading}

+
+
+ ); +}; diff --git a/app/account/buying/purchases/components/columns.tsx b/app/account/buying/purchases/components/columns.tsx new file mode 100644 index 0000000000000000000000000000000000000000..55dba1c5a4e64c36ad87b4f11c0fa000665112c0 --- /dev/null +++ b/app/account/buying/purchases/components/columns.tsx @@ -0,0 +1,77 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { StatusLabel } from "@/components/ui/status-label"; +import { currencyFormatter } from "@/lib/currency"; +import { BuyersOrderTable, CheckoutItem } from "@/lib/types"; +import { convertSecondsToDate, formatOrderNumber } from "@/lib/utils"; +import { ColumnDef } from "@tanstack/react-table"; +import { formatRelative } from "date-fns"; +import { ArrowUpDown } from "lucide-react"; + +export const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const id = row.getValue("id"); + return

{formatOrderNumber(Number(id))}

; + }, + }, + { + accessorKey: "sellerName", + header: "Seller", + }, + { + accessorKey: "total", + header: "Total", + cell: ({ row }) => currencyFormatter(Number(row.getValue("total"))), + }, + { + accessorKey: "items", + header: "Items", + cell: ({ row }) => { + const items = JSON.parse(row.getValue("items")) as CheckoutItem[]; + const total = items.reduce((acc, item) => acc + Number(item.qty), 0); + return ( +

+ {total} {`${total > 1 ? "items" : "item"}`} +

+ ); + }, + }, + { + accessorKey: "stripePaymentIntentStatus", + header: "Payment Status", + cell: ({ row }) => { + const status = row.getValue("stripePaymentIntentStatus") as string; + return ( + + {status} + + ); + }, + }, + { + accessorKey: "createdAt", + header: "Date ordered", + cell: ({ row }) => { + const createdSeconds = parseFloat(row.getValue("createdAt")); + if (!createdSeconds) return; + const relativeDate = formatRelative( + convertSecondsToDate(createdSeconds), + new Date() + ); + return relativeDate[0].toUpperCase() + relativeDate.slice(1); + }, + }, +]; diff --git a/app/account/buying/purchases/components/data-table.tsx b/app/account/buying/purchases/components/data-table.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c37cd7f7e67888e8c78b313ced046ec5da6b2fe9 --- /dev/null +++ b/app/account/buying/purchases/components/data-table.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Settings2 } from "lucide-react"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [sorting, setSorting] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + const [columnFilters, setColumnFilters] = useState([]); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + state: { + sorting, + columnFilters, + columnVisibility, + }, + }); + + return ( +
+
+ + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + const headerValue = column.columnDef.header; + return ( + + column.toggleVisibility(!!value) + } + > + {typeof headerValue === "string" + ? headerValue + : column.id === "id" + ? "Order Number" + : column.id} + + ); + })} + + +
+ + table.getColumn("sellerName")?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> +
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ + +
+
+ ); +} diff --git a/app/account/buying/purchases/page.tsx b/app/account/buying/purchases/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b93e76f247121d073ef81c140aeccc0e3dfd53fe --- /dev/null +++ b/app/account/buying/purchases/page.tsx @@ -0,0 +1,55 @@ +import { BuyersOrderTable } from "@/lib/types"; +import { DataTable } from "./components/data-table"; +import { columns } from "./components/columns"; +import { db } from "@/db/db"; +import { orders, stores } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { InfoCard } from "@/components/admin/info-card"; +import { Box } from "lucide-react"; +import { HeadingAndSubheading } from "@/components/admin/heading-and-subheading"; +import { currentUser } from "@clerk/nextjs"; + +async function getData(): Promise { + const user = await currentUser(); + const userEmailAddress = user?.emailAddresses[0].emailAddress; + if (!userEmailAddress) return []; + const storeOrders = await db + .select({ + id: orders.prettyOrderId, + sellerName: stores.name, + items: orders.items, + total: orders.total, + stripePaymentIntentStatus: orders.stripePaymentIntentStatus, + createdAt: orders.createdAt, + }) + .from(orders) + .leftJoin(stores, eq(orders.storeId, stores.id)) + .where(eq(orders.email, userEmailAddress)); + return (storeOrders as BuyersOrderTable[]).sort( + (a, b) => b.createdAt - a.createdAt + ); +} + +export default async function OrdersPage() { + const data = await getData(); + + return ( +
+
+ +
+ {data.length > 0 ? ( + + ) : ( + } + /> + )} +
+ ); +} diff --git a/app/account/layout.tsx b/app/account/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..59c22dd35a6cab3f7014467851181fc42a3e0087 --- /dev/null +++ b/app/account/layout.tsx @@ -0,0 +1,81 @@ +// app/account/layout.tsx +export const dynamic = "force-dynamic"; +export const revalidate = 0; // optional, but makes intent clear + + +import { ContentWrapper } from "@/components/content-wrapper"; +import { Footer } from "@/components/footer"; +import { NavBar } from "@/components/navbar"; +import { Heading } from "@/components/ui/heading"; +import { PropsWithChildren } from "react"; +import { SignedIn, SignedOut, UserButton } from "@clerk/nextjs"; +import SignInWrapper from "@/components/sign-in"; +import { singleLevelNestedRoutes } from "@/lib/routes"; +import { MenuItems, SecondaryMenu } from "@/components/secondary-menu"; +import { Line } from "@/components/line"; +import { PaymentConnectionStatus } from "@/components/admin/payment-connection-status"; + +export default async function AdminLayout({ children }: PropsWithChildren) { + return ( +
+ + +
+
+ + Your Account +
+ +
+
+
+
+ + + + +
+ +
+ + + +
{children}
+
+ + + +
+ +
+
+ ); +} + +const menuItems: MenuItems = [ + { + name: "Profile", + href: singleLevelNestedRoutes.account.profile, + group: "selling", + }, + { + name: "Products", + href: singleLevelNestedRoutes.account.products, + group: "selling", + }, + { + name: "Orders", + href: singleLevelNestedRoutes.account.orders, + group: "selling", + }, + { + name: "Payments", + href: singleLevelNestedRoutes.account.payments, + group: "selling", + }, + { + name: "Your purchases", + href: singleLevelNestedRoutes.account["your-purchases"], + group: "buying", + }, +]; diff --git a/app/account/page.tsx b/app/account/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..582403556934ffb4c0dc825d70b74f09d6d516dc --- /dev/null +++ b/app/account/page.tsx @@ -0,0 +1,12 @@ +import { HeadingAndSubheading } from "@/components/admin/heading-and-subheading"; + +export default function Account() { + return ( + <> + + + ); +} diff --git a/app/account/selling/(orders)/abandoned-carts/components/columns.tsx b/app/account/selling/(orders)/abandoned-carts/components/columns.tsx new file mode 100644 index 0000000000000000000000000000000000000000..73aebcd26a076282fd56d0b0212e3a9c0194c395 --- /dev/null +++ b/app/account/selling/(orders)/abandoned-carts/components/columns.tsx @@ -0,0 +1,45 @@ +"use client"; +import { formatRelative } from "date-fns"; +import { currencyFormatter } from "@/lib/currency"; +import { convertSecondsToDate } from "@/lib/utils"; +import { ColumnDef } from "@tanstack/react-table"; + +// This type is used to define the shape of our data. +// You can use a Zod schema here if you want. +export type Payment = { + id: string; + amount: number; + created: number; + cartId: number; +}; + +export const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: "Checkout ID", + }, + { + accessorKey: "amount", + header: "Amount", + cell: ({ row }) => { + const amount = parseFloat(row.getValue("amount")); + return currencyFormatter(amount); + }, + }, + { + accessorKey: "created", + header: "Created At", + cell: ({ row }) => { + const createdSeconds = parseFloat(row.getValue("created")); + const relativeDate = formatRelative( + convertSecondsToDate(createdSeconds), + new Date() + ); + return relativeDate[0].toUpperCase() + relativeDate.slice(1); + }, + }, + { + accessorKey: "cartId", + header: "Cart ID", + }, +]; diff --git a/app/account/selling/(orders)/abandoned-carts/components/data-table.tsx b/app/account/selling/(orders)/abandoned-carts/components/data-table.tsx new file mode 100644 index 0000000000000000000000000000000000000000..86fa93218b5fea6df16c55a2d102465c23260293 --- /dev/null +++ b/app/account/selling/(orders)/abandoned-carts/components/data-table.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { useState } from "react"; +import { Payment } from "./columns"; +import { Loader2 } from "lucide-react"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + getPaymentIntents: ({ + startingAfterPaymentId, + beforePaymentId, + }: { + startingAfterPaymentId?: string | undefined; + beforePaymentId?: string | undefined; + }) => Promise<{ paymentIntents: any; hasMore: any }>; + lastPaymentIntentInInitialFetchId: string; + initialFetchHasNextPage: boolean; +} + +export function DataTable(props: DataTableProps) { + const [newData, setNewData] = useState(props.data as Payment[]); + const [hasNextPage, setHasNextPage] = useState(props.initialFetchHasNextPage); + const [isLoadingNewPage, setIsLoadingNewPage] = useState({ + previous: false, + next: false, + }); + const [pageIndex, setPageIndex] = useState(1); + + const table = useReactTable({ + data: newData as TData[], + columns: props.columns, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+

+ Page {pageIndex <= 0 ? 1 : pageIndex} of{" "} + {hasNextPage ? "many" : pageIndex} +

+
+ + +
+
+
+ ); +} diff --git a/app/account/selling/(orders)/abandoned-carts/page.tsx b/app/account/selling/(orders)/abandoned-carts/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..67d0932153fb61951be4cc0555d54a4eefd6954c --- /dev/null +++ b/app/account/selling/(orders)/abandoned-carts/page.tsx @@ -0,0 +1,45 @@ +import { getPaymentIntents } from "@/server-actions/stripe/payment"; +import { Payment, columns } from "./components/columns"; +import { DataTable } from "./components/data-table"; +import { InfoCard } from "@/components/admin/info-card"; +import { ShoppingCart } from "lucide-react"; +import { Heading } from "@/components/ui/heading"; + +async function getData(): Promise<{ + paymentIntents: Payment[]; + hasMore: boolean; +}> { + return await getPaymentIntents({}); +} + +export default async function OrdersPage() { + const data = await getData(); + const lastPaymentIntentInInitialFetch = data.paymentIntents.at(-1) as Payment; + + return ( +
+
+ Abondoned carts +
+ {!data.paymentIntents.length ? ( + } + /> + ) : ( +
+ +
+ )} +
+ ); +} diff --git a/app/account/selling/(orders)/layout.tsx b/app/account/selling/(orders)/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6be2133c5efcc350a21a62e606d8ed84a6a3effa --- /dev/null +++ b/app/account/selling/(orders)/layout.tsx @@ -0,0 +1,29 @@ +import { HeadingAndSubheading } from "@/components/admin/heading-and-subheading"; +import { Button } from "@/components/ui/button"; +import { singleLevelNestedRoutes } from "@/lib/routes"; +import Link from "next/link"; +import { PropsWithChildren } from "react"; + +export default function OrdersLayout(props: PropsWithChildren) { + return ( + <> + +
+ + + + + + +
+ {props.children} + + ); +} diff --git a/app/account/selling/(orders)/order/[orderId]/page.tsx b/app/account/selling/(orders)/order/[orderId]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5c524198fd2a54924cc5ccdf07ca59a7da2e231c --- /dev/null +++ b/app/account/selling/(orders)/order/[orderId]/page.tsx @@ -0,0 +1,120 @@ +import { HeadingAndSubheading } from "@/components/admin/heading-and-subheading"; +import { OrderLineItems } from "@/components/order-line-items"; +import { Heading } from "@/components/ui/heading"; +import { StatusLabel } from "@/components/ui/status-label"; +import { db } from "@/db/db"; +import { addresses, orders } from "@/db/schema"; +import { currencyFormatter } from "@/lib/currency"; +import { CheckoutItem } from "@/lib/types"; +import { + convertDateToRelativeTime, + convertSecondsToDate, + formatOrderNumber, + removeOrderNumberFormatting, +} from "@/lib/utils"; +import { getDetailsOfProductsOrdered } from "@/server-actions/orders"; +import { getStoreId } from "@/server-actions/store-details"; +import { and, eq } from "drizzle-orm"; + +export default async function OrderDetailPage(context: { + params: { orderId: string }; +}) { + const storeId = await getStoreId(); + if (!storeId || isNaN(Number(context.params.orderId))) { + throw new Error("Store ID not found"); + } + const orderDetails = await db + .select({ + order: orders, + address: addresses, + }) + .from(orders) + .leftJoin(addresses, eq(orders.addressId, addresses.id)) + .where( + and( + eq( + orders.prettyOrderId, + removeOrderNumberFormatting(Number(context.params.orderId)) + ), + eq(orders.storeId, Number(storeId)) + ) + ); + + const record = orderDetails[0]; + const checkoutItems = JSON.parse( + (record.order.items as string) ?? "[]" + ) as CheckoutItem[]; + const products = await getDetailsOfProductsOrdered(checkoutItems); + const totalItems = checkoutItems.reduce((acc, curr) => acc + curr.qty, 0); + + return ( +
+
+ +
+
+
+ + Items ({totalItems}) + + +
+ + Total paid:{" "} + {isNaN(Number(record.order.total)) + ? "" + : currencyFormatter(Number(record.order.total))} + +
+
+
+
+ Customer +
+

{record.order.name}

+

{record.order.email}

+
+
+
+ Shipping Details +
+

{record.address?.line1}

+

{record.address?.line2}

+

+ {record.address?.city}, {record.address?.postal_code} +

+

+ {record.address?.state}, {record.address?.country} +

+
+
+
+ Payment +
+ + {record.order?.stripePaymentIntentStatus} + +
+
+
+
+
+ ); +} diff --git a/app/account/selling/(orders)/order/error.tsx b/app/account/selling/(orders)/order/error.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9354bce8333e2a2759e0f6d97d1f8fec5fbb8db9 --- /dev/null +++ b/app/account/selling/(orders)/order/error.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { InfoCard } from "@/components/admin/info-card"; +import { Button } from "@/components/ui/button"; +import { singleLevelNestedRoutes } from "@/lib/routes"; +import { Box } from "lucide-react"; +import Link from "next/link"; + +export default function Error() { + return ( + } + button={ + + + + } + /> + ); +} diff --git a/app/account/selling/(orders)/orders/components/columns.tsx b/app/account/selling/(orders)/orders/components/columns.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da79f6474b5576553ea2f69a73f4df503f9b0bcd --- /dev/null +++ b/app/account/selling/(orders)/orders/components/columns.tsx @@ -0,0 +1,91 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { StatusLabel } from "@/components/ui/status-label"; +import { currencyFormatter } from "@/lib/currency"; +import { secondLevelNestedRoutes } from "@/lib/routes"; +import { CheckoutItem } from "@/lib/types"; + +import { OrdersTable } from "@/lib/types"; +import { convertSecondsToDate, formatOrderNumber } from "@/lib/utils"; +import { ColumnDef } from "@tanstack/react-table"; +import { formatRelative } from "date-fns"; +import { ArrowUpDown } from "lucide-react"; +import Link from "next/link"; + +export const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const id = row.getValue("id"); + return ( + + + + ); + }, + }, + { + accessorKey: "name", + header: "Customer", + }, + { + accessorKey: "total", + header: "Total", + cell: ({ row }) => currencyFormatter(Number(row.getValue("total"))), + }, + { + accessorKey: "items", + header: "Items", + cell: ({ row }) => { + const items = JSON.parse(row.getValue("items")) as CheckoutItem[]; + const total = items.reduce((acc, item) => acc + Number(item.qty), 0); + return ( +

+ {total} {`${total > 1 ? "items" : "item"}`} +

+ ); + }, + }, + { + accessorKey: "stripePaymentIntentStatus", + header: "Payment Status", + cell: ({ row }) => { + const status = row.getValue("stripePaymentIntentStatus") as string; + return ( + + {status} + + ); + }, + }, + { + accessorKey: "createdAt", + header: "Date ordered", + cell: ({ row }) => { + const createdSeconds = parseFloat(row.getValue("createdAt")); + if (!createdSeconds) return; + const relativeDate = formatRelative( + convertSecondsToDate(createdSeconds), + new Date() + ); + return relativeDate[0].toUpperCase() + relativeDate.slice(1); + }, + }, +]; diff --git a/app/account/selling/(orders)/orders/components/data-table.tsx b/app/account/selling/(orders)/orders/components/data-table.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8ca451a0af0ff5056ff55f8290c591201efec00d --- /dev/null +++ b/app/account/selling/(orders)/orders/components/data-table.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Settings2 } from "lucide-react"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [sorting, setSorting] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + const [columnFilters, setColumnFilters] = useState([]); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + state: { + sorting, + columnFilters, + columnVisibility, + }, + }); + + return ( +
+
+ + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + const headerValue = column.columnDef.header; + return ( + + column.toggleVisibility(!!value) + } + > + {typeof headerValue === "string" + ? headerValue + : column.id === "id" + ? "Order Number" + : column.id} + + ); + })} + + +
+ + table.getColumn("name")?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> +
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ + +
+
+ ); +} diff --git a/app/account/selling/(orders)/orders/page.tsx b/app/account/selling/(orders)/orders/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6137a3e75531548aa62144f99ead27750af72bc7 --- /dev/null +++ b/app/account/selling/(orders)/orders/page.tsx @@ -0,0 +1,48 @@ +import { OrdersTable } from "@/lib/types"; +import { columns } from "./components/columns"; +import { DataTable } from "./components/data-table"; +import { db } from "@/db/db"; +import { orders } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { getStoreId } from "@/server-actions/store-details"; +import { InfoCard } from "@/components/admin/info-card"; +import { Box } from "lucide-react"; +import { Heading } from "@/components/ui/heading"; + +async function getData(): Promise { + const storeId = await getStoreId(); + if (isNaN(Number(storeId))) return []; + const storeOrders = await db + .select({ + id: orders.prettyOrderId, + name: orders.name, + items: orders.items, + total: orders.total, + stripePaymentIntentStatus: orders.stripePaymentIntentStatus, + createdAt: orders.createdAt, + }) + .from(orders) + .where(eq(orders.storeId, Number(storeId))); + return (storeOrders as OrdersTable[]).sort( + (a, b) => b.createdAt - a.createdAt + ); +} + +export default async function OrdersPage() { + const data = await getData(); + + return ( +
+ All orders + {data.length > 0 ? ( + + ) : ( + } + /> + )} +
+ ); +} diff --git a/app/account/selling/layout.tsx b/app/account/selling/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ad70076fcc0742909bbde1b92e4474b8d64b7db --- /dev/null +++ b/app/account/selling/layout.tsx @@ -0,0 +1,18 @@ +import { CreateNewStore } from "@/components/admin/create-new-store"; +import { createStore } from "@/server-actions/store"; +import { currentUser } from "@clerk/nextjs"; +import { PropsWithChildren } from "react"; + +export default async function SellerLayout(props: PropsWithChildren) { + const user = await currentUser(); + + return ( + <> + {user?.privateMetadata?.storeId ? ( +
{props.children}
+ ) : ( + + )} + + ); +} diff --git a/app/account/selling/payments/components/create-connected-account.tsx b/app/account/selling/payments/components/create-connected-account.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2b4e578e7a2927235f653f36ef1e7974853b7a99 --- /dev/null +++ b/app/account/selling/payments/components/create-connected-account.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/use-toast"; +import { Loader2, Lock } from "lucide-react"; +import { useState } from "react"; + +export const CreateConnectedAccount = (props: { + createAccountLink: () => Promise; +}) => { + const [isLoading, setIsLoading] = useState(false); + + return ( +
+ +
+ ); +}; diff --git a/app/account/selling/payments/loading.tsx b/app/account/selling/payments/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2ea0760cf89bddb97f3550b5f5857f25540e3ac4 --- /dev/null +++ b/app/account/selling/payments/loading.tsx @@ -0,0 +1,11 @@ +import { LoadingSkeleton } from "@/components/ui/loading-skeleton"; + +export default function Loading() { + return ( +
+ + + +
+ ); +} diff --git a/app/account/selling/payments/page.tsx b/app/account/selling/payments/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6e400f545852a5a5d44bdf1b4b6cdbed373d9572 --- /dev/null +++ b/app/account/selling/payments/page.tsx @@ -0,0 +1,64 @@ +import { HeadingAndSubheading } from "@/components/admin/heading-and-subheading"; +import { InfoCard } from "@/components/admin/info-card"; +import { CreditCard } from "lucide-react"; +import { CreateConnectedAccount } from "./components/create-connected-account"; +import { + createAccountLink, + getStripeAccountDetails, + hasConnectedStripeAccount, + updateStripeAccountStatus, +} from "@/server-actions/stripe/account"; +import { getStoreId } from "@/server-actions/store-details"; +import { Button } from "@/components/ui/button"; + +export default async function PaymentsPage() { + await updateStripeAccountStatus(); + const connectedStripeAccount = await hasConnectedStripeAccount(); + const storeId = Number(await getStoreId()); + const stripeAccountDetails = await getStripeAccountDetails(storeId); + + return ( + <> + + {connectedStripeAccount ? ( +
+
+ Payment status: Stripe + account connected +
+
+

Stripe Details

+

+ Currency: {stripeAccountDetails?.default_currency.toUpperCase()} +

+

Country: {stripeAccountDetails?.country}

+

Account Email: {stripeAccountDetails?.email}

+ + + +
+
+ ) : ( + } + button={ + // pass server action from server component to client component - work around for nextjs/server actions bug with clerk. + // calling the server action inside the client component causes a clerk error of "Error: Clerk: auth() and currentUser() are only supported in App Router (/app directory)" + + } + /> + )} + + ); +} diff --git a/app/account/selling/product/[productId]/error.tsx b/app/account/selling/product/[productId]/error.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a7b1bee332ce382e9f02be912016a36db41c3f48 --- /dev/null +++ b/app/account/selling/product/[productId]/error.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { HeadingAndSubheading } from "@/components/admin/heading-and-subheading"; +import { Button } from "@/components/ui/button"; +import { singleLevelNestedRoutes } from "@/lib/routes"; +import Link from "next/link"; + +export default function Error(props: { error: Error; reset: () => void }) { + return ( +
+ +
+ + + + +
+
+ ); +} diff --git a/app/account/selling/product/[productId]/loading.tsx b/app/account/selling/product/[productId]/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4bac25e0587f3bb426f981bfc07da5683eb144e7 --- /dev/null +++ b/app/account/selling/product/[productId]/loading.tsx @@ -0,0 +1,19 @@ +import { LoadingSkeleton } from "@/components/ui/loading-skeleton"; + +export default function Loading() { + return ( +
+ +
+ + + +
+
+ {Array.from(Array(2)).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/app/account/selling/product/[productId]/page.tsx b/app/account/selling/product/[productId]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5261f4313e034c0bd4b3e37d7697332066ceba89 --- /dev/null +++ b/app/account/selling/product/[productId]/page.tsx @@ -0,0 +1,45 @@ +import { ProductEditor } from "@/components/admin/product-editor"; +import { db } from "@/db/db"; +import { products } from "@/db/schema"; +import { + createProduct, + deleteProduct, + updateProduct, +} from "@/server-actions/products"; +import { currentUser } from "@clerk/nextjs"; +import { and, eq } from "drizzle-orm"; + +export default async function ProductDetailPage(props: { + params: { productId: string }; +}) { + const user = await currentUser(); + + const productDetails = await db + .select() + .from(products) + .where( + and( + eq(products.storeId, Number(user?.privateMetadata.storeId)), + eq(products.id, Number(props.params.productId)) + ) + ) + .then((res) => { + if (res.length === 0) { + throw new Error("Product not found or user not authorised"); + } + return res[0]; + }) + .catch((err) => { + console.log(err); + throw new Error(err); + }); + + return ( + <> + + + ); +} diff --git a/app/account/selling/product/layout.tsx b/app/account/selling/product/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1f36d8322daa56655cbba22407e76a99f6ebcac3 --- /dev/null +++ b/app/account/selling/product/layout.tsx @@ -0,0 +1,22 @@ +import { singleLevelNestedRoutes } from "@/lib/routes"; +import { ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { type PropsWithChildren } from "react"; + +export default function ProductLayout(props: PropsWithChildren) { + return ( + <> +
+ + Products + + {" "} + Product Details +
+ {props.children} + + ); +} diff --git a/app/account/selling/product/new/page.tsx b/app/account/selling/product/new/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d6c1299d2491b08f82c1765bc9341662b5c84c38 --- /dev/null +++ b/app/account/selling/product/new/page.tsx @@ -0,0 +1,10 @@ +import { ProductEditor } from "@/components/admin/product-editor"; +import { + createProduct, + deleteProduct, + updateProduct, +} from "@/server-actions/products"; + +export default function NewProductPage() { + return ; +} diff --git a/app/account/selling/products/@modal/(..)product/new/page.tsx b/app/account/selling/products/@modal/(..)product/new/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4d25f8270b04047e7ac83a8593efc19013113dd3 --- /dev/null +++ b/app/account/selling/products/@modal/(..)product/new/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { ProductEditor } from "@/components/admin/product-editor"; +import { AlertDialogContent } from "@/components/ui/alert-dialog"; +import { AlertDialog } from "@radix-ui/react-alert-dialog"; + +export default function NewProductModal() { + return ( + + + + + + ); +} diff --git a/app/account/selling/products/@modal/default.ts b/app/account/selling/products/@modal/default.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ddf1b76fa5c3ce74b26a8c29c6c97bfb2273a43 --- /dev/null +++ b/app/account/selling/products/@modal/default.ts @@ -0,0 +1,3 @@ +export default function Default() { + return null; +} diff --git a/app/account/selling/products/columns.tsx b/app/account/selling/products/columns.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8c8830ae4664b06d8998fc0baea92e6a7b9d4b24 --- /dev/null +++ b/app/account/selling/products/columns.tsx @@ -0,0 +1,123 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { currencyFormatter } from "@/lib/currency"; +import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { routes, secondLevelNestedRoutes } from "@/lib/routes"; +import { ProductImages } from "@/lib/types"; +import { ColumnDef } from "@tanstack/react-table"; +import Link from "next/link"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export type Product = { + id: string; + name: string; + price: number; + inventory: string; + images: ProductImages[]; +}; + +export const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const name = row.original.name; + const id = row.original.id; + return ( + + ); + }, + }, + { + accessorKey: "price", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const price = parseFloat(row.getValue("price")); + return currencyFormatter(price); + }, + }, + { + accessorKey: "inventory", + header: ({ column }) => { + return ( + + ); + }, + }, + { + accessorKey: "images", + header: "Images", + cell: ({ row }) => { + const images = row.getValue("images") as ProductImages[]; + return images.length; + }, + }, + { + id: "actions", + cell: ({ row }) => { + const id = row.original.id; + + return ( + + + + + + Actions + + Edit + + + View on storefront + + + + ); + }, + }, +]; diff --git a/app/account/selling/products/data-table.tsx b/app/account/selling/products/data-table.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5537cc81a6e432a31f95097f2ec33be0b09491ec --- /dev/null +++ b/app/account/selling/products/data-table.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, + getSortedRowModel, + SortingState, + ColumnFiltersState, + getFilteredRowModel, +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableHead, + TableRow, +} from "@/components/ui/table"; +import { Button } from "../../../../components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; +import { XIcon } from "lucide-react"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { pagination: { pageSize: 25 } }, + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + state: { + sorting, + columnFilters, + }, + }); + return ( +
+
+ + table.getColumn("name")?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> + {(table.getColumn("name")?.getFilterValue() as string) && ( + + )} +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ {table.getCanPreviousPage() && ( + + )} + {table.getCanNextPage() && ( + + )} +
+
+ ); +} diff --git a/app/account/selling/products/layout.tsx b/app/account/selling/products/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3a0e6be2e000f75c8986b72c9e2fcae4c903390c --- /dev/null +++ b/app/account/selling/products/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from "react"; + +export default function Layout( + props: PropsWithChildren<{ modal: React.ReactNode }> +) { + return ( + <> + {props.children} + {props.modal} + + ); +} diff --git a/app/account/selling/products/loading.tsx b/app/account/selling/products/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5e2d8791d91596ab9532a1e74216ea5c6828222b --- /dev/null +++ b/app/account/selling/products/loading.tsx @@ -0,0 +1,20 @@ +import { LoadingSkeleton } from "@/components/ui/loading-skeleton"; + +export default function Loading() { + return ( + <> +
+ + +
+
+ +
+
+ {Array.from(Array(6)).map((_, i) => ( + + ))} +
+ + ); +} diff --git a/app/account/selling/products/page.tsx b/app/account/selling/products/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e82bf3d34577346ecd379fb7a880933d85444218 --- /dev/null +++ b/app/account/selling/products/page.tsx @@ -0,0 +1,71 @@ +import { HeadingAndSubheading } from "@/components/admin/heading-and-subheading"; +import { Button } from "@/components/ui/button"; +import { Plus, Store } from "lucide-react"; +import Link from "next/link"; +import { db } from "@/db/db"; +import { products } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { currentUser } from "@clerk/nextjs"; +import { secondLevelNestedRoutes } from "@/lib/routes"; +import { InfoCard } from "@/components/admin/info-card"; +import { DataTable } from "./data-table"; +import { type Product, columns } from "./columns"; + +async function getData(): Promise { + const user = await currentUser(); + // ternary required here as while the layout won't render children if not authed, RSC still seems to run regardless + return !isNaN(Number(user?.privateMetadata.storeId)) + ? ((await db + .select({ + id: products.id, + name: products.name, + price: products.price, + inventory: products.inventory, + images: products.images, + }) + .from(products) + .where(eq(products.storeId, Number(user?.privateMetadata.storeId))) + .catch((err) => { + console.log(err); + return []; + })) as any[]) + : []; +} + +export default async function ProductsPage() { + const productsList = await getData(); + + return ( + <> +
+ + + + +
+ {productsList.length === 0 ? ( + } + button={ + + + + } + /> + ) : ( + <> +
+ +
+ + )} + + ); +} diff --git a/app/account/selling/profile/loading.tsx b/app/account/selling/profile/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e404782e66237186d2cb4d91aa520f71620b1630 --- /dev/null +++ b/app/account/selling/profile/loading.tsx @@ -0,0 +1,19 @@ +import { LoadingSkeleton } from "@/components/ui/loading-skeleton"; + +export default function Loading() { + return ( + <> + +
+
+ + +
+ +
+
+ +
+ + ); +} diff --git a/app/account/selling/profile/page.tsx b/app/account/selling/profile/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..99ea9d59f8ec2b58dd433756dfca45306d4ed32a --- /dev/null +++ b/app/account/selling/profile/page.tsx @@ -0,0 +1,35 @@ +import { HeadingAndSubheading } from "@/components/admin/heading-and-subheading"; +import { EditStoreFields } from "@/components/admin/edit-store-fields"; +import { db } from "@/db/db"; +import { stores } from "@/db/schema"; +import { currentUser } from "@clerk/nextjs"; +import { eq } from "drizzle-orm"; +import { updateStore } from "@/server-actions/store"; + +export default async function SellerProfile() { + const user = await currentUser(); + + const storeDetails = await db + .select() + .from(stores) + .where(eq(stores.id, Number(user?.privateMetadata?.storeId))) + .catch((err) => { + console.log(err); + return null; + }); + + return ( + <> + + {storeDetails && ( + + )} + + ); +} diff --git a/app/api/product/route.ts b/app/api/product/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..9904247009d31d8fe4f3b1dc0590c12dcb9871ed --- /dev/null +++ b/app/api/product/route.ts @@ -0,0 +1,11 @@ +import { createProduct } from "@/server-actions/products"; +import { NextResponse } from "next/server"; + +// this api function exists to wrap the server action so it can be called from client components due to this requirement from Clerk. +// the parallel/intercepted route page is a client component, so it can't call server actions directly and therefore needs this +export async function POST(request: Request) { + const body = await request.json(); + console.log("body", body); + const response = await createProduct(body); + return NextResponse.json(response); +} diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..e73b6d6af8fc80eb0383c91aa02481ba3094fa5e --- /dev/null +++ b/app/api/stripe/webhook/route.ts @@ -0,0 +1,174 @@ +import { addresses, products } from "./../../../../db/schema"; +import { db } from "@/db/db"; +import { carts, orders, payments } from "@/db/schema"; +import { CheckoutItem } from "@/lib/types"; +import { SQL, eq, inArray, sql } from "drizzle-orm"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { Readable } from "stream"; +import Stripe from "stripe"; + +const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; + +async function getRawBody(readable: Readable): Promise { + const chunks = []; + for await (const chunk of readable) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks); +} + +export async function POST(request: Request) { + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2022-11-15", + }); + + const rawBody = await getRawBody(request.body as unknown as Readable); + + const headersList = headers(); + const sig = headersList.get("stripe-signature"); + let event; + try { + event = stripe.webhooks.constructEvent( + rawBody, + sig as string, + endpointSecret as string + ); + } catch (err: any) { + return NextResponse.json( + { error: `Webhook Error: ${err.message}` }, + { status: 400 } + ); + } + + let dbUpdateCartResponse; + // Handle the event + switch (event.type) { + case "payment_intent.payment_failed": + const paymentIntentPaymentFailed = event.data.object; + // Then define and call a function to handle the event payment_intent.payment_failed + break; + case "payment_intent.processing": + const paymentIntentProcessing = event.data.object; + // Then define and call a function to handle the event payment_intent.processing + break; + case "payment_intent.succeeded": + const paymentIntentSucceeded = event.data.object; + // Then define and call a function to handle the event payment_intent.succeeded + // Mark cart as closed in DB + + const stripeObject = event?.data?.object as { + id: string; + amount: string; + metadata: { + cartId: string; + items: string; + }; + shipping: { + name: string; + address: { + line1: string; + line2: string; + city: string; + state: string; + postal_code: string; + country: string; + }; + }; + receipt_email: string; + status: string; + }; + + const paymentIntentId = stripeObject?.id; + const orderTotal = stripeObject?.amount; + + try { + if (!event.account) throw new Error("No account on event"); + const store = await db + .select({ + storeId: payments.storeId, + }) + .from(payments) + .where(eq(payments.stripeAccountId, event.account)); + + const storeId = store[0].storeId as number; + + // create new address in DB + const stripeAddress = stripeObject?.shipping?.address; + + const newAddress = await db.insert(addresses).values({ + line1: stripeAddress?.line1, + line2: stripeAddress?.line2, + city: stripeAddress?.city, + state: stripeAddress?.state, + postal_code: stripeAddress?.postal_code, + country: stripeAddress?.country, + }); + + if (!newAddress[0].insertId) throw new Error("No address created"); + + // get current order count in DB + const storeOrderCount = await db + .select({ count: sql`count(*)` }) + .from(orders) + .where(eq(orders.storeId, storeId)); + + // create new order in DB + const newOrder = await db.insert(orders).values({ + prettyOrderId: Number(storeOrderCount[0].count) + 1, + storeId: storeId, + items: stripeObject.metadata?.items, + total: String(Number(orderTotal) / 100), + stripePaymentIntentId: paymentIntentId, + stripePaymentIntentStatus: stripeObject?.status, + name: stripeObject?.shipping?.name, + email: stripeObject?.receipt_email, + createdAt: event.created, + addressId: Number(newAddress[0].insertId), + }); + console.log("ORDER CREATED", newOrder); + } catch (err) { + console.log("ORDER CREATION WEBHOOK ERROR", err); + } + + // update inventory from DB + // try { + // const orderedItems = JSON.parse( + // stripeObject.metadata?.items + // ) as CheckoutItem[]; + + // for (let index in orderedItems) { + // orderedItems[index].id; + // } + + // orderedItems.forEach((item) => item.id); + + // // UPDATE products SET inventory = inventory - 1 WHERE id = $1 or id = $2 or id = $3 or id = $4 or id = $5; --> [0, 1, 2, 3, 4] + // } catch (err) { + // console.log("INVENTORY UPDATE WEBHOOK ERROR", err); + // } + + try { + // Close cart and clear items + dbUpdateCartResponse = await db + .update(carts) + .set({ + isClosed: true, + items: JSON.stringify([]), + }) + .where(eq(carts.paymentIntentId, paymentIntentId)); + } catch (err) { + console.log("WEBHOOK ERROR", err); + return NextResponse.json( + { response: dbUpdateCartResponse, error: err }, + { status: 500 } + ); + } + + break; + // ... handle other event types + default: + console.log(`Unhandled event type ${event.type}`); + } + return NextResponse.json({ status: 200 }); +} diff --git a/app/api/uploadthing/core.ts b/app/api/uploadthing/core.ts new file mode 100644 index 0000000000000000000000000000000000000000..66004ba35a0cc476e075a42f1b65867530a92bfc --- /dev/null +++ b/app/api/uploadthing/core.ts @@ -0,0 +1,31 @@ +import { currentUser } from "@clerk/nextjs"; +import { createUploadthing, type FileRouter } from "uploadthing/next"; + +const f = createUploadthing(); + +const getUser = async () => await currentUser(); + +// FileRouter for your app, can contain multiple FileRoutes +export const ourFileRouter = { + // Define as many FileRoutes as you like, each with a unique routeSlug + imageUploader: f({ image: { maxFileSize: "1MB", maxFileCount: 5 } }) + // Set permissions and file types for this FileRoute + .middleware(async (req) => { + // This code runs on your server before upload + const user = await getUser(); + + // If you throw, the user will not be able to upload + if (!user) throw new Error("Unauthorized"); + + // Whatever is returned here is accessible in onUploadComplete as `metadata` + return { storeId: user.privateMetadata.storeId }; // add product id metadata here too. + }) + .onUploadComplete(async ({ metadata, file }) => { + // This code RUNS ON YOUR SERVER after upload + console.log("Upload complete for userId:", metadata.storeId); + + console.log("file url", file.url); + }), +} satisfies FileRouter; + +export type OurFileRouter = typeof ourFileRouter; diff --git a/app/api/uploadthing/route.ts b/app/api/uploadthing/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..be94bb0ca0e52790e98bdf616c69e8ec49fcfbd5 --- /dev/null +++ b/app/api/uploadthing/route.ts @@ -0,0 +1,8 @@ +import { createNextRouteHandler } from "uploadthing/next"; + +import { ourFileRouter } from "./core"; + +// Export routes for Next App Router +export const { GET, POST } = createNextRouteHandler({ + router: ourFileRouter, +}); diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5542e4b2b21b9aaa26e87f87d06087c4c4aa71f5 --- /dev/null +++ b/app/auth/layout.tsx @@ -0,0 +1,11 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/app/auth/sign-in/[[...index.ts]]/page.tsx b/app/auth/sign-in/[[...index.ts]]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4f79c7b8f520b6168f5ac858acad23d8e56611e9 --- /dev/null +++ b/app/auth/sign-in/[[...index.ts]]/page.tsx @@ -0,0 +1,11 @@ +import SignInWrapper from "@/components/sign-in"; + +const SignInPage = () => { + return ( + <> + + + ); +}; + +export default SignInPage; diff --git a/app/auth/sign-up/[[...index]]/page.tsx b/app/auth/sign-up/[[...index]]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..700a442886dffade469a1b0227f47b74340ef189 --- /dev/null +++ b/app/auth/sign-up/[[...index]]/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { routes } from "@/lib/routes"; +import { SignUp } from "@clerk/nextjs"; + +const SignUpPage = () => ( + +); + +export default SignUpPage; diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a6b71d3ba8c00dcec09ed89bd60e86d74fa8d862 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Heading } from "@/components/ui/heading"; +import Link from "next/link"; + +export default function GlobalError(props: { + error: Error; + reset: () => void; +}) { + console.log(props.error); + + return ( +
+ Sorry, an error occured loading this page. +

+ Please try again, or contact our customer support team if this issue + persists. +

+
+ + + +
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..5bd6c4ec933249923d76f27628a24f2ab6c4b609 --- /dev/null +++ b/app/globals.css @@ -0,0 +1 @@ +/* @import "../styles/globals.css"; */ diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71283fc60de1446d8c229d227b7e550c5f028e93 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,24 @@ +// app/layout.tsx +import type { Metadata } from "next"; +import { ClerkProvider } from "@clerk/nextjs"; +import "./globals.css"; +import "../styles/globals.css"; + +export const metadata: Metadata = { + title: "ShopSmart", + description: "Multi-store shopping, live better with ShopSmart.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} \ No newline at end of file diff --git a/components/admin/create-new-store.tsx b/components/admin/create-new-store.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e840c98f5753abbf56454df9b7dab6f04805d62e --- /dev/null +++ b/components/admin/create-new-store.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "../ui/button"; +import { Heading } from "../ui/heading"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { useToast } from "../ui/use-toast"; +import { type createStore } from "@/server-actions/store"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +export const CreateNewStore = (props: { createStore: typeof createStore }) => { + const router = useRouter(); + const { toast } = useToast(); + const [storeName, setStoreName] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + props.createStore(storeName).then((res) => { + setIsLoading(false); + if (!res.error) { + setStoreName(""); + router.refresh(); + } + toast({ + title: res.message, + description: res.action, + }); + }); + }; + + return ( +
+
+
+ Create your store +

+ Enter the name of your store below and press create. +

+
+
+ + setStoreName(e.target.value)} + /> +
+
+ +
+
+
+ Why sell on One Stop Shop? +

+ Thousands of visitors visit this site every day, searching for a whole + range of products. Get the exposure your products deserve by creating + a store. +

+
    +
  • Thousands of visitors every day
  • +
  • No monthly fees
  • +
  • 24/7 customer support
  • +
+
+
+ ); +}; diff --git a/components/admin/edit-store-fields.tsx b/components/admin/edit-store-fields.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da30d387af112a63cc33e032c081871e98417149 --- /dev/null +++ b/components/admin/edit-store-fields.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Store } from "@/db/schema"; +import { useState } from "react"; +import { TextInputWithLabel } from "../text-input-with-label"; +import { Button } from "../ui/button"; +import { apiRoutes } from "@/lib/routes"; +import { toast } from "../ui/use-toast"; +import { Loader2 } from "lucide-react"; +import { type updateStore } from "@/server-actions/store"; + +export const EditStoreFields = (props: { + storeDetails: Store; + updateStore: typeof updateStore; +}) => { + const [isLoading, setIsLoading] = useState(false); + const [formValues, setFormValues] = useState>({ + name: props.storeDetails.name, + industry: props.storeDetails.industry, + description: props.storeDetails.description, + }); + + const handleUpdateDetails = (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + props + .updateStore({ + name: formValues.name, + industry: formValues.industry, + description: formValues.description, + }) + .then((res) => { + setIsLoading(false); + toast({ + title: res.message, + description: res.action, + }); + }); + }; + + return ( +
+
+
+ + +
+ +
+
+ +
+
+ ); +}; diff --git a/components/admin/heading-and-subheading.tsx b/components/admin/heading-and-subheading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..23aa9674c48d4e5cbf2532372e4b0fc32075017c --- /dev/null +++ b/components/admin/heading-and-subheading.tsx @@ -0,0 +1,16 @@ +import { cn } from "@/lib/utils"; +import { Heading } from "../ui/heading"; +import { Text } from "../ui/text"; + +export const HeadingAndSubheading = (props: { + heading: string; + subheading: string; + className?: string; +}) => { + return ( +
+ {props.heading} + {props.subheading} +
+ ); +}; diff --git a/components/admin/info-card.tsx b/components/admin/info-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e2b78a3290398c1b89a0990f930f17d5e0b82fad --- /dev/null +++ b/components/admin/info-card.tsx @@ -0,0 +1,20 @@ +import { HeadingAndSubheading } from "./heading-and-subheading"; + +export const InfoCard = (props: { + heading: string; + subheading: string; + icon: React.ReactNode; + button?: React.ReactNode; +}) => { + return ( +
+ {props.icon} + + {props.button} +
+ ); +}; diff --git a/components/admin/payment-connection-status.tsx b/components/admin/payment-connection-status.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6842728c3268f1339f51c2f0299494ebf6f4b5f6 --- /dev/null +++ b/components/admin/payment-connection-status.tsx @@ -0,0 +1,48 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { singleLevelNestedRoutes } from "@/lib/routes"; +import { cn } from "@/lib/utils"; +import { hasConnectedStripeAccount } from "@/server-actions/stripe/account"; +import { AlertCircle, ChevronDown } from "lucide-react"; +import Link from "next/link"; + +export const PaymentConnectionStatus = async () => { + const connectedStripeAccount = await hasConnectedStripeAccount(); + + return ( + + + +

Payments:

+

{connectedStripeAccount ? "Connected" : "Not connected"}

+ +
+ + Payments + + + + Settings + + + Learn more + +
+ ); +}; diff --git a/components/admin/product-editor-elements.tsx b/components/admin/product-editor-elements.tsx new file mode 100644 index 0000000000000000000000000000000000000000..80ae2deb61f4f506a22ca0580b8c9745f9f42172 --- /dev/null +++ b/components/admin/product-editor-elements.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { TextInputWithLabel } from "../text-input-with-label"; +import { Product } from "@/db/schema"; +import { useRouter } from "next/navigation"; +import { Button } from "../ui/button"; +import { secondLevelNestedRoutes, singleLevelNestedRoutes } from "@/lib/routes"; +import { toast } from "../ui/use-toast"; +import { HeadingAndSubheading } from "./heading-and-subheading"; +import { ProductImages } from "@/lib/types"; +import { ProductImageUploader } from "./product-image-uploader"; +import type { deleteProduct, updateProduct } from "@/server-actions/products"; +import { Loader2 } from "lucide-react"; + +const defaultValues = { + name: "", + description: "", + price: "", + inventory: "", + images: [], +}; + +export const ProductEditorElements = (props: { + displayType?: "page" | "modal"; + productStatus: "new-product" | "existing-product"; + productActions: { + updateProduct: typeof updateProduct; + deleteProduct: typeof deleteProduct; + }; + initialValues?: Product; +}) => { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [imagesToDelete, setImagesToDelete] = useState([] as ProductImages[]); + const [newImages, setNewImages] = useState([] as ProductImages[]); + + const [formValues, setFormValues] = useState>( + props.initialValues ?? defaultValues + ); + + const dismissModal = useCallback(() => { + if (props.displayType === "modal") { + router.back(); + } else { + router.push(singleLevelNestedRoutes.account.products); + } + }, [router, props.displayType]); + + const onKeyDown = useCallback( + (e: any) => { + if (e.key === "Escape") dismissModal(); + }, + [dismissModal] + ); + + useEffect(() => { + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [onKeyDown]); + + const handleProductUpdate = async ( + e: + | FormEvent + | React.MouseEvent, + buttonAction?: "delete" + ) => { + e.preventDefault(); + setIsLoading(true); + + let data; + if (buttonAction === "delete") { + // delete product + data = await props.productActions.deleteProduct(props.initialValues?.id); + if (!data.error) { + router.refresh(); + router.push(singleLevelNestedRoutes.account.products); + } + } else if (props.initialValues) { + // update product + const updatedValues = { + ...formValues, + images: [ + ...(props.initialValues?.images as []), + ...(newImages ?? []), + ].filter((item) => imagesToDelete && !imagesToDelete.includes(item)), + } as Omit; + data = await props.productActions.updateProduct(updatedValues); + if (!data.error) { + router.refresh(); + router.push(singleLevelNestedRoutes.account.products); + } + } else { + // create new product + const res = await fetch("/api/product", { + method: "POST", + body: JSON.stringify(formValues), + }); + data = (await res.json()) as unknown as { + error: boolean; + message: string; + action: string; + productId?: string; + }; + console.log(data); + if (data.productId) { + router.push( + `${secondLevelNestedRoutes.product.base}/${data.productId}` + ); + } + setFormValues(defaultValues); + } + setIsLoading(false); + toast({ + title: data.message, + description: data.action, + }); + }; + + return ( + <> + + +
+
+ + + {props.productStatus === "existing-product" && ( + & { + images: ProductImages[]; + } + } + newImages={newImages} + setNewImages={setNewImages} + imagesToDelete={imagesToDelete} + setImagesToDelete={setImagesToDelete} + /> + )} +
+ + +
+
+
+ {!!props.initialValues && ( + + )} +
+ + +
+
+
+ + ); +}; diff --git a/components/admin/product-editor.tsx b/components/admin/product-editor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a0ed560d0957b55681c68cb8e14291bb398b49ec --- /dev/null +++ b/components/admin/product-editor.tsx @@ -0,0 +1,21 @@ +import { deleteProduct, updateProduct } from "@/server-actions/products"; +import { ProductEditorElements } from "./product-editor-elements"; +import { Product } from "@/db/schema"; + +export const ProductEditor = (props: { + displayType?: "page" | "modal"; + productStatus: "new-product" | "existing-product"; + initialValues?: Product; +}) => { + return ( + + ); +}; diff --git a/components/admin/product-image-uploader.tsx b/components/admin/product-image-uploader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9ce3d70e2bf5c492dedf5cfb56b6408515721eda --- /dev/null +++ b/components/admin/product-image-uploader.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useDropzone } from "react-dropzone"; +import type { FileWithPath } from "react-dropzone"; + +import { useUploadThing } from "@/lib/uploadthing-generate-react-helpers"; +import { useCallback, useState } from "react"; +import { Product } from "@/db/schema"; +import { ProductImages } from "@/lib/types"; +import { generateClientDropzoneAccept } from "uploadthing/client"; +import { Label } from "@radix-ui/react-label"; +import Image from "next/image"; +import { XIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { toast } from "../ui/use-toast"; + +export function ProductImageUploader(props: { + product: Omit & { images: ProductImages[] }; + newImages: ProductImages[]; + setNewImages: React.Dispatch>; + imagesToDelete: ProductImages[]; + setImagesToDelete: React.Dispatch>; +}) { + const [files, setFiles] = useState([]); + const onDrop = useCallback((acceptedFiles: FileWithPath[]) => { + setFiles(acceptedFiles); + }, []); + + const { getRootProps, getInputProps } = useDropzone({ + onDrop, + accept: generateClientDropzoneAccept(["image"]), + }); + + const { startUpload, isUploading, permittedFileInfo } = useUploadThing( + "imageUploader", + { + onClientUploadComplete: (data) => { + setFiles([]); + if (!data) return; + props.setNewImages( + data.map((item) => { + return { + url: item.url, + alt: item.key.split("_")[1], + id: item.key, + }; + }) + ); + }, + onUploadError: () => { + toast({ + title: "Sorry, an error occured while uploading your image(s).", + }); + }, + } + ); + + return ( +
+ +
+ {[...props.product.images, ...props.newImages] + .filter((item) => !props.imagesToDelete.includes(item)) + .map((image) => ( +
+
  • + {image.alt + +
  • +
    + ))} +
    +

    + Click to upload + or drag and drop. + + (Max {permittedFileInfo?.config.image?.maxFileSize}) + +

    + +
    +
    + {files.length > 0 && ( +
    + {files.map((file, i) => ( +
  • + {file.name} - {file.size} bytes + {/* */} +
  • + ))} + +
    + )} +
    + ); +} diff --git a/components/announcement-bar.tsx b/components/announcement-bar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6f25916deddd784b91caebd175e23ee874b0640e --- /dev/null +++ b/components/announcement-bar.tsx @@ -0,0 +1,34 @@ +import { PropsWithChildren } from "react"; +import { ContentWrapper } from "./content-wrapper"; +import { cn } from "@/lib/utils"; + +export const AnnouncementBar = ({ + columns, + description, + backgroundColor = "bg-primary", + textColor = "text-secondary", + children, +}: PropsWithChildren<{ + columns: 1 | 2; + description: string; + backgroundColor?: string; + textColor?: string; +}>) => { + return ( +
    + +
    +
    + {description} +
    + {children} +
    +
    +
    + ); +}; diff --git a/components/content-wrapper.tsx b/components/content-wrapper.tsx new file mode 100644 index 0000000000000000000000000000000000000000..52127aeb2a0a0abfa983ea9aca83f94e6f689994 --- /dev/null +++ b/components/content-wrapper.tsx @@ -0,0 +1,11 @@ +import { cn } from "@/lib/utils"; +import { type PropsWithChildren } from "react"; + +export const ContentWrapper = ({ + children, + className, +}: PropsWithChildren<{ className?: string }>) => { + return ( +
    {children}
    + ); +}; diff --git a/components/floating-star.tsx b/components/floating-star.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0e5a70cf42d3fe1fe0b39fc0a99ef46ce400ab4a --- /dev/null +++ b/components/floating-star.tsx @@ -0,0 +1,32 @@ +import { Star } from "lucide-react"; +import { PropsWithChildren } from "react"; + +export const FloatingStar = () => { + return ( +
    +
    + + + +
    +
    + +

    Star project on GitHub

    +
    +
    +
    + ); +}; + +const GitHubLinkWrapper = (props: PropsWithChildren) => { + return ( + + {props.children} + + ); +}; diff --git a/components/footer.tsx b/components/footer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..728fae253f7a5cd1f8785f8ecf7fe0e707b3ac9a --- /dev/null +++ b/components/footer.tsx @@ -0,0 +1,31 @@ +import { GithubIcon } from "lucide-react"; +import { ContentWrapper } from "./content-wrapper"; +import { Logo } from "./logo"; + +export const Footer = () => { + return ( +
    + +
    + +

    Online shopping made easy

    +
    +
    +
    +

    + Fictional online marketplace built by{" "} + + @jackblatch + + . +

    +

    Source code available on GitHub.

    +
    +
    +
    +
    + ); +}; diff --git a/components/icon-with-text.tsx b/components/icon-with-text.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d8fefd31486aaa1f55873c65d2d8d5116d71d887 --- /dev/null +++ b/components/icon-with-text.tsx @@ -0,0 +1,21 @@ +import { Text } from "./ui/text"; + +export const IconWithText = ({ + icon, + headingText, + description, +}: { + icon: React.ReactNode; + headingText: string; + description: string; +}) => { + return ( +
    + {icon} +
    +

    {headingText}

    + {description} +
    +
    + ); +}; diff --git a/components/icons/star.tsx b/components/icons/star.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e7423adfe11cbed2a855b3e32d06ca996b7d03e5 --- /dev/null +++ b/components/icons/star.tsx @@ -0,0 +1,20 @@ +export const StarSVG = () => { + return ( + + + + ); +}; diff --git a/components/line.tsx b/components/line.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e1a8511a7b5a27492e35dd1ce6a252e178e28b9c --- /dev/null +++ b/components/line.tsx @@ -0,0 +1,5 @@ +import { cn } from "@/lib/utils"; + +export const Line = (props: { className?: string }) => { + return
    ; +}; diff --git a/components/logo.tsx b/components/logo.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bddec0890501159ae90bceadc1f7bcdd83a9c8da --- /dev/null +++ b/components/logo.tsx @@ -0,0 +1,7 @@ +export const Logo = () => { + return ( +

    + ShopSmart +

    + ); +}; diff --git a/components/menu-items.tsx b/components/menu-items.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3ba533bf39b71a4cee6c0df6b9fbca25e7d9ee5c --- /dev/null +++ b/components/menu-items.tsx @@ -0,0 +1,134 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; + +import { cn } from "@/lib/utils"; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, +} from "@/components/ui/navigation-menu"; +import { routes } from "@/lib/routes"; + +const components: { title: string; href: string; description: string }[] = [ + { + title: "Tim's Toys", + href: "/", + description: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod lorem ipsum dolor sit amet.", + }, + { + title: "James' Jackpots", + href: "/", + description: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod lorem ipsum dolor sit amet.", + }, + { + title: "Dave's Deals", + href: "/", + description: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod lorem ipsum dolor sit amet.", + }, + { + title: "Tim's Trainers", + href: "/", + description: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod lorem ipsum dolor sit amet.", + }, +]; + +export function MenuItems() { + return ( + + + + + + Products + + + + + Collections + + + + + + {/* Featured Sellers */} + +
      + {components.map((component) => ( + + {component.description} + + ))} +
    +
    +
    +
    +
    + ); +} + +const ListItem = React.forwardRef< + React.ElementRef<"a">, + React.ComponentPropsWithoutRef<"a"> +>(({ className, title, children, ...props }, ref) => { + return ( +
  • + + +
    {title}
    +

    + {children} +

    +
    +
    +
  • + ); +}); +ListItem.displayName = "ListItem"; diff --git a/components/mobile-navigation.tsx b/components/mobile-navigation.tsx new file mode 100644 index 0000000000000000000000000000000000000000..321a3f0be68267c0140e5c96af307d7a22c58765 --- /dev/null +++ b/components/mobile-navigation.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { Menu } from "lucide-react"; +import { routes } from "@/lib/routes"; +import Link from "next/link"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { ProductSearch } from "./storefront/product-search"; +import { ProductImage } from "./product-image"; +import { images } from "@/lib/assets"; +import { Button } from "./ui/button"; +import { useRouter } from "next/navigation"; + +export const MobileNavigation = () => { + const [isMobileNavOpen, setIsMobileNavOpen] = useState(false); + return ( + <> + setIsMobileNavOpen((prev) => !prev)} + > + +
    + +
    +
    + + + Menu + +
    + +
    +
    + + +
    +
    +
    + + ); +}; + +const NavBarLink = (props: { + href: string; + name: string; + image: string; + setIsMobileNavOpen: React.Dispatch>; +}) => { + const router = useRouter(); + return ( +
    + +
    + +
    +
    + ); +}; diff --git a/components/navbar.tsx b/components/navbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7a089c4d87344b093cd7f871b79dc9f1b9887d46 --- /dev/null +++ b/components/navbar.tsx @@ -0,0 +1,93 @@ +import Link from "next/link"; +import { Logo } from "./logo"; +import { ContentWrapper } from "./content-wrapper"; +import { Truck } from "lucide-react"; +import { MenuItems } from "./menu-items"; +import { Line } from "./line"; +import { AnnouncementBar } from "./announcement-bar"; +import { IconWithText } from "./icon-with-text"; +import { routes } from "@/lib/routes"; +import { cn } from "@/lib/utils"; +import { MobileNavigation } from "./mobile-navigation"; +import { ShoppingCartHeader } from "./shopping-cart-header"; +import { ProductSearch } from "./storefront/product-search"; + +export const NavBar = ({ + showSecondAnnouncementBar, +}: { + showSecondAnnouncementBar: boolean; +}) => { + return ( + <> + +
    + + Account + + + Help Centre + +
    +
    + + {showSecondAnnouncementBar && ( + + )} + + + ); +}; diff --git a/components/order-line-items.tsx b/components/order-line-items.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3b54fba0e57470a41b140e7eee52061ee9da81a6 --- /dev/null +++ b/components/order-line-items.tsx @@ -0,0 +1,62 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { currencyFormatter } from "@/lib/currency"; +import { CheckoutItem, OrderItemDetails } from "@/lib/types"; +import { ProductImage } from "@/components/product-image"; +import { Button } from "@/components/ui/button"; + +export const OrderLineItems = (props: { + checkoutItems: CheckoutItem[]; + products: OrderItemDetails[]; +}) => { + return ( + + + + Image + Name + Quantity + Price + + + + {props.products.map((product) => { + const currentProduct = props.checkoutItems.find( + (item) => item.id === product.id + ); + return ( + + + + + + + + {currentProduct?.qty} + + {currencyFormatter(Number(currentProduct?.price))} + + + ); + })} + +
    + ); +}; diff --git a/components/pagination-button.tsx b/components/pagination-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..804ef373f86cf5a80642858ecac8e4df3d29e75b --- /dev/null +++ b/components/pagination-button.tsx @@ -0,0 +1,25 @@ +"use client"; +import { Button } from "./ui/button"; +import { useSearchParams } from "next/navigation"; + +export const PaginationButton = (props: { + pageNumber: number; + searchParamName: string; +}) => { + const searchParams = useSearchParams(); + const param = searchParams.get(props.searchParamName); + + return ( + + ); +}; diff --git a/components/pagination-row.tsx b/components/pagination-row.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ccd7c273015edfde7aac6adf292c36fa92146aee --- /dev/null +++ b/components/pagination-row.tsx @@ -0,0 +1,79 @@ +"use client"; +import Link from "next/link"; +import { PaginationButton } from "./pagination-button"; +import { Button } from "./ui/button"; +import { usePathname, useSearchParams } from "next/navigation"; + +export const PaginationRow = (props: { pagesArray: number[] }) => { + const searchParams = useSearchParams(); + const pageParam = searchParams.get("page"); + const pathname = usePathname(); + const SELLER_PARAMS = searchParams.get("seller") + ? `&seller=${searchParams.get("seller") ?? ""}` + : ""; + + const okToApplyPageCommand = + !isNaN(Number(pageParam)) && + Number(pageParam) - 1 >= 1 && + Number(pageParam) !== props.pagesArray.length && + Number(pageParam) !== props.pagesArray.length - 1; + + return ( +
    + {!isNaN(Number(pageParam)) && Number(pageParam) - 1 >= 1 && ( + + + + )} + {props.pagesArray.length <= 4 + ? props.pagesArray.length > 1 && ( +
    + {props.pagesArray.map((_, i) => ( + + + + ))} +
    + ) + : [ + !!okToApplyPageCommand ? Number(pageParam) - 1 : 1, + !!okToApplyPageCommand ? Number(pageParam) : 2, + props.pagesArray.length - 1, + props.pagesArray.length, + ].map((item, i) => ( +
    + {item === props.pagesArray.length - 1 && + (!!okToApplyPageCommand ? Number(pageParam) : 2) !== + item - 1 &&
    ...
    } + + + +
    + ))} + {!isNaN(Number(pageParam)) && + Number(pageParam) + 1 <= props.pagesArray.length && + props.pagesArray.length > 1 && ( + 2 + ? Number(pageParam) + 1 + : 2 + }${SELLER_PARAMS}`} + > + + + )} +
    + ); +}; diff --git a/components/paragraph-formatter.tsx b/components/paragraph-formatter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1d2c62ca26dc094372ba8c81080bfe7d159fbf53 --- /dev/null +++ b/components/paragraph-formatter.tsx @@ -0,0 +1,16 @@ +import { cn } from "@/lib/utils"; +import { Text } from "./ui/text"; + +export const ParagraphFormatter = (props: { + paragraphs: string | null; + className?: string; +}) => { + if (!props.paragraphs) return null; + return ( +
    + {props.paragraphs?.split("\n").map((item, i) => ( + {item} + ))} +
    + ); +}; diff --git a/components/product-image.tsx b/components/product-image.tsx new file mode 100644 index 0000000000000000000000000000000000000000..88a7b2bc994754a6d3dfc45f009606ae10fe6a23 --- /dev/null +++ b/components/product-image.tsx @@ -0,0 +1,51 @@ +import { cn } from "@/lib/utils"; +import { ImageOff } from "lucide-react"; +import Image from "next/image"; + +export const ProductImage = (props: { + src: string; + alt: string; + sizes?: string; + imageClassName?: string; + wrapperClassName?: string; + height: `h-${string}`; + width: `w-${string}`; +}) => { + return ( + <> + {props.src ? ( +
    + {props.alt} +
    + ) : ( +
    + +
    + )} + + ); +}; diff --git a/components/secondary-menu.tsx b/components/secondary-menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fce08a56e33e461f4ec6637b27f792ba5363fd66 --- /dev/null +++ b/components/secondary-menu.tsx @@ -0,0 +1,45 @@ +"use client"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "./ui/button"; +import Link from "next/link"; + +export type MenuItems = { name: string; href: string; group: Groups }[]; +type Groups = "buying" | "selling"; + +export const SecondaryMenu = (props: { menuItems: MenuItems }) => { + return ( + + + Selling + Buying + + + {menuNames(props.menuItems, "selling")} + + + {menuNames(props.menuItems, "buying")} + + + ); +}; + +const menuNames = (menuItems: MenuItems, group: Groups) => { + return menuItems + .filter((item) => item.group === group) + .map((item, i) => ( + + + + )); +}; diff --git a/components/shopping-cart-header.tsx b/components/shopping-cart-header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..04a8da1a982ccd87f581fa0cab6874a5a7bd3be5 --- /dev/null +++ b/components/shopping-cart-header.tsx @@ -0,0 +1,93 @@ +import { ShoppingCart } from "lucide-react"; +import { cookies } from "next/headers"; +import { + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { getCart } from "@/server-actions/get-cart-details"; +import { CartLineItems } from "./storefront/cart-line-items"; +import { Heading } from "./ui/heading"; +import { Button } from "./ui/button"; +import { routes } from "@/lib/routes"; +import { SheetWrapper } from "./storefront/sheet-wrapper"; +import { EmptyStateWrapper } from "./ui/empty-state-wrapper"; + +export const ShoppingCartHeader = async () => { + const cartId = cookies().get("cartId")?.value; + const { cartItems, uniqueStoreIds, cartItemDetails } = await getCart( + Number(cartId) + ); + + const numberOfCartItems = + !!cartItems && + cartItems.reduce((acc, item) => (acc += Number(item.qty)), 0); + + return ( + + + {numberOfCartItems && numberOfCartItems > 0 ? ( + + {numberOfCartItems} + + ) : null} + + } + buttonRoute={ + numberOfCartItems && numberOfCartItems > 0 + ? routes.cart + : routes.products + } + insideButton={ + + } + > + + + Cart{" "} + {numberOfCartItems && numberOfCartItems > 0 + ? `(${numberOfCartItems})` + : ""} + + + Free shipping on all orders over $50 + + + {numberOfCartItems && numberOfCartItems > 0 ? ( +
    + {uniqueStoreIds.map((storeId, i) => ( +
    + + { + cartItemDetails?.find((item) => item.storeId === storeId) + ?.storeName + } + + item.storeId === storeId) ?? + [] + } + /> +
    + ))} +
    + ) : ( + + Your cart is empty + + )} +
    + ); +}; diff --git a/components/sign-in.tsx b/components/sign-in.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7066e6e10d26f66831b30c4983df5b4823b5fad1 --- /dev/null +++ b/components/sign-in.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { routes } from "@/lib/routes"; +import { SignIn } from "@clerk/nextjs"; + +const SignInWrapper = () => ( + <> + + +); + +export default SignInWrapper; diff --git a/components/slideshow.tsx b/components/slideshow.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fcf82103c8afdabf8a21934f83f40cbc342ba622 --- /dev/null +++ b/components/slideshow.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { images } from "@/lib/assets"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { useEffect, useState } from "react"; +import { Heading } from "./ui/heading"; +import { Button } from "./ui/button"; +import { routes } from "@/lib/routes"; +import Link from "next/link"; + +export const SlideShow = () => { + const [currentImageIndex, setCurrentImageIndex] = useState(0); + const [hasInteracted, setHasInteracted] = useState(false); + + useEffect(() => { + if (hasInteracted) return; + const timer = setTimeout( + () => setCurrentImageIndex((i) => (i === images.length - 1 ? 0 : i + 1)), + 7000 + ); + + return () => clearInterval(timer); + }, [currentImageIndex, hasInteracted]); + + return ( +
    +
    +
    + hero +
    +
    +
    +
    +

    Summer Sale

    +
    +

    + Save up to 50% on our entire range +

    +

    Over 100 products discounted

    +
    + + + +
    +
    +
    +
    +
    + {images.map((_, i) => ( + + ))} +
    +
    + ); +}; + +const Indicator = ({ filled }: { filled: boolean }) => { + return ( +
    + ); +}; + +SlideShow.Indicator = Indicator; diff --git a/components/storefront/cart-line-items.tsx b/components/storefront/cart-line-items.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cfe18362bb5a22cbe315fae2a13b3c1d990b3033 --- /dev/null +++ b/components/storefront/cart-line-items.tsx @@ -0,0 +1,110 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { currencyFormatter } from "@/lib/currency"; +import { routes } from "@/lib/routes"; +import { CartItem, CartLineItemDetails } from "@/lib/types"; +import Link from "next/link"; +import { Button } from "../ui/button"; +import { ProductImage } from "../product-image"; +import { EditCartLineItem } from "./edit-cart-line-item"; + +export const CartLineItems = (props: { + cartItems: CartItem[]; + products: CartLineItemDetails[]; + variant: "cart" | "checkout"; +}) => { + return ( + + + + Image + Name + {props.variant === "cart" ? ( + <> + Price + Quantity + + ) : ( + <> + Quantity + Price + + )} + {props.variant === "cart" ? Total : null} + + + + {props.products.map((product) => { + const currentProductInCart = props.cartItems.find( + (item) => item.id === product.id + ); + return ( + + + + + + {props.variant === "cart" ? ( + + + + ) : ( + + )} + + {props.variant === "cart" ? ( + <> + + {currencyFormatter(Number(product.price))} + + {currentProductInCart?.qty} + + ) : ( + <> + {currentProductInCart?.qty} + + {currencyFormatter(Number(product.price))} + + + )} + {props.variant === "cart" ? ( + + {currencyFormatter( + Number(currentProductInCart?.qty) * Number(product.price) + )} + + ) : null} + {props.variant === "cart" ? ( + + + + ) : null} + + ); + })} + +
    + ); +}; diff --git a/components/storefront/collection-body.tsx b/components/storefront/collection-body.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b7e8157e4bb95509fec0848e1ddf23b7e5811f65 --- /dev/null +++ b/components/storefront/collection-body.tsx @@ -0,0 +1,84 @@ +"use client"; +import { ProductAndStore } from "@/app/(storefront)/(main)/products/page"; +import { ProductSidebar } from "./product-sidebar"; +import { ProductCard } from "./product-card"; +import { PropsWithChildren } from "react"; +import { useSearchParams } from "next/navigation"; +import { Heading } from "../ui/heading"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogFooter, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { SlidersHorizontal } from "lucide-react"; +import { EmptyStateWrapper } from "../ui/empty-state-wrapper"; + +export const CollectionBody = ( + props: PropsWithChildren<{ + storeAndProduct: ProductAndStore[]; + activeSellers: { + id: number; + name: string | null; + slug: string | null; + }[]; + }> +) => { + const searchParams = useSearchParams(); + const seller = searchParams.get("seller"); + const selectedSellers = seller ? [...seller?.split("_")] : []; + + const Sidebar = ( + item.name ?? "") + .filter((item) => item !== "")} + selectedSellers={selectedSellers} + /> + ); + + return ( +
    +
    + {Sidebar} +
    +
    + + +

    Filters

    + +
    + + {Sidebar} + + Close + + +
    +
    + {props.storeAndProduct.length > 0 ? ( +
    + {props.storeAndProduct.map( + (product, i) => + (selectedSellers.includes(product.store.slug ?? "") || + selectedSellers.length === 0) && ( +
    + +
    + ) + )} +
    {props.children}
    +
    + ) : ( + + No products match your filters +

    Change your filters or try again later

    +
    + )} +
    + ); +}; diff --git a/components/storefront/collection-header-wrapper.tsx b/components/storefront/collection-header-wrapper.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f8e153414051ee45727894e6104c57a1573e1deb --- /dev/null +++ b/components/storefront/collection-header-wrapper.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useState, type PropsWithChildren } from "react"; +import { Heading } from "../ui/heading"; +import Image from "next/image"; +import { Button } from "../ui/button"; +import { cn } from "@/lib/utils"; +import { anchorTags } from "@/lib/routes"; +import { LoadingSkeleton } from "../ui/loading-skeleton"; + +const mockImage = + "https://images.unsplash.com/photo-1524758631624-e2822e304c36?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80"; + +export const CollectionHeaderWrapper = ( + props: PropsWithChildren<{ heading: string }> +) => { + const [showMore, setShowMore] = useState(false); + + return ( +
    +
    + mock image +
    +
    + {props.heading} +
    + {!showMore && ( +
    + )} + {props.children} +
    + +
    +
    + ); +}; diff --git a/components/storefront/collection-page-pagination.tsx b/components/storefront/collection-page-pagination.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d21bb80169b97fd04023406e8c8d86c3475de82 --- /dev/null +++ b/components/storefront/collection-page-pagination.tsx @@ -0,0 +1,39 @@ +import { db } from "@/db/db"; +import { products, stores } from "@/db/schema"; +import { PaginationRow } from "../pagination-row"; +import { eq, inArray } from "drizzle-orm"; + +export const CollectionPagePagination = async (props: { + productsPerPage: number; + sellerParams: string; +}) => { + const numberOfProducts = ( + await db + .select({ + product: { + id: products.id, + }, + store: { + slug: stores.slug, + }, + }) + .from(products) + .where(() => { + if (props.sellerParams === undefined || props.sellerParams === "") + return; + return inArray(stores.slug, props.sellerParams.split("_")); + }) + .leftJoin(stores, eq(products.storeId, stores.id)) + ).length; + + const unroundedNumberOfPages = numberOfProducts / props.productsPerPage; + + const numberOfPages = + unroundedNumberOfPages === Math.floor(unroundedNumberOfPages) + ? unroundedNumberOfPages + : Math.floor(unroundedNumberOfPages) + 1; + + return ( + + ); +}; diff --git a/components/storefront/edit-cart-line-item.tsx b/components/storefront/edit-cart-line-item.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b479adba4e791afa67d9661e8fbdd08f9d0fc6b0 --- /dev/null +++ b/components/storefront/edit-cart-line-item.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "../ui/button"; +import { CartItem, CartLineItemDetails } from "@/lib/types"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { updateCart } from "@/server-actions/update-cart"; +import { useState } from "react"; +import { handleInputQuantity } from "@/lib/utils"; +import { toast } from "../ui/use-toast"; + +export const EditCartLineItem = (props: { + productInCart: CartItem | undefined; + product: CartLineItemDetails; +}) => { + const [isOpen, setIsOpen] = useState(false); + const [quantity, setQuantity] = useState( + props.productInCart?.qty ?? 1 + ); + + return ( + <> + + + + + Edit {props.product.name} + + Change the quantity or remove this item from your cart. + + +
    + + setQuantity(e.target.value)} + onBlur={(e) => handleInputQuantity(e, setQuantity, 0)} + /> +
    + + + setIsOpen((prev) => !prev)}> + Cancel + + { + setIsOpen((prev) => !prev); + if (props.productInCart) { + void updateCart({ + ...props.productInCart, + qty: Number(quantity), + }); + toast({ + title: "Cart updated", + description: `${props.product.name} has been updated in your cart.`, + }); + } + }} + > + Update + + +
    +
    + + ); +}; diff --git a/components/storefront/feature-icons.tsx b/components/storefront/feature-icons.tsx new file mode 100644 index 0000000000000000000000000000000000000000..889d4f729536f5980d04a4f0a5c5a2fe2c13734d --- /dev/null +++ b/components/storefront/feature-icons.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/lib/utils"; +import { Phone, RotateCcw, Truck } from "lucide-react"; + +export const FeatureIcons = (props: { className?: string }) => { + return ( +
    +
    + +

    Fast Dispatch

    +
    +
    + +

    30 Day Returns

    +
    +
    + +

    24/7 Support

    +
    +
    + ); +}; diff --git a/components/storefront/product-card.tsx b/components/storefront/product-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..06f88dc07a5787ed6d6f98dacafe69b4ee61b6fc --- /dev/null +++ b/components/storefront/product-card.tsx @@ -0,0 +1,61 @@ +import Image from "next/image"; +import { Text } from "../ui/text"; +import { ImageOff } from "lucide-react"; +import { routes } from "@/lib/routes"; +import Link from "next/link"; +import { currencyFormatter } from "@/lib/currency"; +import { Button } from "../ui/button"; +import { ProductAndStore } from "@/app/(storefront)/(main)/products/page"; +import { ProductImage } from "../product-image"; +import { ProductForm } from "./product-form"; +import { addToCart } from "@/server-actions/add-to-cart"; + +export const ProductCard = (props: { + storeAndProduct: ProductAndStore; + hideButtonActions?: boolean; +}) => { + const productPageLink = `${routes.product}/${props.storeAndProduct.product.id}`; + + return ( +
    + + + + + + {props.storeAndProduct.product.name} + + + {currencyFormatter(Number(props.storeAndProduct.product.price))} + + + {!props.hideButtonActions && ( +
    + + + + +
    + )} +
    + ); +}; diff --git a/components/storefront/product-form.tsx b/components/storefront/product-form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..798171e9195b15caac861e7c172e12bcb8259f1c --- /dev/null +++ b/components/storefront/product-form.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { type addToCart } from "@/server-actions/add-to-cart"; +import { toast } from "../ui/use-toast"; +import { ToastAction } from "../ui/toast"; +import { routes } from "@/lib/routes"; +import { cn, handleInputQuantity } from "@/lib/utils"; +import Link from "next/link"; + +export const ProductForm = (props: { + addToCartAction: typeof addToCart; + availableInventory: string | null; + productId: number; + productName: string | null; + disableQuantitySelector?: boolean; + buttonSize?: "default" | "sm"; +}) => { + const [quantity, setQuantity] = useState(1); + let [isPending, startTransition] = useTransition(); + + return ( +
    + {props.availableInventory && + Number(props.availableInventory) > 0 && + !props.disableQuantitySelector && ( +
    + + setQuantity(e.target.value)} + onBlur={(e) => handleInputQuantity(e, setQuantity)} + type="number" + /> +
    + )} + {props.availableInventory && Number(props.availableInventory) > 0 ? ( + + ) : ( + + )} +
    + ); +}; diff --git a/components/storefront/product-search.tsx b/components/storefront/product-search.tsx new file mode 100644 index 0000000000000000000000000000000000000000..30b663311e9644035a859de56654cdf6eccf643c --- /dev/null +++ b/components/storefront/product-search.tsx @@ -0,0 +1,138 @@ +"use client"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Product } from "@/db/schema"; +import { routes } from "@/lib/routes"; +import { getProductsBySearchTerm } from "@/server-actions/product-search"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Input } from "../ui/input"; +import { Button } from "../ui/button"; +import Image from "next/image"; +import { ProductImages } from "@/lib/types"; +import { ImageOff } from "lucide-react"; +import { currencyFormatter } from "@/lib/currency"; +import { LoadingSkeleton } from "../ui/loading-skeleton"; +import { ProductImage } from "../product-image"; + +export function ProductSearch() { + const [searchTerm, setSearchTerm] = useState(""); + const [results, setResults] = useState< + (Pick & { images: ProductImages[] })[] + >([]); + const [isLoadingResults, setIsLoadingResults] = useState(false); + const [confirmedHasNoResults, setConfirmedHasNoResults] = useState(false); + + const [open, setOpen] = useState(false); + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && e.metaKey) { + e.preventDefault(); + setOpen((open) => !open); + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, []); + + useEffect(() => { + if (searchTerm === "") return setResults([]); + const getData = setTimeout(async () => { + if (searchTerm === "") return; + setIsLoadingResults(true); + setConfirmedHasNoResults(false); + setResults( + await getProductsBySearchTerm(searchTerm) + .then((res) => { + if (!res.length) setConfirmedHasNoResults(true); + return res as unknown as (Pick & { + images: ProductImages[]; + })[]; + }) + .finally(() => setIsLoadingResults(false)) + ); + }, 500); + return () => clearTimeout(getData); + }, [searchTerm]); + + return ( + <> +
    + +
    + { + setOpen(isOpen); + }} + > + + + Search for a product + + Search our entire product catalogue + + + setSearchTerm(e.target.value)} + /> +
    + {isLoadingResults && } + {!results.length && + searchTerm !== "" && + !isLoadingResults && + confirmedHasNoResults &&

    No results found.

    } + {results.map((product) => ( + setOpen(false)} + key={product.id} + className="w-full bg-secondary p-2 rounded-md" + > +
    + +
    + +

    + {currencyFormatter(Number(product.price))} +

    +
    +
    + + ))} +
    +
    +
    + + ); +} diff --git a/components/storefront/product-sidebar.tsx b/components/storefront/product-sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3ff4648b084ce12916efab211e33d56c318079bf --- /dev/null +++ b/components/storefront/product-sidebar.tsx @@ -0,0 +1,136 @@ +"use client"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Heading } from "../ui/heading"; +import React, { useState } from "react"; +import { createSlug } from "@/lib/createSlug"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { Button } from "../ui/button"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { anchorTags } from "@/lib/routes"; + +export const ProductSidebar = (props: { + uniqueStoresList: string[]; + selectedSellers: string[]; +}) => { + const [isSellerListExpanded, setIsSellerListExpanded] = useState(false); + const searchParams = useSearchParams(); + const seller = searchParams.get("seller"); + const pathname = usePathname(); + const router = useRouter(); + + return ( +
    +
    + Filters + {seller && ( + + )} +
    +
    + Sellers + {props.uniqueStoresList.slice(0, 5).map((store, i) => ( + + ))} + {isSellerListExpanded && + props.uniqueStoresList + .slice(5) + .map((store, i) => ( + + ))} + +
    +
    + ); +}; + +const FilterGroup = (props: { heading: string }) => { + return ( +
    + {props.heading} +
    + ); +}; + +const FilterCheckbox = (props: { + label: string; + id: string; + selectedSellers: string[]; +}) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const page = searchParams.get("page"); + const seller = searchParams.get("seller"); + const pathname = usePathname(); + + return ( +
    + { + if (checked) { + router.push( + `${pathname}?page=1&seller=${ + seller ? `${seller}_${props.id}` : props.id + }#${anchorTags.collectionHeader}` + ); + } else { + const filteredSellers = seller + ?.split("_") + .filter((seller) => seller !== props.id); + router.push( + `${pathname}?page=1${ + filteredSellers?.length + ? `&seller=${filteredSellers.join("_")}` + : "" + }#${anchorTags.collectionHeader}` + ); + } + }} + /> + +
    + ); +}; + +ProductSidebar.Group = FilterGroup; +ProductSidebar.Checkbox = FilterCheckbox; diff --git a/components/storefront/quickview-modal-wrapper.tsx b/components/storefront/quickview-modal-wrapper.tsx new file mode 100644 index 0000000000000000000000000000000000000000..701f2e8eb4e140ac8cf08efccba822bb6b3a226c --- /dev/null +++ b/components/storefront/quickview-modal-wrapper.tsx @@ -0,0 +1,33 @@ +"use client"; +import { PropsWithChildren, useCallback, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { XIcon } from "lucide-react"; + +export const QuickViewModalWrapper = (props: PropsWithChildren) => { + const router = useRouter(); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + router.back(); + } + }, + [router] + ); + + useEffect(() => { + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [onKeyDown]); + + return ( +
    +
    + +
    + {props.children} +
    + ); +}; diff --git a/components/storefront/sheet-wrapper.tsx b/components/storefront/sheet-wrapper.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3565893702c52a29ad4a7cbf7621cfb076c8a9bb --- /dev/null +++ b/components/storefront/sheet-wrapper.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useState, type PropsWithChildren } from "react"; +import { Sheet, SheetContent } from "../ui/sheet"; +import { useRouter } from "next/navigation"; + +export const SheetWrapper = ( + props: PropsWithChildren<{ + buttonRoute: string; + insideButton: React.ReactNode; + trigger: React.ReactNode; + }> +) => { + const [isOpen, setIsOpen] = useState(false); + const router = useRouter(); + + return ( + setIsOpen((prev) => !prev)} open={isOpen}> + {props.trigger} + + {props.children} +
    { + setIsOpen(false); + router.push(props.buttonRoute); + }} + > + {props.insideButton} +
    +
    +
    + ); +}; diff --git a/components/text-input-with-label.tsx b/components/text-input-with-label.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f41f5db4b713e1dd55c787ab23603a2bccca8ed6 --- /dev/null +++ b/components/text-input-with-label.tsx @@ -0,0 +1,55 @@ +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { Textarea } from "./ui/textarea"; + +export const TextInputWithLabel = ({ + label, + id, + type, + inputType, + state, + setState, + ...delegated +}: { + label: string; + id: string; + type: string; + inputType?: "input" | "textarea"; + state: Record; + setState: React.Dispatch>; + [x: string]: unknown; +}) => { + return ( +
    + + {inputType === "textarea" ? ( +