├── .eslintrc.json
├── .gitignore
├── README.md
├── components.json
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── prettier.config.js
├── public
└── placeholder.png
├── src
├── app
│ ├── Footer.tsx
│ ├── MainNavigation.tsx
│ ├── MobileMenu.tsx
│ ├── Navbar.tsx
│ ├── ReactQueryProvider.tsx
│ ├── ShoppingCartButton.tsx
│ ├── api
│ │ ├── auth
│ │ │ └── callback
│ │ │ │ └── wix
│ │ │ │ └── route.ts
│ │ └── review-media-upload-url
│ │ │ └── route.ts
│ ├── checkout-success
│ │ ├── ClearCart.tsx
│ │ └── page.tsx
│ ├── collections
│ │ └── [slug]
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ ├── loading.tsx
│ ├── not-found.tsx
│ ├── opengraph-image.png
│ ├── page.tsx
│ ├── products
│ │ ├── [slug]
│ │ │ ├── ProductDetails.tsx
│ │ │ ├── ProductMedia.tsx
│ │ │ ├── ProductOptions.tsx
│ │ │ ├── ProductPrice.tsx
│ │ │ ├── ProductReviews.tsx
│ │ │ ├── loading.tsx
│ │ │ └── page.tsx
│ │ └── id
│ │ │ └── [id]
│ │ │ └── page.tsx
│ ├── profile
│ │ ├── MemberInfoForm.tsx
│ │ ├── Orders.tsx
│ │ └── page.tsx
│ ├── shop
│ │ ├── SearchFilterLayout.tsx
│ │ ├── layout.tsx
│ │ └── page.tsx
│ └── terms
│ │ └── page.tsx
├── assets
│ ├── banner.jpg
│ └── logo.png
├── components
│ ├── AddToCartButton.tsx
│ ├── BackInStockNotificationButton.tsx
│ ├── BuyNowButton.tsx
│ ├── CheckoutButton.tsx
│ ├── DiscountBadge.tsx
│ ├── LoadingButton.tsx
│ ├── Order.tsx
│ ├── PaginationBar.tsx
│ ├── Product.tsx
│ ├── SearchField.tsx
│ ├── UserButton.tsx
│ ├── WixImage.tsx
│ ├── reviews
│ │ ├── CreateProductReviewButton.tsx
│ │ ├── CreateProductReviewDialog.tsx
│ │ ├── StarRatingInput.tsx
│ │ └── useMediaUpload.ts
│ └── ui
│ │ ├── accordion.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── checkbox.tsx
│ │ ├── dialog.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── navigation-menu.tsx
│ │ ├── pagination.tsx
│ │ ├── select.tsx
│ │ ├── sheet.tsx
│ │ ├── skeleton.tsx
│ │ ├── textarea.tsx
│ │ ├── toast.tsx
│ │ ├── toaster.tsx
│ │ └── tooltip.tsx
├── env.ts
├── hooks
│ ├── auth.ts
│ ├── back-in-stock.ts
│ ├── cart.ts
│ ├── checkout.ts
│ ├── members.ts
│ ├── reviews.ts
│ └── use-toast.ts
├── lib
│ ├── constants.ts
│ ├── utils.ts
│ ├── validation.ts
│ ├── wix-client.base.ts
│ ├── wix-client.browser.ts
│ └── wix-client.server.ts
├── middleware.ts
└── wix-api
│ ├── auth.ts
│ ├── backInStockNotifications.ts
│ ├── cart.ts
│ ├── checkout.ts
│ ├── collections.ts
│ ├── members.ts
│ ├── orders.ts
│ ├── products.ts
│ └── reviews.ts
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "next/core-web-vitals",
4 | "prettier",
5 | "plugin:@tanstack/eslint-plugin-query/recommended"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # env files (can opt-in for commiting if needed)
29 | .env*
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next.js 15 Wix Headless E-Commerce Store
2 |
3 | A beautiful and fast e-commerce website built with **Next.js 15** and **Wix Studio Headless APIs**.
4 |
5 | Includes everything an online store needs, like cart management, checkout, user accounts, email automations, and payment provider integration.
6 |
7 | Uses Next.js best practices like server-side rendering, URL state, optimistic updates, and more.
8 |
9 | Watch the tutorial for free: https://www.youtube.com/watch?v=gr--RC_naa0
10 |
11 | 
12 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | }
20 | }
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | experimental: {
4 | staleTimes: {
5 | dynamic: 30,
6 | },
7 | },
8 | };
9 |
10 | export default nextConfig;
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-15-wix-store",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@hookform/resolvers": "^3.9.0",
13 | "@radix-ui/react-accordion": "^1.2.0",
14 | "@radix-ui/react-checkbox": "^1.1.1",
15 | "@radix-ui/react-dialog": "^1.1.1",
16 | "@radix-ui/react-dropdown-menu": "^2.1.1",
17 | "@radix-ui/react-label": "^2.1.0",
18 | "@radix-ui/react-navigation-menu": "^1.2.0",
19 | "@radix-ui/react-select": "^2.1.1",
20 | "@radix-ui/react-slot": "^1.1.0",
21 | "@radix-ui/react-toast": "^1.2.1",
22 | "@radix-ui/react-tooltip": "^1.1.2",
23 | "@t3-oss/env-nextjs": "^0.11.1",
24 | "@tanstack/react-query": "^5.55.4",
25 | "@wix/ecom": "^1.0.727",
26 | "@wix/media": "^1.0.116",
27 | "@wix/members": "^1.0.100",
28 | "@wix/redirects": "^1.0.55",
29 | "@wix/reviews": "^1.0.36",
30 | "@wix/sdk": "^1.12.12",
31 | "@wix/stores": "^1.0.214",
32 | "class-variance-authority": "^0.7.0",
33 | "clsx": "^2.1.1",
34 | "date-fns": "^3.6.0",
35 | "js-cookie": "^3.0.5",
36 | "ky": "^1.7.2",
37 | "lucide-react": "^0.440.0",
38 | "next": "15.0.0-rc.0",
39 | "next-themes": "^0.3.0",
40 | "react": "19.0.0-rc-f994737d14-20240522",
41 | "react-dom": "19.0.0-rc-f994737d14-20240522",
42 | "react-hook-form": "^7.53.0",
43 | "react-medium-image-zoom": "^5.2.9",
44 | "tailwind-merge": "^2.5.2",
45 | "tailwindcss-animate": "^1.0.7",
46 | "zod": "^3.23.8"
47 | },
48 | "devDependencies": {
49 | "@tailwindcss/typography": "^0.5.15",
50 | "@tanstack/eslint-plugin-query": "^5.53.0",
51 | "@tanstack/react-query-devtools": "^5.55.4",
52 | "@types/js-cookie": "^3.0.6",
53 | "@types/node": "^20",
54 | "@types/react": "^18",
55 | "@types/react-dom": "^18",
56 | "eslint": "^8",
57 | "eslint-config-next": "15.0.0-rc.0",
58 | "eslint-config-prettier": "^9.1.0",
59 | "postcss": "^8",
60 | "prettier": "^3.3.3",
61 | "prettier-plugin-tailwindcss": "^0.6.6",
62 | "tailwindcss": "^3.4.1",
63 | "typescript": "^5"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ["prettier-plugin-tailwindcss"],
3 | };
4 |
--------------------------------------------------------------------------------
/public/placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/nextjs-15-wix-store/11f628c1b80eaf71ad010d2d42167e9bb6da10bf/public/placeholder.png
--------------------------------------------------------------------------------
/src/app/MainNavigation.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | NavigationMenu,
5 | NavigationMenuContent,
6 | NavigationMenuItem,
7 | NavigationMenuLink,
8 | NavigationMenuList,
9 | NavigationMenuTrigger,
10 | navigationMenuTriggerStyle,
11 | } from "@/components/ui/navigation-menu";
12 | import { cn } from "@/lib/utils";
13 | import { collections } from "@wix/stores";
14 | import Link from "next/link";
15 |
16 | interface MainNavigationProps {
17 | collections: collections.Collection[];
18 | className?: string;
19 | }
20 |
21 | export default function MainNavigation({
22 | collections,
23 | className,
24 | }: MainNavigationProps) {
25 | return (
26 |
27 |
28 |
29 |
30 |
31 | Shop
32 |
33 |
34 |
35 |
36 | Collections
37 |
38 |
39 | {collections.map((collection) => (
40 |
41 |
46 |
52 | {collection.name}
53 |
54 |
55 |
56 | ))}
57 |
58 |
59 |
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/app/MobileMenu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import SearchField from "@/components/SearchField";
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | Sheet,
7 | SheetContent,
8 | SheetHeader,
9 | SheetTitle,
10 | } from "@/components/ui/sheet";
11 | import UserButton from "@/components/UserButton";
12 | import { twConfig } from "@/lib/utils";
13 | import { members } from "@wix/members";
14 | import { collections } from "@wix/stores";
15 | import { MenuIcon } from "lucide-react";
16 | import Link from "next/link";
17 | import { usePathname, useSearchParams } from "next/navigation";
18 | import { useEffect, useState } from "react";
19 |
20 | interface MobileMenuProps {
21 | collections: collections.Collection[];
22 | loggedInMember: members.Member | null;
23 | }
24 |
25 | export default function MobileMenu({
26 | collections,
27 | loggedInMember,
28 | }: MobileMenuProps) {
29 | const pathname = usePathname();
30 | const searchParams = useSearchParams();
31 |
32 | const [isOpen, setIsOpen] = useState(false);
33 |
34 | useEffect(() => {
35 | const handleResize = () => {
36 | if (window.innerWidth > parseInt(twConfig.theme.screens.lg)) {
37 | setIsOpen(false);
38 | }
39 | };
40 | window.addEventListener("resize", handleResize);
41 | return () => window.removeEventListener("resize", handleResize);
42 | }, []);
43 |
44 | useEffect(() => {
45 | setIsOpen(false);
46 | }, [pathname, searchParams]);
47 |
48 | return (
49 | <>
50 | setIsOpen(true)}
55 | >
56 |
57 |
58 |
59 |
60 |
61 | Navigation
62 |
63 |
64 |
65 |
66 |
67 |
68 | Shop
69 |
70 |
71 | {collections.map((collection) => (
72 |
73 |
77 | {collection.name}
78 |
79 |
80 | ))}
81 |
82 |
83 |
84 |
85 |
86 | >
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/src/app/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import logo from "@/assets/logo.png";
2 | import SearchField from "@/components/SearchField";
3 | import UserButton from "@/components/UserButton";
4 | import { getWixServerClient } from "@/lib/wix-client.server";
5 | import { getCart } from "@/wix-api/cart";
6 | import { getCollections } from "@/wix-api/collections";
7 | import { getLoggedInMember } from "@/wix-api/members";
8 | import Image from "next/image";
9 | import Link from "next/link";
10 | import { Suspense } from "react";
11 | import MainNavigation from "./MainNavigation";
12 | import MobileMenu from "./MobileMenu";
13 | import ShoppingCartButton from "./ShoppingCartButton";
14 |
15 | export default async function Navbar() {
16 | const wixClient = getWixServerClient();
17 |
18 | const [cart, loggedInMember, collections] = await Promise.all([
19 | getCart(wixClient),
20 | getLoggedInMember(wixClient),
21 | getCollections(wixClient),
22 | ]);
23 |
24 | return (
25 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/app/ReactQueryProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
5 | import { useState } from "react";
6 |
7 | export default function ReactQueryProvider({
8 | children,
9 | }: {
10 | children: React.ReactNode;
11 | }) {
12 | const [client] = useState(new QueryClient());
13 |
14 | return (
15 |
16 | {children}
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/ShoppingCartButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import CheckoutButton from "@/components/CheckoutButton";
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | Sheet,
7 | SheetContent,
8 | SheetHeader,
9 | SheetTitle,
10 | } from "@/components/ui/sheet";
11 | import WixImage from "@/components/WixImage";
12 | import {
13 | useCart,
14 | useRemoveCartItem,
15 | useUpdateCartItemQuantity,
16 | } from "@/hooks/cart";
17 | import { currentCart } from "@wix/ecom";
18 | import { Loader2, ShoppingCartIcon, X } from "lucide-react";
19 | import Link from "next/link";
20 | import { useState } from "react";
21 |
22 | interface ShoppingCartButtonProps {
23 | initialData: currentCart.Cart | null;
24 | }
25 |
26 | export default function ShoppingCartButton({
27 | initialData,
28 | }: ShoppingCartButtonProps) {
29 | const [sheetOpen, setSheetOpen] = useState(false);
30 |
31 | const cartQuery = useCart(initialData);
32 |
33 | const totalQuantity =
34 | cartQuery.data?.lineItems?.reduce(
35 | (acc, item) => acc + (item.quantity || 0),
36 | 0,
37 | ) || 0;
38 |
39 | return (
40 | <>
41 |
42 | setSheetOpen(true)}>
43 |
44 |
45 | {totalQuantity < 10 ? totalQuantity : "9+"}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | Your cart{" "}
54 |
55 | ({totalQuantity} {totalQuantity === 1 ? "item" : "items"})
56 |
57 |
58 |
59 |
60 |
61 | {cartQuery.data?.lineItems?.map((item) => (
62 | setSheetOpen(false)}
66 | />
67 | ))}
68 |
69 | {cartQuery.isPending && (
70 |
71 | )}
72 | {cartQuery.error && (
73 |
{cartQuery.error.message}
74 | )}
75 | {!cartQuery.isPending && !cartQuery.data?.lineItems?.length && (
76 |
77 |
78 |
Your cart is empty
79 |
setSheetOpen(false)}
83 | >
84 | Start shopping now
85 |
86 |
87 |
88 | )}
89 |
90 |
91 |
92 |
93 |
Subtotal amount:
94 |
95 | {/* @ts-expect-error */}
96 | {cartQuery.data?.subtotal?.formattedConvertedAmount}
97 |
98 |
99 | Shipping and taxes calculated at checkout
100 |
101 |
102 |
106 |
107 |
108 |
109 | >
110 | );
111 | }
112 |
113 | interface ShoppingCartItemProps {
114 | item: currentCart.LineItem;
115 | onProductLinkClicked: () => void;
116 | }
117 |
118 | function ShoppingCartItem({
119 | item,
120 | onProductLinkClicked,
121 | }: ShoppingCartItemProps) {
122 | const updateQuantityMutation = useUpdateCartItemQuantity();
123 |
124 | const removeItemMutation = useRemoveCartItem();
125 |
126 | const productId = item._id;
127 |
128 | if (!productId) return null;
129 |
130 | const slug = item.url?.split("/").pop();
131 |
132 | const quantityLimitReached =
133 | !!item.quantity &&
134 | !!item.availability?.quantityAvailable &&
135 | item.quantity >= item.availability.quantityAvailable;
136 |
137 | return (
138 |
139 |
140 |
141 |
148 |
149 | removeItemMutation.mutate(productId)}
152 | >
153 |
154 |
155 |
156 |
157 |
158 |
{item.productName?.translated || "Item"}
159 |
160 | {!!item.descriptionLines?.length && (
161 |
162 | {item.descriptionLines
163 | .map(
164 | (line) =>
165 | line.colorInfo?.translated || line.plainText?.translated,
166 | )
167 | .join(", ")}
168 |
169 | )}
170 |
171 | {item.quantity} x {item.price?.formattedConvertedAmount}
172 | {item.fullPrice && item.fullPrice.amount !== item.price?.amount && (
173 |
174 | {item.fullPrice.formattedConvertedAmount}
175 |
176 | )}
177 |
178 |
179 |
184 | updateQuantityMutation.mutate({
185 | productId,
186 | newQuantity: !item.quantity ? 0 : item.quantity - 1,
187 | })
188 | }
189 | >
190 | -
191 |
192 | {item.quantity}
193 |
198 | updateQuantityMutation.mutate({
199 | productId,
200 | newQuantity: !item.quantity ? 1 : item.quantity + 1,
201 | })
202 | }
203 | >
204 | +
205 |
206 | {quantityLimitReached && Quantity limit reached }
207 |
208 |
209 |
210 | );
211 | }
212 |
--------------------------------------------------------------------------------
/src/app/api/auth/callback/wix/route.ts:
--------------------------------------------------------------------------------
1 | import { WIX_OAUTH_DATA_COOKIE, WIX_SESSION_COOKIE } from "@/lib/constants";
2 | import { getWixServerClient } from "@/lib/wix-client.server";
3 | import { OauthData } from "@wix/sdk";
4 | import { cookies } from "next/headers";
5 | import { NextRequest } from "next/server";
6 |
7 | export async function GET(req: NextRequest) {
8 | const code = req.nextUrl.searchParams.get("code");
9 | const state = req.nextUrl.searchParams.get("state");
10 | const error = req.nextUrl.searchParams.get("error");
11 | const error_description = req.nextUrl.searchParams.get("error_description");
12 |
13 | if (error) {
14 | return new Response(error_description, { status: 400 });
15 | }
16 |
17 | const oAuthData: OauthData = JSON.parse(
18 | cookies().get(WIX_OAUTH_DATA_COOKIE)?.value || "{}",
19 | );
20 |
21 | if (!code || !state || !oAuthData) {
22 | return new Response("Invalid request", { status: 400 });
23 | }
24 |
25 | const wixClient = getWixServerClient();
26 |
27 | const memberTokens = await wixClient.auth.getMemberTokens(
28 | code,
29 | state,
30 | oAuthData,
31 | );
32 |
33 | cookies().delete(WIX_OAUTH_DATA_COOKIE);
34 | cookies().set(WIX_SESSION_COOKIE, JSON.stringify(memberTokens), {
35 | maxAge: 60 * 60 * 24 * 14,
36 | secure: process.env.NODE_ENV === "production",
37 | });
38 |
39 | return new Response(null, {
40 | status: 302,
41 | headers: {
42 | Location: oAuthData.originalUri || "/",
43 | },
44 | });
45 | }
46 |
--------------------------------------------------------------------------------
/src/app/api/review-media-upload-url/route.ts:
--------------------------------------------------------------------------------
1 | import { getWixAdminClient } from "@/lib/wix-client.server";
2 | import { NextRequest } from "next/server";
3 |
4 | export async function GET(req: NextRequest) {
5 | const fileName = req.nextUrl.searchParams.get("fileName");
6 | const mimeType = req.nextUrl.searchParams.get("mimeType");
7 |
8 | if (!fileName || !mimeType) {
9 | return new Response("Missing required query parameters", {
10 | status: 400,
11 | });
12 | }
13 |
14 | const { uploadUrl } = await getWixAdminClient().files.generateFileUploadUrl(
15 | mimeType,
16 | {
17 | fileName,
18 | filePath: "product-reviews-media",
19 | private: false,
20 | },
21 | );
22 |
23 | return Response.json({ uploadUrl });
24 | }
25 |
--------------------------------------------------------------------------------
/src/app/checkout-success/ClearCart.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useClearCart } from "@/hooks/cart";
4 | import { useEffect } from "react";
5 |
6 | export default function ClearCart() {
7 | const { mutate } = useClearCart();
8 |
9 | useEffect(mutate, [mutate]);
10 |
11 | return null;
12 | }
13 |
--------------------------------------------------------------------------------
/src/app/checkout-success/page.tsx:
--------------------------------------------------------------------------------
1 | import Order from "@/components/Order";
2 | import { getWixServerClient } from "@/lib/wix-client.server";
3 | import { getLoggedInMember } from "@/wix-api/members";
4 | import { getOrder } from "@/wix-api/orders";
5 | import { Metadata } from "next";
6 | import Link from "next/link";
7 | import { notFound } from "next/navigation";
8 | import ClearCart from "./ClearCart";
9 |
10 | interface PageProps {
11 | searchParams: { orderId: string };
12 | }
13 |
14 | export const metadata: Metadata = {
15 | title: "Checkout success",
16 | };
17 |
18 | export default async function Page({ searchParams: { orderId } }: PageProps) {
19 | const wixClient = getWixServerClient();
20 |
21 | const [order, loggedInMember] = await Promise.all([
22 | getOrder(wixClient, orderId),
23 | getLoggedInMember(wixClient),
24 | ]);
25 |
26 | if (!order) {
27 | notFound();
28 | }
29 |
30 | const orderCreatedDate = order._createdDate
31 | ? new Date(order._createdDate)
32 | : null;
33 |
34 | return (
35 |
36 | We received your order!
37 | A summary of your order was sent to your email address.
38 | Order details
39 |
40 | {loggedInMember && (
41 |
42 | View all your orders
43 |
44 | )}
45 | {orderCreatedDate &&
46 | orderCreatedDate.getTime() > Date.now() - 60_000 * 5 && }
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/app/collections/[slug]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 | import WixImage from "@/components/WixImage";
3 | import { cn } from "@/lib/utils";
4 | import { getWixServerClient } from "@/lib/wix-client.server";
5 | import { getCollectionBySlug } from "@/wix-api/collections";
6 | import { notFound } from "next/navigation";
7 | import { Suspense } from "react";
8 |
9 | interface LayoutProps {
10 | children: React.ReactNode;
11 | params: { slug: string };
12 | }
13 |
14 | export default function Layout({ children, params }: LayoutProps) {
15 | return (
16 | }>
17 | {children}
18 |
19 | );
20 | }
21 |
22 | async function CollectionsLayout({ children, params: { slug } }: LayoutProps) {
23 | const collection = await getCollectionBySlug(getWixServerClient(), slug);
24 |
25 | if (!collection) notFound();
26 |
27 | const banner = collection.media?.mainMedia?.image;
28 |
29 | return (
30 |
31 |
32 | {banner && (
33 |
34 |
40 |
41 |
42 | {collection.name}
43 |
44 |
45 | )}
46 |
52 | {collection.name}
53 |
54 |
55 | {children}
56 |
57 | );
58 | }
59 |
60 | function LoadingSkeleton() {
61 | return (
62 |
63 |
64 |
65 |
Products
66 |
67 | {Array.from({ length: 8 }).map((_, i) => (
68 |
69 | ))}
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/src/app/collections/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import PaginationBar from "@/components/PaginationBar";
2 | import Product from "@/components/Product";
3 | import { Skeleton } from "@/components/ui/skeleton";
4 | import { getWixServerClient } from "@/lib/wix-client.server";
5 | import { getCollectionBySlug } from "@/wix-api/collections";
6 | import { queryProducts } from "@/wix-api/products";
7 | import { Metadata } from "next";
8 | import { notFound } from "next/navigation";
9 | import { Suspense } from "react";
10 |
11 | interface PageProps {
12 | params: { slug: string };
13 | searchParams: { page?: string };
14 | }
15 |
16 | export async function generateMetadata({
17 | params: { slug },
18 | }: PageProps): Promise {
19 | const collection = await getCollectionBySlug(getWixServerClient(), slug);
20 |
21 | if (!collection) notFound();
22 |
23 | const banner = collection.media?.mainMedia?.image;
24 |
25 | return {
26 | title: collection.name,
27 | description: collection.description,
28 | openGraph: {
29 | images: banner ? [{ url: banner.url }] : [],
30 | },
31 | };
32 | }
33 |
34 | export default async function Page({
35 | params: { slug },
36 | searchParams: { page = "1" },
37 | }: PageProps) {
38 | const collection = await getCollectionBySlug(getWixServerClient(), slug);
39 |
40 | if (!collection?._id) notFound();
41 |
42 | return (
43 |
44 |
Products
45 |
} key={page}>
46 |
47 |
48 |
49 | );
50 | }
51 |
52 | interface ProductsProps {
53 | collectionId: string;
54 | page: number;
55 | }
56 |
57 | async function Products({ collectionId, page }: ProductsProps) {
58 | const pageSize = 8;
59 |
60 | const collectionProducts = await queryProducts(getWixServerClient(), {
61 | collectionIds: collectionId,
62 | limit: pageSize,
63 | skip: (page - 1) * pageSize,
64 | });
65 |
66 | if (!collectionProducts.length) notFound();
67 |
68 | if (page > (collectionProducts.totalPages || 1)) notFound();
69 |
70 | return (
71 |
72 |
73 | {collectionProducts.items.map((product) => (
74 |
75 | ))}
76 |
77 |
81 |
82 | );
83 | }
84 |
85 | function LoadingSkeleton() {
86 | return (
87 |
88 | {Array.from({ length: 8 }).map((_, i) => (
89 |
90 | ))}
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/nextjs-15-wix-store/11f628c1b80eaf71ad010d2d42167e9bb6da10bf/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @import "~react-medium-image-zoom/dist/styles.css";
6 |
7 | @layer base {
8 | :root {
9 | --background: 0 0% 100%;
10 | --foreground: 20 14.3% 4.1%;
11 | --card: 0 0% 100%;
12 | --card-foreground: 20 14.3% 4.1%;
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 20 14.3% 4.1%;
15 | --primary: 24.6 95% 53.1%;
16 | --primary-foreground: 60 9.1% 97.8%;
17 | --secondary: 60 4.8% 95.9%;
18 | --secondary-foreground: 24 9.8% 10%;
19 | --muted: 60 4.8% 95.9%;
20 | --muted-foreground: 25 5.3% 44.7%;
21 | --accent: 60 4.8% 95.9%;
22 | --accent-foreground: 24 9.8% 10%;
23 | --destructive: 0 84.2% 60.2%;
24 | --destructive-foreground: 60 9.1% 97.8%;
25 | --border: 20 5.9% 90%;
26 | --input: 20 5.9% 90%;
27 | --ring: 24.6 95% 53.1%;
28 | --radius: 0rem;
29 | --chart-1: 12 76% 61%;
30 | --chart-2: 173 58% 39%;
31 | --chart-3: 197 37% 24%;
32 | --chart-4: 43 74% 66%;
33 | --chart-5: 27 87% 67%;
34 | }
35 |
36 | .dark {
37 | --background: 20 14.3% 4.1%;
38 | --foreground: 60 9.1% 97.8%;
39 | --card: 20 14.3% 4.1%;
40 | --card-foreground: 60 9.1% 97.8%;
41 | --popover: 20 14.3% 4.1%;
42 | --popover-foreground: 60 9.1% 97.8%;
43 | --primary: 20.5 90.2% 48.2%;
44 | --primary-foreground: 60 9.1% 97.8%;
45 | --secondary: 12 6.5% 15.1%;
46 | --secondary-foreground: 60 9.1% 97.8%;
47 | --muted: 12 6.5% 15.1%;
48 | --muted-foreground: 24 5.4% 63.9%;
49 | --accent: 12 6.5% 15.1%;
50 | --accent-foreground: 60 9.1% 97.8%;
51 | --destructive: 0 72.2% 50.6%;
52 | --destructive-foreground: 60 9.1% 97.8%;
53 | --border: 12 6.5% 15.1%;
54 | --input: 12 6.5% 15.1%;
55 | --ring: 20.5 90.2% 48.2%;
56 | --chart-1: 220 70% 50%;
57 | --chart-2: 160 60% 45%;
58 | --chart-3: 30 80% 55%;
59 | --chart-4: 280 65% 60%;
60 | --chart-5: 340 75% 55%;
61 |
62 | [data-rmiz-modal-overlay="visible"] {
63 | background-color: black;
64 | }
65 | }
66 | }
67 |
68 | @layer base {
69 | * {
70 | @apply border-border;
71 | }
72 | body {
73 | @apply bg-background text-foreground;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster } from "@/components/ui/toaster";
2 | import type { Metadata } from "next";
3 | import { ThemeProvider } from "next-themes";
4 | import { Lora } from "next/font/google";
5 | import Footer from "./Footer";
6 | import "./globals.css";
7 | import Navbar from "./Navbar";
8 | import ReactQueryProvider from "./ReactQueryProvider";
9 |
10 | const lora = Lora({ subsets: ["latin"] });
11 |
12 | export const metadata: Metadata = {
13 | title: {
14 | template: "%s | Flow Shop",
15 | absolute: "Flow Shop",
16 | },
17 | description: "A full-stack e-commerce application built with Next.js 15",
18 | };
19 |
20 | export default function RootLayout({
21 | children,
22 | }: Readonly<{
23 | children: React.ReactNode;
24 | }>) {
25 | return (
26 |
27 |
28 |
34 |
35 |
36 | {children}
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/app/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Loader2 } from "lucide-react";
2 |
3 | export default function Loading() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | export default function NotFound() {
2 | return (
3 |
4 | Not Found
5 | The page you are looking for does not exist.
6 |
7 | );
8 | }
9 |
--------------------------------------------------------------------------------
/src/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/nextjs-15-wix-store/11f628c1b80eaf71ad010d2d42167e9bb6da10bf/src/app/opengraph-image.png
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import banner from "@/assets/banner.jpg";
2 | import Product from "@/components/Product";
3 | import { Button } from "@/components/ui/button";
4 | import { Skeleton } from "@/components/ui/skeleton";
5 | import { getWixServerClient } from "@/lib/wix-client.server";
6 | import { getCollectionBySlug } from "@/wix-api/collections";
7 | import { queryProducts } from "@/wix-api/products";
8 | import { ArrowRight } from "lucide-react";
9 | import Image from "next/image";
10 | import Link from "next/link";
11 | import { Suspense } from "react";
12 |
13 | export default function Home() {
14 | return (
15 |
16 |
17 |
18 |
19 | Fill the void in your heart
20 |
21 |
22 | Tough day? Credit card maxed out? Buy some expensive stuff and
23 | become happy again!
24 |
25 |
26 |
27 | Shop Now
28 |
29 |
30 |
31 |
39 |
40 | }>
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | async function FeaturedProducts() {
48 | const wixClient = getWixServerClient();
49 |
50 | const collection = await getCollectionBySlug(wixClient, "featured-products");
51 |
52 | if (!collection?._id) {
53 | return null;
54 | }
55 |
56 | const featuredProducts = await queryProducts(wixClient, {
57 | collectionIds: collection._id,
58 | });
59 |
60 | if (!featuredProducts.items.length) {
61 | return null;
62 | }
63 |
64 | return (
65 |
66 |
Featured Products
67 |
68 | {featuredProducts.items.map((product) => (
69 |
70 | ))}
71 |
72 |
73 | );
74 | }
75 |
76 | function LoadingSkeleton() {
77 | return (
78 |
79 | {Array.from({ length: 8 }).map((_, i) => (
80 |
81 | ))}
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/src/app/products/[slug]/ProductDetails.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import AddToCartButton from "@/components/AddToCartButton";
4 | import BackInStockNotificationButton from "@/components/BackInStockNotificationButton";
5 | import BuyNowButton from "@/components/BuyNowButton";
6 | import {
7 | Accordion,
8 | AccordionContent,
9 | AccordionItem,
10 | AccordionTrigger,
11 | } from "@/components/ui/accordion";
12 | import Badge from "@/components/ui/badge";
13 | import { Input } from "@/components/ui/input";
14 | import { Label } from "@/components/ui/label";
15 | import { checkInStock, findVariant } from "@/lib/utils";
16 | import { products } from "@wix/stores";
17 | import { InfoIcon } from "lucide-react";
18 | import { useState } from "react";
19 | import ProductMedia from "./ProductMedia";
20 | import ProductOptions from "./ProductOptions";
21 | import ProductPrice from "./ProductPrice";
22 |
23 | interface ProductDetailsProps {
24 | product: products.Product;
25 | }
26 |
27 | export default function ProductDetails({ product }: ProductDetailsProps) {
28 | const [quantity, setQuantity] = useState(1);
29 |
30 | const [selectedOptions, setSelectedOptions] = useState<
31 | Record
32 | >(
33 | product.productOptions
34 | ?.map((option) => ({
35 | [option.name || ""]: option.choices?.[0].description || "",
36 | }))
37 | ?.reduce((acc, curr) => ({ ...acc, ...curr }), {}) || {},
38 | );
39 |
40 | const selectedVariant = findVariant(product, selectedOptions);
41 |
42 | const inStock = checkInStock(product, selectedOptions);
43 |
44 | const availableQuantity =
45 | selectedVariant?.stock?.quantity ?? product.stock?.quantity;
46 |
47 | const availableQuantityExceeded =
48 | !!availableQuantity && quantity > availableQuantity;
49 |
50 | const selectedOptionsMedia = product.productOptions?.flatMap((option) => {
51 | const selectedChoice = option.choices?.find(
52 | (choice) => choice.description === selectedOptions[option.name || ""],
53 | );
54 | return selectedChoice?.media?.items ?? [];
55 | });
56 |
57 | return (
58 |
59 |
66 |
67 |
68 |
{product.name}
69 | {product.brand && (
70 |
{product.brand}
71 | )}
72 | {product.ribbon &&
{product.ribbon} }
73 |
74 | {product.description && (
75 |
79 | )}
80 |
81 |
86 |
87 |
Quantity
88 |
89 | setQuantity(Number(e.target.value))}
94 | className="w-24"
95 | disabled={!inStock}
96 | />
97 | {!!availableQuantity &&
98 | (availableQuantityExceeded || availableQuantity < 10) && (
99 |
100 | Only {availableQuantity} left in stock
101 |
102 | )}
103 |
104 |
105 | {inStock ? (
106 |
121 | ) : (
122 |
127 | )}
128 | {!!product.additionalInfoSections?.length && (
129 |
130 |
131 |
132 | Additional product information
133 |
134 |
135 | {product.additionalInfoSections.map((section) => (
136 |
137 | {section.title}
138 |
139 |
145 |
146 |
147 | ))}
148 |
149 |
150 | )}
151 |
152 |
153 | );
154 | }
155 |
--------------------------------------------------------------------------------
/src/app/products/[slug]/ProductMedia.tsx:
--------------------------------------------------------------------------------
1 | import WixImage from "@/components/WixImage";
2 | import { cn } from "@/lib/utils";
3 | import { products } from "@wix/stores";
4 | import { PlayIcon } from "lucide-react";
5 | import { useEffect, useState } from "react";
6 | import Zoom from "react-medium-image-zoom";
7 |
8 | interface ProductMediaProps {
9 | media: products.MediaItem[] | undefined;
10 | }
11 |
12 | export default function ProductMedia({ media }: ProductMediaProps) {
13 | const [selectedMedia, setSelectedMedia] = useState(media?.[0]);
14 |
15 | useEffect(() => {
16 | setSelectedMedia(media?.[0]);
17 | }, [media]);
18 |
19 | if (!media?.length) return null;
20 |
21 | const selectedImage = selectedMedia?.image;
22 | const selectedVideo = selectedMedia?.video?.files?.[0];
23 |
24 | return (
25 |
26 |
27 | {selectedImage?.url ? (
28 |
29 |
35 |
36 | ) : selectedVideo?.url ? (
37 |
38 |
39 |
43 |
44 |
45 | ) : null}
46 |
47 | {media.length > 1 && (
48 |
49 | {media.map((mediaItem) => (
50 | setSelectedMedia(mediaItem)}
55 | />
56 | ))}
57 |
58 | )}
59 |
60 | );
61 | }
62 |
63 | interface MediaPreviewProps {
64 | mediaItem: products.MediaItem;
65 | isSelected: boolean;
66 | onSelect: () => void;
67 | }
68 |
69 | function MediaPreview({ mediaItem, isSelected, onSelect }: MediaPreviewProps) {
70 | const imageUrl = mediaItem.image?.url;
71 | const stillFrameMediaId = mediaItem.video?.stillFrameMediaId;
72 | const thumbnailUrl = mediaItem.thumbnail?.url;
73 | const resolvedThumbnailUrl =
74 | stillFrameMediaId && thumbnailUrl
75 | ? thumbnailUrl.split(stillFrameMediaId)[0] + stillFrameMediaId
76 | : undefined;
77 |
78 | if (!imageUrl && !resolvedThumbnailUrl) return null;
79 |
80 | return (
81 |
87 |
94 | {resolvedThumbnailUrl && (
95 |
96 |
97 |
98 | )}
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/src/app/products/[slug]/ProductOptions.tsx:
--------------------------------------------------------------------------------
1 | import { Label } from "@/components/ui/label";
2 | import { checkInStock, cn } from "@/lib/utils";
3 | import { products } from "@wix/stores";
4 |
5 | interface ProductOptionsProps {
6 | product: products.Product;
7 | selectedOptions: Record;
8 | setSelectedOptions: (options: Record) => void;
9 | }
10 |
11 | export default function ProductOptions({
12 | product,
13 | selectedOptions,
14 | setSelectedOptions,
15 | }: ProductOptionsProps) {
16 | return (
17 |
18 | {product.productOptions?.map((option) => (
19 |
20 |
21 |
22 | {option.name}
23 |
24 |
25 |
26 | {option.choices?.map((choice) => (
27 |
28 |
37 | setSelectedOptions({
38 | ...selectedOptions,
39 | [option.name || ""]: choice.description || "",
40 | })
41 | }
42 | className="peer hidden"
43 | />
44 |
54 | {option.optionType === products.OptionType.color && (
55 |
59 | )}
60 | {choice.description}
61 |
62 |
63 | ))}
64 |
65 |
66 | ))}
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/app/products/[slug]/ProductPrice.tsx:
--------------------------------------------------------------------------------
1 | import DiscountBadge from "@/components/DiscountBadge";
2 | import { cn } from "@/lib/utils";
3 | import { products } from "@wix/stores";
4 |
5 | interface ProductPriceProps {
6 | product: products.Product;
7 | selectedVariant: products.Variant | null;
8 | }
9 |
10 | export default function ProductPrice({
11 | product,
12 | selectedVariant,
13 | }: ProductPriceProps) {
14 | const priceData = selectedVariant?.variant?.priceData || product.priceData;
15 |
16 | if (!priceData) return null;
17 |
18 | const hasDiscount = priceData.discountedPrice !== priceData.price;
19 |
20 | return (
21 |
22 |
23 | {priceData.formatted?.price}
24 |
25 | {hasDiscount && {priceData.formatted?.discountedPrice} }
26 | {product.discount && }
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/products/[slug]/ProductReviews.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import logo from "@/assets/logo.png";
4 | import LoadingButton from "@/components/LoadingButton";
5 | import { Skeleton } from "@/components/ui/skeleton";
6 | import WixImage from "@/components/WixImage";
7 | import { cn } from "@/lib/utils";
8 | import { wixBrowserClient } from "@/lib/wix-client.browser";
9 | import { getProductReviews } from "@/wix-api/reviews";
10 | import { useInfiniteQuery } from "@tanstack/react-query";
11 | import { reviews } from "@wix/reviews";
12 | import { media as wixMedia } from "@wix/sdk";
13 | import { products } from "@wix/stores";
14 | import { CornerDownRight, StarIcon } from "lucide-react";
15 | import Image from "next/image";
16 | import Zoom from "react-medium-image-zoom";
17 |
18 | interface ProductReviewsProps {
19 | product: products.Product;
20 | }
21 |
22 | export default function ProductReviews({ product }: ProductReviewsProps) {
23 | const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
24 | useInfiniteQuery({
25 | queryKey: ["product-reviews", product._id],
26 | queryFn: async ({ pageParam }) => {
27 | if (!product._id) {
28 | throw Error("Product ID missing");
29 | }
30 |
31 | const pageSize = 2;
32 |
33 | return getProductReviews(wixBrowserClient, {
34 | productId: product._id,
35 | limit: pageSize,
36 | cursor: pageParam,
37 | });
38 | },
39 | select: (data) => ({
40 | ...data,
41 | pages: data.pages.map((page) => ({
42 | ...page,
43 | items: page.items.filter(
44 | (item) =>
45 | item.moderation?.moderationStatus ===
46 | reviews.ModerationModerationStatus.APPROVED,
47 | ),
48 | })),
49 | }),
50 | initialPageParam: null as string | null,
51 | getNextPageParam: (lastPage) => lastPage.cursors.next,
52 | });
53 |
54 | const reviewItems = data?.pages.flatMap((page) => page.items) || [];
55 |
56 | return (
57 |
58 | {status === "pending" &&
}
59 | {status === "error" && (
60 |
Error fetching reviews
61 | )}
62 | {status === "success" && !reviewItems.length && !hasNextPage && (
63 |
No reviews yet
64 | )}
65 |
66 | {reviewItems.map((review) => (
67 |
68 | ))}
69 |
70 | {hasNextPage && (
71 |
fetchNextPage()}
74 | >
75 | Load more reviews
76 |
77 | )}
78 |
79 | );
80 | }
81 |
82 | interface ReviewProps {
83 | review: reviews.Review;
84 | }
85 |
86 | function Review({
87 | review: { author, reviewDate, content, reply },
88 | }: ReviewProps) {
89 | return (
90 |
91 |
92 |
93 | {Array.from({ length: 5 }).map((_, i) => (
94 |
101 | ))}
102 | {content?.title &&
{content.title} }
103 |
104 |
105 | by {author?.authorName || "Anonymous"}
106 | {reviewDate && <> on {new Date(reviewDate).toLocaleDateString()}>}
107 |
108 | {content?.body && (
109 |
{content.body}
110 | )}
111 | {!!content?.media?.length && (
112 |
113 | {content.media.map((media) => (
114 |
115 | ))}
116 |
117 | )}
118 |
119 | {reply?.message && (
120 |
121 |
122 |
123 |
130 | Flow Shop Team
131 |
132 |
{reply.message}
133 |
134 | )}
135 |
136 | );
137 | }
138 |
139 | export function ProductReviewsLoadingSkeleton() {
140 | return (
141 |
142 | {Array.from({ length: 2 }).map((_, i) => (
143 |
144 |
145 |
146 |
147 |
148 | ))}
149 |
150 | );
151 | }
152 |
153 | interface MediaAttachmentProps {
154 | media: reviews.Media;
155 | }
156 |
157 | function MediaAttachment({ media }: MediaAttachmentProps) {
158 | if (media.image) {
159 | return (
160 |
161 |
167 |
168 | );
169 | }
170 |
171 | if (media.video) {
172 | return (
173 |
174 |
175 |
176 | );
177 | }
178 |
179 | return Unsupported media type ;
180 | }
181 |
--------------------------------------------------------------------------------
/src/app/products/[slug]/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 |
3 | export default function Loading() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/products/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import Product from "@/components/Product";
2 | import CreateProductReviewButton from "@/components/reviews/CreateProductReviewButton";
3 | import { Skeleton } from "@/components/ui/skeleton";
4 | import { getWixServerClient } from "@/lib/wix-client.server";
5 | import { getLoggedInMember } from "@/wix-api/members";
6 | import { getProductBySlug, getRelatedProducts } from "@/wix-api/products";
7 | import { getProductReviews } from "@/wix-api/reviews";
8 | import { products } from "@wix/stores";
9 | import { Metadata } from "next";
10 | import { notFound } from "next/navigation";
11 | import { Suspense } from "react";
12 | import ProductDetails from "./ProductDetails";
13 | import ProductReviews, {
14 | ProductReviewsLoadingSkeleton,
15 | } from "./ProductReviews";
16 |
17 | interface PageProps {
18 | params: { slug: string };
19 | }
20 |
21 | export async function generateMetadata({
22 | params: { slug },
23 | }: PageProps): Promise {
24 | const product = await getProductBySlug(getWixServerClient(), slug);
25 |
26 | if (!product) notFound();
27 |
28 | const mainImage = product.media?.mainMedia?.image;
29 |
30 | return {
31 | title: product.name,
32 | description: "Get this product on Flow Shop",
33 | openGraph: {
34 | images: mainImage?.url
35 | ? [
36 | {
37 | url: mainImage.url,
38 | width: mainImage.width,
39 | height: mainImage.height,
40 | alt: mainImage.altText || "",
41 | },
42 | ]
43 | : undefined,
44 | },
45 | };
46 | }
47 |
48 | export default async function Page({ params: { slug } }: PageProps) {
49 | const product = await getProductBySlug(getWixServerClient(), slug);
50 |
51 | if (!product?._id) notFound();
52 |
53 | return (
54 |
55 |
56 |
57 | }>
58 |
59 |
60 |
61 |
62 |
Buyer reviews
63 |
}>
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
71 | interface RelatedProductsProps {
72 | productId: string;
73 | }
74 |
75 | async function RelatedProducts({ productId }: RelatedProductsProps) {
76 | const relatedProducts = await getRelatedProducts(
77 | getWixServerClient(),
78 | productId,
79 | );
80 |
81 | if (!relatedProducts.length) return null;
82 |
83 | return (
84 |
85 |
Related Products
86 |
87 | {relatedProducts.map((product) => (
88 |
89 | ))}
90 |
91 |
92 | );
93 | }
94 |
95 | function RelatedProductsLoadingSkeleton() {
96 | return (
97 |
98 | {Array.from({ length: 4 }).map((_, i) => (
99 |
100 | ))}
101 |
102 | );
103 | }
104 |
105 | interface ProductReviewsSectionProps {
106 | product: products.Product;
107 | }
108 |
109 | async function ProductReviewsSection({ product }: ProductReviewsSectionProps) {
110 | if (!product._id) return null;
111 |
112 | const wixClient = getWixServerClient();
113 |
114 | const loggedInMember = await getLoggedInMember(wixClient);
115 |
116 | const existingReview = loggedInMember?.contactId
117 | ? (
118 | await getProductReviews(wixClient, {
119 | productId: product._id,
120 | contactId: loggedInMember.contactId,
121 | })
122 | ).items[0]
123 | : null;
124 |
125 | return (
126 |
134 | );
135 | }
136 |
--------------------------------------------------------------------------------
/src/app/products/id/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { getWixServerClient } from "@/lib/wix-client.server";
2 | import { getProductById } from "@/wix-api/products";
3 | import { notFound, redirect } from "next/navigation";
4 |
5 | interface PageProps {
6 | params: { id: string };
7 | searchParams: any;
8 | }
9 |
10 | export default async function Page({ params, searchParams }: PageProps) {
11 | if (params.id === "someId") {
12 | redirect(`/products/i-m-a-product-1?${new URLSearchParams(searchParams)}`);
13 | }
14 |
15 | const product = await getProductById(getWixServerClient(), params.id);
16 |
17 | if (!product) notFound();
18 |
19 | redirect(`/products/${product.slug}?${new URLSearchParams(searchParams)}`);
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/profile/MemberInfoForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import LoadingButton from "@/components/LoadingButton";
4 | import {
5 | Form,
6 | FormControl,
7 | FormField,
8 | FormItem,
9 | FormLabel,
10 | FormMessage,
11 | } from "@/components/ui/form";
12 | import { Input } from "@/components/ui/input";
13 | import { useUpdateMember } from "@/hooks/members";
14 | import { requiredString } from "@/lib/validation";
15 | import { zodResolver } from "@hookform/resolvers/zod";
16 | import { members } from "@wix/members";
17 | import { useForm } from "react-hook-form";
18 | import { z } from "zod";
19 |
20 | const formSchema = z.object({
21 | loginEmail: requiredString,
22 | firstName: z.string(),
23 | lastName: z.string(),
24 | });
25 |
26 | type FormValues = z.infer;
27 |
28 | interface MemberInfoFormProps {
29 | member: members.Member;
30 | }
31 |
32 | export default function MemberInfoForm({ member }: MemberInfoFormProps) {
33 | const form = useForm({
34 | resolver: zodResolver(formSchema),
35 | defaultValues: {
36 | loginEmail: member.loginEmail || "",
37 | firstName: member.contact?.firstName || "",
38 | lastName: member.contact?.lastName || "",
39 | },
40 | });
41 |
42 | const mutation = useUpdateMember();
43 |
44 | function onSubmit(values: FormValues) {
45 | mutation.mutate(values);
46 | }
47 |
48 | return (
49 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/src/app/profile/Orders.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import LoadingButton from "@/components/LoadingButton";
4 | import Order from "@/components/Order";
5 | import { Skeleton } from "@/components/ui/skeleton";
6 | import { wixBrowserClient } from "@/lib/wix-client.browser";
7 | import { getUserOrders } from "@/wix-api/orders";
8 | import { useInfiniteQuery } from "@tanstack/react-query";
9 |
10 | export default function Orders() {
11 | const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
12 | useInfiniteQuery({
13 | queryKey: ["orders"],
14 | queryFn: async ({ pageParam }) =>
15 | getUserOrders(wixBrowserClient, {
16 | limit: 2,
17 | cursor: pageParam,
18 | }),
19 | initialPageParam: null as string | null,
20 | getNextPageParam: (lastPage) => lastPage.metadata?.cursors?.next,
21 | });
22 |
23 | const orders = data?.pages.flatMap((page) => page.orders) || [];
24 |
25 | return (
26 |
27 |
Your orders
28 | {status === "pending" &&
}
29 | {status === "error" && (
30 |
Error fetching orders
31 | )}
32 | {status === "success" && !orders.length && !hasNextPage && (
33 |
No orders yet
34 | )}
35 | {orders.map((order) => (
36 |
37 | ))}
38 | {hasNextPage && (
39 |
fetchNextPage()}
42 | >
43 | Load more orders
44 |
45 | )}
46 |
47 | );
48 | }
49 |
50 | function OrdersLoadingSkeleton() {
51 | return (
52 |
53 | {Array.from({ length: 2 }).map((_, i) => (
54 |
55 | ))}
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/app/profile/page.tsx:
--------------------------------------------------------------------------------
1 | import { getWixServerClient } from "@/lib/wix-client.server";
2 | import { getLoggedInMember } from "@/wix-api/members";
3 | import { Metadata } from "next";
4 | import { notFound } from "next/navigation";
5 | import MemberInfoForm from "./MemberInfoForm";
6 | import Orders from "./Orders";
7 |
8 | export const metadata: Metadata = {
9 | title: "Profile",
10 | description: "Your profile page",
11 | };
12 |
13 | export default async function Page() {
14 | const member = await getLoggedInMember(getWixServerClient());
15 |
16 | if (!member) notFound();
17 |
18 | return (
19 |
20 |
21 | Your profile
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/shop/SearchFilterLayout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Checkbox } from "@/components/ui/checkbox";
5 | import { Input } from "@/components/ui/input";
6 | import {
7 | Select,
8 | SelectContent,
9 | SelectItem,
10 | SelectTrigger,
11 | SelectValue,
12 | } from "@/components/ui/select";
13 | import { ProductsSort } from "@/wix-api/products";
14 | import { collections } from "@wix/stores";
15 | import { useRouter, useSearchParams } from "next/navigation";
16 | import { useEffect, useOptimistic, useState, useTransition } from "react";
17 |
18 | interface SearchFilterLayoutProps {
19 | collections: collections.Collection[];
20 | children: React.ReactNode;
21 | }
22 |
23 | export default function SearchFilterLayout({
24 | collections,
25 | children,
26 | }: SearchFilterLayoutProps) {
27 | const router = useRouter();
28 | const searchParams = useSearchParams();
29 |
30 | const [optimisticFilters, setOptimisticFilters] = useOptimistic({
31 | collection: searchParams.getAll("collection"),
32 | price_min: searchParams.get("price_min") || undefined,
33 | price_max: searchParams.get("price_max") || undefined,
34 | sort: searchParams.get("sort") || undefined,
35 | });
36 |
37 | const [isPending, startTransition] = useTransition();
38 |
39 | function updateFilters(updates: Partial) {
40 | const newState = { ...optimisticFilters, ...updates };
41 | const newSearchParams = new URLSearchParams(searchParams);
42 |
43 | Object.entries(newState).forEach(([key, value]) => {
44 | newSearchParams.delete(key);
45 |
46 | if (Array.isArray(value)) {
47 | value.forEach((v) => newSearchParams.append(key, v));
48 | } else if (value) {
49 | newSearchParams.set(key, value);
50 | }
51 | });
52 |
53 | newSearchParams.delete("page");
54 |
55 | startTransition(() => {
56 | setOptimisticFilters(newState);
57 | router.push(`?${newSearchParams.toString()}`);
58 | });
59 | }
60 |
61 | return (
62 |
63 |
67 |
71 | updateFilters({ collection: collectionIds })
72 | }
73 | />
74 |
78 | updateFilters({
79 | price_min: priceMin,
80 | price_max: priceMax,
81 | })
82 | }
83 | />
84 |
85 |
86 |
87 | updateFilters({ sort })}
90 | />
91 |
92 | {children}
93 |
94 |
95 | );
96 | }
97 |
98 | interface CollectionsFilterProps {
99 | collections: collections.Collection[];
100 | selectedCollectionIds: string[];
101 | updateCollectionIds: (collectionIds: string[]) => void;
102 | }
103 |
104 | function CollectionsFilter({
105 | collections,
106 | selectedCollectionIds,
107 | updateCollectionIds,
108 | }: CollectionsFilterProps) {
109 | return (
110 |
111 |
Collections
112 |
113 | {collections.map((collection) => {
114 | const collectionId = collection._id;
115 | if (!collectionId) return null;
116 | return (
117 |
118 |
119 | {
123 | updateCollectionIds(
124 | checked
125 | ? [...selectedCollectionIds, collectionId]
126 | : selectedCollectionIds.filter(
127 | (id) => id !== collectionId,
128 | ),
129 | );
130 | }}
131 | />
132 |
133 | {collection.name}
134 |
135 |
136 |
137 | );
138 | })}
139 |
140 | {selectedCollectionIds.length > 0 && (
141 |
updateCollectionIds([])}
143 | className="text-sm text-primary hover:underline"
144 | >
145 | Clear
146 |
147 | )}
148 |
149 | );
150 | }
151 |
152 | interface PriceFilterProps {
153 | minDefaultInput: string | undefined;
154 | maxDefaultInput: string | undefined;
155 | updatePriceRange: (min: string | undefined, max: string | undefined) => void;
156 | }
157 |
158 | function PriceFilter({
159 | minDefaultInput,
160 | maxDefaultInput,
161 | updatePriceRange,
162 | }: PriceFilterProps) {
163 | const [minInput, setMinInput] = useState(minDefaultInput);
164 | const [maxInput, setMaxInput] = useState(maxDefaultInput);
165 |
166 | useEffect(() => {
167 | setMinInput(minDefaultInput || "");
168 | setMaxInput(maxDefaultInput || "");
169 | }, [minDefaultInput, maxDefaultInput]);
170 |
171 | function onSubmit(e: React.FormEvent) {
172 | e.preventDefault();
173 | updatePriceRange(minInput, maxInput);
174 | }
175 |
176 | return (
177 |
178 |
Price range
179 |
199 | {(!!minDefaultInput || !!maxDefaultInput) && (
200 |
updatePriceRange(undefined, undefined)}
202 | className="text-sm text-primary hover:underline"
203 | >
204 | Clear
205 |
206 | )}
207 |
208 | );
209 | }
210 |
211 | interface SortFilterProps {
212 | sort: string | undefined;
213 | updateSort: (value: ProductsSort) => void;
214 | }
215 |
216 | function SortFilter({ sort, updateSort }: SortFilterProps) {
217 | return (
218 |
219 |
220 |
221 | Sort by:
222 |
223 |
224 |
225 | Newest
226 | Price (Low to high)
227 | Price (High to low)
228 |
229 |
230 | );
231 | }
232 |
--------------------------------------------------------------------------------
/src/app/shop/layout.tsx:
--------------------------------------------------------------------------------
1 | import { getWixServerClient } from "@/lib/wix-client.server";
2 | import { getCollections } from "@/wix-api/collections";
3 | import SearchFilterLayout from "./SearchFilterLayout";
4 |
5 | export default async function Layout({
6 | children,
7 | }: {
8 | children: React.ReactNode;
9 | }) {
10 | const collections = await getCollections(getWixServerClient());
11 |
12 | return (
13 |
14 | {children}
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/shop/page.tsx:
--------------------------------------------------------------------------------
1 | import PaginationBar from "@/components/PaginationBar";
2 | import Product from "@/components/Product";
3 | import { Skeleton } from "@/components/ui/skeleton";
4 | import { getWixServerClient } from "@/lib/wix-client.server";
5 | import { ProductsSort, queryProducts } from "@/wix-api/products";
6 | import { Metadata } from "next";
7 | import { notFound } from "next/navigation";
8 | import { Suspense } from "react";
9 |
10 | interface PageProps {
11 | searchParams: {
12 | q?: string;
13 | page?: string;
14 | collection?: string[];
15 | price_min?: string;
16 | price_max?: string;
17 | sort?: string;
18 | };
19 | }
20 |
21 | export function generateMetadata({ searchParams: { q } }: PageProps): Metadata {
22 | return {
23 | title: q ? `Results for "${q}"` : "Products",
24 | };
25 | }
26 |
27 | export default async function Page({
28 | searchParams: {
29 | q,
30 | page = "1",
31 | collection: collectionIds,
32 | price_min,
33 | price_max,
34 | sort,
35 | },
36 | }: PageProps) {
37 | const title = q ? `Results for "${q}"` : "Products";
38 |
39 | return (
40 |
41 |
{title}
42 |
} key={`${q}-${page}`}>
43 |
51 |
52 |
53 | );
54 | }
55 |
56 | interface ProductResultsProps {
57 | q?: string;
58 | page: number;
59 | collectionIds?: string[];
60 | priceMin?: number;
61 | priceMax?: number;
62 | sort?: ProductsSort;
63 | }
64 |
65 | async function ProductResults({
66 | q,
67 | page,
68 | collectionIds,
69 | priceMin,
70 | priceMax,
71 | sort,
72 | }: ProductResultsProps) {
73 | const pageSize = 8;
74 |
75 | const products = await queryProducts(getWixServerClient(), {
76 | q,
77 | limit: pageSize,
78 | skip: (page - 1) * pageSize,
79 | collectionIds,
80 | priceMin,
81 | priceMax,
82 | sort,
83 | });
84 |
85 | if (page > (products.totalPages || 1)) notFound();
86 |
87 | return (
88 |
89 |
90 | {products.totalCount}{" "}
91 | {products.totalCount === 1 ? "product" : "products"} found
92 |
93 |
94 | {products.items.map((product) => (
95 |
96 | ))}
97 |
98 |
99 |
100 | );
101 | }
102 |
103 | function LoadingSkeleton() {
104 | return (
105 |
106 |
107 |
108 | {Array.from({ length: 8 }).map((_, i) => (
109 |
110 | ))}
111 |
112 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/src/app/terms/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next";
2 |
3 | export const metadata: Metadata = {
4 | title: "Terms and Conditions",
5 | description: "Terms and conditions for using our services",
6 | };
7 |
8 | export default function Page() {
9 | return (
10 |
11 |
12 |
CUSTOMER CARE
13 |
14 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
15 | eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
16 | minim veniam, quis nostrud exercitation ullamco laboris nisi ut
17 | aliquip ex ea commodo consequat. Duis aute irure dolor in
18 | reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
19 | pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
20 | culpa qui officia deserunt mollit anim id est laborum.
21 |
22 |
23 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem
24 | accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae
25 | ab illo inventore veritatis et quasi architecto beatae vitae dicta
26 | sunt explicabo.
27 |
28 |
PRIVACY & SAFETY
29 |
30 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
31 | eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
32 | minim veniam, quis nostrud exercitation ullamco laboris nisi ut
33 | aliquip ex ea commodo consequat. Duis aute irure dolor in
34 | reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
35 | pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
36 | culpa qui officia deserunt mollit anim id est laborum.
37 |
38 |
39 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem
40 | accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae
41 | ab illo inventore veritatis et quasi architecto beatae vitae dicta
42 | sunt explicabo.
43 |
44 |
WHOLESALE INQUIRIES
45 |
46 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
47 | eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
48 | minim veniam, quis nostrud exercitation ullamco laboris nisi ut
49 | aliquip ex ea commodo consequat. Duis aute irure dolor in
50 | reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
51 | pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
52 | culpa qui officia deserunt mollit anim id est laborum.
53 |
54 |
55 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem
56 | accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae
57 | ab illo inventore veritatis et quasi architecto beatae vitae dicta
58 | sunt explicabo.
59 |
60 |
PAYMENT METHODS
61 |
62 | - Credit / Debit Cards
63 | - PAYPAL
64 | - Offline Payments
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/assets/banner.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/nextjs-15-wix-store/11f628c1b80eaf71ad010d2d42167e9bb6da10bf/src/assets/banner.jpg
--------------------------------------------------------------------------------
/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/nextjs-15-wix-store/11f628c1b80eaf71ad010d2d42167e9bb6da10bf/src/assets/logo.png
--------------------------------------------------------------------------------
/src/components/AddToCartButton.tsx:
--------------------------------------------------------------------------------
1 | import { useAddItemToCart } from "@/hooks/cart";
2 | import { cn } from "@/lib/utils";
3 | import { products } from "@wix/stores";
4 | import { ShoppingCartIcon } from "lucide-react";
5 | import LoadingButton from "./LoadingButton";
6 | import { ButtonProps } from "./ui/button";
7 |
8 | interface AddToCartButtonProps extends ButtonProps {
9 | product: products.Product;
10 | selectedOptions: Record;
11 | quantity: number;
12 | }
13 |
14 | export default function AddToCartButton({
15 | product,
16 | selectedOptions,
17 | quantity,
18 | className,
19 | ...props
20 | }: AddToCartButtonProps) {
21 | const mutation = useAddItemToCart();
22 |
23 | return (
24 |
26 | mutation.mutate({
27 | product,
28 | selectedOptions,
29 | quantity,
30 | })
31 | }
32 | loading={mutation.isPending}
33 | className={cn("flex gap-3", className)}
34 | {...props}
35 | >
36 |
37 | Add to cart
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/BackInStockNotificationButton.tsx:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import { useCreateBackInStockNotificationRequest } from "@/hooks/back-in-stock";
3 | import { requiredString } from "@/lib/validation";
4 | import { zodResolver } from "@hookform/resolvers/zod";
5 | import { products } from "@wix/stores";
6 | import { useForm } from "react-hook-form";
7 | import { z } from "zod";
8 | import LoadingButton from "./LoadingButton";
9 | import { Button, ButtonProps } from "./ui/button";
10 | import {
11 | Dialog,
12 | DialogContent,
13 | DialogDescription,
14 | DialogHeader,
15 | DialogTitle,
16 | DialogTrigger,
17 | } from "./ui/dialog";
18 | import {
19 | Form,
20 | FormControl,
21 | FormField,
22 | FormItem,
23 | FormLabel,
24 | FormMessage,
25 | } from "./ui/form";
26 | import { Input } from "./ui/input";
27 |
28 | const formSchema = z.object({
29 | email: requiredString.email(),
30 | });
31 |
32 | type FormValues = z.infer;
33 |
34 | interface BackInStockNotificationButtonProps extends ButtonProps {
35 | product: products.Product;
36 | selectedOptions: Record;
37 | }
38 |
39 | export default function BackInStockNotificationButton({
40 | product,
41 | selectedOptions,
42 | ...props
43 | }: BackInStockNotificationButtonProps) {
44 | const form = useForm({
45 | resolver: zodResolver(formSchema),
46 | defaultValues: {
47 | email: "",
48 | },
49 | });
50 |
51 | const mutation = useCreateBackInStockNotificationRequest();
52 |
53 | async function onSubmit({ email }: FormValues) {
54 | mutation.mutate({
55 | email,
56 | itemUrl: env.NEXT_PUBLIC_BASE_URL + "/products/" + product.slug,
57 | product,
58 | selectedOptions,
59 | });
60 | }
61 |
62 | return (
63 |
64 |
65 | Notify when available
66 |
67 |
68 |
69 | Notify when available
70 |
71 | Enter your email address and we'll let you know when this
72 | product is back in stock.
73 |
74 |
75 |
94 |
95 | {mutation.isSuccess && (
96 |
97 | Thank you! We'll notify you when this product is back in stock.
98 |
99 | )}
100 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/src/components/BuyNowButton.tsx:
--------------------------------------------------------------------------------
1 | import { useQuickBuy } from "@/hooks/checkout";
2 | import { cn } from "@/lib/utils";
3 | import { products } from "@wix/stores";
4 | import { CreditCardIcon } from "lucide-react";
5 | import LoadingButton from "./LoadingButton";
6 | import { ButtonProps } from "./ui/button";
7 |
8 | interface BuyNowButtonProps extends ButtonProps {
9 | product: products.Product;
10 | quantity: number;
11 | selectedOptions: Record;
12 | }
13 |
14 | export default function BuyNowButton({
15 | product,
16 | quantity,
17 | selectedOptions,
18 | className,
19 | ...props
20 | }: BuyNowButtonProps) {
21 | const { startCheckoutFlow, pending } = useQuickBuy();
22 |
23 | return (
24 | startCheckoutFlow({ product, quantity, selectedOptions })}
26 | loading={pending}
27 | variant="secondary"
28 | className={cn("flex gap-3", className)}
29 | {...props}
30 | >
31 |
32 | Buy now
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/CheckoutButton.tsx:
--------------------------------------------------------------------------------
1 | import { useCartCheckout } from "@/hooks/checkout";
2 | import LoadingButton from "./LoadingButton";
3 | import { ButtonProps } from "./ui/button";
4 |
5 | export default function CheckoutButton(props: ButtonProps) {
6 | const { startCheckoutFlow, pending } = useCartCheckout();
7 |
8 | return (
9 |
10 | Checkout
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/DiscountBadge.tsx:
--------------------------------------------------------------------------------
1 | import { products } from "@wix/stores";
2 | import Badge from "./ui/badge";
3 |
4 | interface DiscountBadgeProps {
5 | data: products.Discount;
6 | }
7 |
8 | export default function DiscountBadge({ data }: DiscountBadgeProps) {
9 | if (data.type !== "PERCENT") {
10 | return null;
11 | }
12 |
13 | return -{data.value}% ;
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/LoadingButton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { Loader2 } from "lucide-react";
3 | import { Button, ButtonProps } from "./ui/button";
4 |
5 | interface LoadingButtonProps extends ButtonProps {
6 | loading: boolean;
7 | }
8 |
9 | export default function LoadingButton({
10 | loading,
11 | disabled,
12 | className,
13 | ...props
14 | }: LoadingButtonProps) {
15 | return (
16 |
21 | {loading && }
22 | {props.children}
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/Order.tsx:
--------------------------------------------------------------------------------
1 | import { SUPPORT_EMAIL } from "@/lib/constants";
2 | import { cn } from "@/lib/utils";
3 | import { orders } from "@wix/ecom";
4 | import { formatDate } from "date-fns";
5 | import Link from "next/link";
6 | import Badge from "./ui/badge";
7 | import WixImage from "./WixImage";
8 |
9 | interface OrderProps {
10 | order: orders.Order;
11 | }
12 |
13 | export default function Order({ order }: OrderProps) {
14 | const paymentStatusMap: Record = {
15 | [orders.PaymentStatus.PAID]: "Paid",
16 | [orders.PaymentStatus.NOT_PAID]: "Not paid",
17 | [orders.PaymentStatus.FULLY_REFUNDED]: "Refunded",
18 | [orders.PaymentStatus.PARTIALLY_PAID]: "Partially paid",
19 | [orders.PaymentStatus.PARTIALLY_REFUNDED]: "Partially refunded",
20 | [orders.PaymentStatus.PENDING]: "Pending",
21 | [orders.PaymentStatus.UNSPECIFIED]: "No information",
22 | };
23 |
24 | const fulfillmentStatusMap: Record = {
25 | [orders.FulfillmentStatus.FULFILLED]: "Delivered",
26 | [orders.FulfillmentStatus.NOT_FULFILLED]: "Not sent",
27 | [orders.FulfillmentStatus.PARTIALLY_FULFILLED]: "Partially delivered",
28 | };
29 |
30 | const paymentStatus = order.paymentStatus
31 | ? paymentStatusMap[order.paymentStatus]
32 | : null;
33 |
34 | const fulfillmentStatus = order.fulfillmentStatus
35 | ? fulfillmentStatusMap[order.fulfillmentStatus]
36 | : null;
37 |
38 | const shippingDestination =
39 | order.shippingInfo?.logistics?.shippingDestination;
40 |
41 | return (
42 |
43 |
44 | Order #{order.number}
45 | {order._createdDate && (
46 | {formatDate(order._createdDate, "MMM d, yyyy")}
47 | )}
48 | `)}`}
50 | className="ms-auto text-sm hover:underline"
51 | >
52 | Need help?
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | Subtotal: {order.priceSummary?.subtotal?.formattedAmount}
61 |
62 |
71 | {paymentStatus || "No information"}
72 |
73 |
74 |
75 | {fulfillmentStatus || "No information"}
76 |
77 |
78 |
79 | {order.lineItems?.map((item) => (
80 |
81 | ))}
82 |
83 |
84 | {shippingDestination && (
85 |
86 |
Delivery address:
87 |
88 | {shippingDestination.contactDetails?.firstName}{" "}
89 | {shippingDestination.contactDetails?.lastName}
90 |
91 |
92 | {shippingDestination.address?.streetAddress?.name}{" "}
93 | {shippingDestination.address?.streetAddress?.number}
94 |
95 |
96 | {shippingDestination.address?.postalCode}{" "}
97 | {shippingDestination.address?.city}
98 |
99 |
100 | {shippingDestination.address?.subdivision ||
101 | shippingDestination.address?.country}
102 |
103 |
{order.shippingInfo?.title}
104 |
105 | )}
106 |
107 |
108 | );
109 | }
110 |
111 | interface OrderItemProps {
112 | item: orders.OrderLineItem;
113 | }
114 |
115 | function OrderItem({ item }: OrderItemProps) {
116 | return (
117 |
118 |
125 |
126 |
{item.productName?.translated}
127 |
128 | {item.quantity} x {item.price?.formattedAmount}
129 |
130 | {!!item.descriptionLines?.length && (
131 |
132 | {item.descriptionLines
133 | .map(
134 | (line) =>
135 | line.colorInfo?.translated || line.plainText?.translated,
136 | )
137 | .join(", ")}
138 |
139 | )}
140 |
141 |
142 | );
143 | }
144 |
--------------------------------------------------------------------------------
/src/components/PaginationBar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { useSearchParams } from "next/navigation";
5 | import {
6 | Pagination,
7 | PaginationContent,
8 | PaginationEllipsis,
9 | PaginationItem,
10 | PaginationLink,
11 | PaginationNext,
12 | PaginationPrevious,
13 | } from "./ui/pagination";
14 |
15 | interface PaginationBarProps {
16 | currentPage: number;
17 | totalPages: number;
18 | }
19 |
20 | export default function PaginationBar({
21 | currentPage,
22 | totalPages,
23 | }: PaginationBarProps) {
24 | const searchParams = useSearchParams();
25 |
26 | function getLink(page: number) {
27 | const newSearchParams = new URLSearchParams(searchParams);
28 | newSearchParams.set("page", page.toString());
29 | return `?${newSearchParams.toString()}`;
30 | }
31 |
32 | if (totalPages <= 1) {
33 | return null;
34 | }
35 |
36 | return (
37 |
38 |
39 |
40 |
46 |
47 | {Array.from({ length: totalPages }).map((_, i) => {
48 | const page = i + 1;
49 | const isEdgePage = page === 1 || page === totalPages;
50 | const isNearCurrentPage = Math.abs(page - currentPage) <= 2;
51 |
52 | if (!isEdgePage && !isNearCurrentPage) {
53 | if (i === 1 || i === totalPages - 2) {
54 | return (
55 |
56 |
57 |
58 | );
59 | }
60 | return null;
61 | }
62 | return (
63 |
70 |
74 | {page}
75 |
76 |
77 | );
78 | })}
79 |
80 | = totalPages &&
84 | "pointer-events-none text-muted-foreground",
85 | )}
86 | />
87 |
88 |
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/src/components/Product.tsx:
--------------------------------------------------------------------------------
1 | import { formatCurrency } from "@/lib/utils";
2 | import { products } from "@wix/stores";
3 | import Link from "next/link";
4 | import DiscountBadge from "./DiscountBadge";
5 | import WixImage from "./WixImage";
6 | import Badge from "./ui/badge";
7 |
8 | interface ProductProps {
9 | product: products.Product;
10 | }
11 |
12 | export default function Product({ product }: ProductProps) {
13 | const mainImage = product.media?.mainMedia?.image;
14 |
15 | return (
16 |
17 |
18 |
25 |
26 | {product.ribbon && {product.ribbon} }
27 | {product.discount && }
28 |
29 | {getFormattedPrice(product)}
30 |
31 |
32 |
33 |
34 |
{product.name}
35 |
39 |
40 |
41 | );
42 | }
43 |
44 | function getFormattedPrice(product: products.Product) {
45 | const minPrice = product.priceRange?.minValue;
46 | const maxPrice = product.priceRange?.maxValue;
47 |
48 | if (minPrice && maxPrice && minPrice !== maxPrice) {
49 | return `from ${formatCurrency(minPrice, product.priceData?.currency)}`;
50 | } else {
51 | return (
52 | product.priceData?.formatted?.discountedPrice ||
53 | product.priceData?.formatted?.price ||
54 | "n/a"
55 | );
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/SearchField.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { SearchIcon } from "lucide-react";
5 | import { useRouter } from "next/navigation";
6 | import { Input } from "./ui/input";
7 |
8 | interface SearchFieldProps {
9 | className?: string;
10 | }
11 |
12 | export default function SearchField({ className }: SearchFieldProps) {
13 | const router = useRouter();
14 |
15 | function handleSubmit(e: React.FormEvent) {
16 | e.preventDefault();
17 | const form = e.currentTarget;
18 | const q = (form.q as HTMLInputElement).value.trim();
19 | if (!q) return;
20 | router.push(`/shop?q=${encodeURIComponent(q)}`);
21 | }
22 |
23 | return (
24 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/UserButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import useAuth from "@/hooks/auth";
4 | import { members } from "@wix/members";
5 | import {
6 | Check,
7 | LogInIcon,
8 | LogOutIcon,
9 | Monitor,
10 | Moon,
11 | Sun,
12 | UserIcon,
13 | } from "lucide-react";
14 | import { useTheme } from "next-themes";
15 | import Link from "next/link";
16 | import { Button } from "./ui/button";
17 | import {
18 | DropdownMenu,
19 | DropdownMenuContent,
20 | DropdownMenuItem,
21 | DropdownMenuLabel,
22 | DropdownMenuPortal,
23 | DropdownMenuSeparator,
24 | DropdownMenuSub,
25 | DropdownMenuSubContent,
26 | DropdownMenuSubTrigger,
27 | DropdownMenuTrigger,
28 | } from "./ui/dropdown-menu";
29 |
30 | interface UserButtonProps {
31 | loggedInMember: members.Member | null;
32 | className?: string;
33 | }
34 |
35 | export default function UserButton({
36 | loggedInMember,
37 | className,
38 | }: UserButtonProps) {
39 | const { login, logout } = useAuth();
40 |
41 | const { theme, setTheme } = useTheme();
42 |
43 | return (
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | {loggedInMember && (
52 | <>
53 |
54 | Logged in as{" "}
55 | {loggedInMember.contact?.firstName || loggedInMember.loginEmail}
56 |
57 |
58 |
59 |
60 |
61 | Profile
62 |
63 |
64 |
65 | >
66 | )}
67 |
68 |
69 |
70 | Theme
71 |
72 |
73 |
74 | setTheme("system")}>
75 |
76 | System default
77 | {theme === "system" && }
78 |
79 | setTheme("light")}>
80 |
81 | Light
82 | {theme === "light" && }
83 |
84 | setTheme("dark")}>
85 |
86 | Dark
87 | {theme === "dark" && }
88 |
89 |
90 |
91 |
92 |
93 | {loggedInMember ? (
94 | logout()}>
95 |
96 | Logout
97 |
98 | ) : (
99 | login()}>
100 |
101 | Login
102 |
103 | )}
104 |
105 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/src/components/WixImage.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 |
3 | import { media as wixMedia } from "@wix/sdk";
4 | import { ImgHTMLAttributes } from "react";
5 |
6 | type WixImageProps = Omit<
7 | ImgHTMLAttributes,
8 | "src" | "width" | "height" | "alt"
9 | > & {
10 | mediaIdentifier: string | undefined;
11 | placeholder?: string;
12 | alt?: string | null | undefined;
13 | } & (
14 | | {
15 | scaleToFill?: true;
16 | width: number;
17 | height: number;
18 | }
19 | | {
20 | scaleToFill: false;
21 | }
22 | );
23 |
24 | export default function WixImage({
25 | mediaIdentifier,
26 | placeholder = "/placeholder.png",
27 | alt,
28 | ...props
29 | }: WixImageProps) {
30 | const imageUrl = mediaIdentifier
31 | ? props.scaleToFill || props.scaleToFill === undefined
32 | ? wixMedia.getScaledToFillImageUrl(
33 | mediaIdentifier,
34 | props.width,
35 | props.height,
36 | {},
37 | )
38 | : wixMedia.getImageUrl(mediaIdentifier).url
39 | : placeholder;
40 |
41 | return ;
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/reviews/CreateProductReviewButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { members } from "@wix/members";
4 | import { products } from "@wix/stores";
5 | import { useSearchParams } from "next/navigation";
6 | import { useState } from "react";
7 | import { Button } from "../ui/button";
8 | import {
9 | Dialog,
10 | DialogContent,
11 | DialogDescription,
12 | DialogFooter,
13 | DialogHeader,
14 | DialogTitle,
15 | } from "../ui/dialog";
16 | import CreateProductReviewDialog from "./CreateProductReviewDialog";
17 |
18 | interface CreateProductReviewButtonProps {
19 | product: products.Product;
20 | loggedInMember: members.Member | null;
21 | hasExistingReview: boolean;
22 | }
23 |
24 | export default function CreateProductReviewButton({
25 | product,
26 | loggedInMember,
27 | hasExistingReview,
28 | }: CreateProductReviewButtonProps) {
29 | const searchParams = useSearchParams();
30 |
31 | const [showReviewDialog, setShowReviewDialog] = useState(
32 | searchParams.has("createReview"),
33 | );
34 |
35 | const [showConfirmationDialog, setShowConfirmationDialog] = useState(false);
36 |
37 | return (
38 | <>
39 | setShowReviewDialog(true)}
41 | disabled={!loggedInMember}
42 | >
43 | {loggedInMember ? "Write a review" : "Log in to write a review"}
44 |
45 | {
50 | setShowReviewDialog(false);
51 | setShowConfirmationDialog(true);
52 | }}
53 | />
54 |
58 |
62 | >
63 | );
64 | }
65 |
66 | interface ReviewSubmittedDialogProps {
67 | open: boolean;
68 | onOpenChange: (open: boolean) => void;
69 | }
70 |
71 | function ReviewSubmittedDialog({
72 | open,
73 | onOpenChange,
74 | }: ReviewSubmittedDialogProps) {
75 | return (
76 |
77 |
78 |
79 | Thank you for your review!
80 |
81 | Your review has been submitted successfully. It will be visible once
82 | it has been approved by our team.
83 |
84 |
85 |
86 | onOpenChange(false)}>Close
87 |
88 |
89 |
90 | );
91 | }
92 |
93 | interface ReviewAlreadyExistsDialogProps {
94 | open: boolean;
95 | onOpenChange: (open: boolean) => void;
96 | }
97 |
98 | function ReviewAlreadyExistsDialog({
99 | open,
100 | onOpenChange,
101 | }: ReviewAlreadyExistsDialogProps) {
102 | return (
103 |
104 |
105 |
106 | Review already exists
107 |
108 | You have already written a review for this product. You can only
109 | write one review per product.
110 |
111 |
112 |
113 | onOpenChange(false)}>Close
114 |
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/src/components/reviews/CreateProductReviewDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useCreateProductReview } from "@/hooks/reviews";
2 | import { cn } from "@/lib/utils";
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { products } from "@wix/stores";
5 | import { CircleAlert, ImageUp, Loader2, X } from "lucide-react";
6 | import { useRouter } from "next/navigation";
7 | import { useRef } from "react";
8 | import { useForm } from "react-hook-form";
9 | import { z } from "zod";
10 | import LoadingButton from "../LoadingButton";
11 | import { Button } from "../ui/button";
12 | import {
13 | Dialog,
14 | DialogContent,
15 | DialogDescription,
16 | DialogHeader,
17 | DialogTitle,
18 | } from "../ui/dialog";
19 | import {
20 | Form,
21 | FormControl,
22 | FormDescription,
23 | FormField,
24 | FormItem,
25 | FormLabel,
26 | FormMessage,
27 | } from "../ui/form";
28 | import { Input } from "../ui/input";
29 | import { Label } from "../ui/label";
30 | import { Textarea } from "../ui/textarea";
31 | import WixImage from "../WixImage";
32 | import StarRatingInput from "./StarRatingInput";
33 | import useMediaUpload, { MediaAttachment } from "./useMediaUpload";
34 |
35 | const formSchema = z.object({
36 | title: z
37 | .string()
38 | .trim()
39 | .min(5, "Must be at least 5 characters")
40 | .max(100, "Can't be longer than 100 characters")
41 | .or(z.literal("")),
42 | body: z
43 | .string()
44 | .trim()
45 | .min(10, "Must be at least 10 characters")
46 | .max(3000, "Can't be longer than 3000 characters")
47 | .or(z.literal("")),
48 | rating: z.number().int().min(1, "Please rate this product"),
49 | });
50 |
51 | type FormValues = z.infer;
52 |
53 | interface CreateProductReviewDialogProps {
54 | product: products.Product;
55 | open: boolean;
56 | onOpenChange: (open: boolean) => void;
57 | onSubmitted: () => void;
58 | }
59 |
60 | export default function CreateProductReviewDialog({
61 | product,
62 | open,
63 | onOpenChange,
64 | onSubmitted,
65 | }: CreateProductReviewDialogProps) {
66 | const form = useForm({
67 | resolver: zodResolver(formSchema),
68 | defaultValues: {
69 | title: "",
70 | body: "",
71 | rating: 0,
72 | },
73 | });
74 |
75 | const mutation = useCreateProductReview();
76 |
77 | const { attachments, startUpload, removeAttachment, clearAttachments } =
78 | useMediaUpload();
79 |
80 | const router = useRouter();
81 |
82 | async function onSubmit({ title, body, rating }: FormValues) {
83 | if (!product._id) {
84 | throw Error("Product ID is missing");
85 | }
86 |
87 | mutation.mutate(
88 | {
89 | productId: product._id,
90 | title,
91 | body,
92 | rating,
93 | media: attachments
94 | .filter((m) => m.url)
95 | .map((m) => ({
96 | url: m.url!,
97 | type: m.file.type.startsWith("image") ? "image" : "video",
98 | })),
99 | },
100 | {
101 | onSuccess: () => {
102 | form.reset();
103 | clearAttachments();
104 | onSubmitted();
105 | setTimeout(() => {
106 | router.refresh();
107 | }, 2000);
108 | },
109 | },
110 | );
111 | }
112 |
113 | const uploadInProgress = attachments.some((m) => m.state === "uploading");
114 |
115 | return (
116 |
117 |
118 |
119 | Write a review
120 |
121 | Did you like this product? Share your thoughts with other customers.
122 |
123 |
124 |
125 |
126 |
Product
127 |
128 |
133 | {product.name}
134 |
135 |
136 |
209 |
210 |
211 |
212 |
213 | );
214 | }
215 |
216 | interface AddMediaButtonProps {
217 | onFileSelected: (file: File) => void;
218 | disabled: boolean;
219 | }
220 |
221 | function AddMediaButton({ onFileSelected, disabled }: AddMediaButtonProps) {
222 | const fileInputRef = useRef(null);
223 |
224 | return (
225 | <>
226 | fileInputRef.current?.click()}
233 | >
234 |
235 |
236 | {
242 | const files = Array.from(e.target.files || []);
243 | if (files.length) {
244 | onFileSelected(files[0]);
245 | e.target.value = "";
246 | }
247 | }}
248 | />
249 | >
250 | );
251 | }
252 |
253 | interface AttachmentPreviewProps {
254 | attachment: MediaAttachment;
255 | onRemoveClick: (id: string) => void;
256 | }
257 |
258 | function AttachmentPreview({
259 | attachment: { id, file, state, url },
260 | onRemoveClick,
261 | }: AttachmentPreviewProps) {
262 | return (
263 |
269 | {file.type.startsWith("image") ? (
270 |
280 | ) : (
281 |
285 |
286 |
287 | )}
288 | {state === "uploading" && (
289 |
290 |
291 |
292 | )}
293 | {state === "failed" && (
294 |
298 |
299 |
300 | )}
301 |
onRemoveClick(id)}
305 | className="absolute -right-1.5 -top-1.5 border bg-background"
306 | >
307 |
308 |
309 |
310 | );
311 | }
312 |
--------------------------------------------------------------------------------
/src/components/reviews/StarRatingInput.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { StarIcon } from "lucide-react";
3 |
4 | interface StarRatingInputProps {
5 | value: number;
6 | onChange: (value: number) => void;
7 | }
8 |
9 | export default function StarRatingInput({
10 | value,
11 | onChange,
12 | }: StarRatingInputProps) {
13 | const ratingsText = ["Terrible", "Bad", "Okay", "Good", "Great"];
14 |
15 | return (
16 |
17 | {Array.from({ length: 5 }).map((_, i) => (
18 | onChange(i + 1)} type="button">
19 |
22 |
23 | ))}
24 | {ratingsText[value - 1]}
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/reviews/useMediaUpload.ts:
--------------------------------------------------------------------------------
1 | import { useToast } from "@/hooks/use-toast";
2 | import ky from "ky";
3 | import { useState } from "react";
4 |
5 | export interface MediaAttachment {
6 | id: string;
7 | file: File;
8 | url?: string;
9 | state: "uploading" | "uploaded" | "failed";
10 | }
11 |
12 | export default function useMediaUpload() {
13 | const { toast } = useToast();
14 |
15 | const [attachments, setAttachments] = useState([]);
16 |
17 | async function startUpload(file: File) {
18 | const id = crypto.randomUUID();
19 |
20 | setAttachments((prev) => [
21 | ...prev,
22 | {
23 | id,
24 | file,
25 | state: "uploading",
26 | },
27 | ]);
28 |
29 | try {
30 | const { uploadUrl } = await ky
31 | .get("/api/review-media-upload-url", {
32 | searchParams: {
33 | fileName: file.name,
34 | mimeType: file.type,
35 | },
36 | })
37 | .json<{ uploadUrl: string }>();
38 |
39 | const {
40 | file: { url },
41 | } = await ky
42 | .put(uploadUrl, {
43 | timeout: false,
44 | body: file,
45 | headers: {
46 | "Content-Type": "application/octet-stream",
47 | },
48 | searchParams: {
49 | filename: file.name,
50 | },
51 | })
52 | .json<{ file: { url: string } }>();
53 |
54 | setAttachments((prev) =>
55 | prev.map((attachment) =>
56 | attachment.id === id
57 | ? { ...attachment, state: "uploaded", url }
58 | : attachment,
59 | ),
60 | );
61 | } catch (error) {
62 | console.error(error);
63 | setAttachments((prev) =>
64 | prev.map((attachment) =>
65 | attachment.id === id
66 | ? { ...attachment, state: "failed" }
67 | : attachment,
68 | ),
69 | );
70 | toast({
71 | variant: "destructive",
72 | description: "Failed to upload attachment",
73 | });
74 | }
75 | }
76 |
77 | function removeAttachment(id: string) {
78 | setAttachments((prev) => prev.filter((attachment) => attachment.id !== id));
79 | }
80 |
81 | function clearAttachments() {
82 | setAttachments([]);
83 | }
84 |
85 | return { attachments, startUpload, removeAttachment, clearAttachments };
86 | }
87 |
--------------------------------------------------------------------------------
/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
5 | import { ChevronDown } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Accordion = AccordionPrimitive.Root
10 |
11 | const AccordionItem = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
20 | ))
21 | AccordionItem.displayName = "AccordionItem"
22 |
23 | const AccordionTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, children, ...props }, ref) => (
27 |
28 | svg]:rotate-180",
32 | className
33 | )}
34 | {...props}
35 | >
36 | {children}
37 |
38 |
39 |
40 | ))
41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
42 |
43 | const AccordionContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, children, ...props }, ref) => (
47 |
52 | {children}
53 |
54 | ))
55 |
56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
57 |
58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
59 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | interface BadgeProps {
4 | children: React.ReactNode;
5 | className?: string;
6 | }
7 |
8 | export default function Badge({ children, className }: BadgeProps) {
9 | return (
10 |
16 | {children}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5 | import { Check } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ))
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
29 |
30 | export { Checkbox }
31 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ))
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ))
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ))
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ))
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ))
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | )
181 | }
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | }
201 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { Slot } from "@radix-ui/react-slot"
6 | import {
7 | Controller,
8 | ControllerProps,
9 | FieldPath,
10 | FieldValues,
11 | FormProvider,
12 | useFormContext,
13 | } from "react-hook-form"
14 |
15 | import { cn } from "@/lib/utils"
16 | import { Label } from "@/components/ui/label"
17 |
18 | const Form = FormProvider
19 |
20 | type FormFieldContextValue<
21 | TFieldValues extends FieldValues = FieldValues,
22 | TName extends FieldPath = FieldPath
23 | > = {
24 | name: TName
25 | }
26 |
27 | const FormFieldContext = React.createContext(
28 | {} as FormFieldContextValue
29 | )
30 |
31 | const FormField = <
32 | TFieldValues extends FieldValues = FieldValues,
33 | TName extends FieldPath = FieldPath
34 | >({
35 | ...props
36 | }: ControllerProps) => {
37 | return (
38 |
39 |
40 |
41 | )
42 | }
43 |
44 | const useFormField = () => {
45 | const fieldContext = React.useContext(FormFieldContext)
46 | const itemContext = React.useContext(FormItemContext)
47 | const { getFieldState, formState } = useFormContext()
48 |
49 | const fieldState = getFieldState(fieldContext.name, formState)
50 |
51 | if (!fieldContext) {
52 | throw new Error("useFormField should be used within ")
53 | }
54 |
55 | const { id } = itemContext
56 |
57 | return {
58 | id,
59 | name: fieldContext.name,
60 | formItemId: `${id}-form-item`,
61 | formDescriptionId: `${id}-form-item-description`,
62 | formMessageId: `${id}-form-item-message`,
63 | ...fieldState,
64 | }
65 | }
66 |
67 | type FormItemContextValue = {
68 | id: string
69 | }
70 |
71 | const FormItemContext = React.createContext(
72 | {} as FormItemContextValue
73 | )
74 |
75 | const FormItem = React.forwardRef<
76 | HTMLDivElement,
77 | React.HTMLAttributes
78 | >(({ className, ...props }, ref) => {
79 | const id = React.useId()
80 |
81 | return (
82 |
83 |
84 |
85 | )
86 | })
87 | FormItem.displayName = "FormItem"
88 |
89 | const FormLabel = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => {
93 | const { error, formItemId } = useFormField()
94 |
95 | return (
96 |
102 | )
103 | })
104 | FormLabel.displayName = "FormLabel"
105 |
106 | const FormControl = React.forwardRef<
107 | React.ElementRef,
108 | React.ComponentPropsWithoutRef
109 | >(({ ...props }, ref) => {
110 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
111 |
112 | return (
113 |
124 | )
125 | })
126 | FormControl.displayName = "FormControl"
127 |
128 | const FormDescription = React.forwardRef<
129 | HTMLParagraphElement,
130 | React.HTMLAttributes
131 | >(({ className, ...props }, ref) => {
132 | const { formDescriptionId } = useFormField()
133 |
134 | return (
135 |
141 | )
142 | })
143 | FormDescription.displayName = "FormDescription"
144 |
145 | const FormMessage = React.forwardRef<
146 | HTMLParagraphElement,
147 | React.HTMLAttributes
148 | >(({ className, children, ...props }, ref) => {
149 | const { error, formMessageId } = useFormField()
150 | const body = error ? String(error?.message) : children
151 |
152 | if (!body) {
153 | return null
154 | }
155 |
156 | return (
157 |
163 | {body}
164 |
165 | )
166 | })
167 | FormMessage.displayName = "FormMessage"
168 |
169 | export {
170 | useFormField,
171 | Form,
172 | FormItem,
173 | FormLabel,
174 | FormControl,
175 | FormDescription,
176 | FormMessage,
177 | FormField,
178 | }
179 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/src/components/ui/navigation-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
3 | import { cva } from "class-variance-authority"
4 | import { ChevronDown } from "lucide-react"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const NavigationMenu = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
20 | {children}
21 |
22 |
23 | ))
24 | NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
25 |
26 | const NavigationMenuList = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, ...props }, ref) => (
30 |
38 | ))
39 | NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
40 |
41 | const NavigationMenuItem = NavigationMenuPrimitive.Item
42 |
43 | const navigationMenuTriggerStyle = cva(
44 | "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
45 | )
46 |
47 | const NavigationMenuTrigger = React.forwardRef<
48 | React.ElementRef,
49 | React.ComponentPropsWithoutRef
50 | >(({ className, children, ...props }, ref) => (
51 |
56 | {children}{" "}
57 |
61 |
62 | ))
63 | NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
64 |
65 | const NavigationMenuContent = React.forwardRef<
66 | React.ElementRef,
67 | React.ComponentPropsWithoutRef
68 | >(({ className, ...props }, ref) => (
69 |
77 | ))
78 | NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
79 |
80 | const NavigationMenuLink = NavigationMenuPrimitive.Link
81 |
82 | const NavigationMenuViewport = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
87 |
95 |
96 | ))
97 | NavigationMenuViewport.displayName =
98 | NavigationMenuPrimitive.Viewport.displayName
99 |
100 | const NavigationMenuIndicator = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, ...props }, ref) => (
104 |
112 |
113 |
114 | ))
115 | NavigationMenuIndicator.displayName =
116 | NavigationMenuPrimitive.Indicator.displayName
117 |
118 | export {
119 | navigationMenuTriggerStyle,
120 | NavigationMenu,
121 | NavigationMenuList,
122 | NavigationMenuItem,
123 | NavigationMenuContent,
124 | NavigationMenuTrigger,
125 | NavigationMenuLink,
126 | NavigationMenuIndicator,
127 | NavigationMenuViewport,
128 | }
129 |
--------------------------------------------------------------------------------
/src/components/ui/pagination.tsx:
--------------------------------------------------------------------------------
1 | import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
2 | import * as React from "react";
3 |
4 | import { ButtonProps, buttonVariants } from "@/components/ui/button";
5 | import { cn } from "@/lib/utils";
6 | import Link from "next/link";
7 |
8 | const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
9 |
15 | );
16 | Pagination.displayName = "Pagination";
17 |
18 | const PaginationContent = React.forwardRef<
19 | HTMLUListElement,
20 | React.ComponentProps<"ul">
21 | >(({ className, ...props }, ref) => (
22 |
27 | ));
28 | PaginationContent.displayName = "PaginationContent";
29 |
30 | const PaginationItem = React.forwardRef<
31 | HTMLLIElement,
32 | React.ComponentProps<"li">
33 | >(({ className, ...props }, ref) => (
34 |
35 | ));
36 | PaginationItem.displayName = "PaginationItem";
37 |
38 | type PaginationLinkProps = {
39 | isActive?: boolean;
40 | } & Pick &
41 | React.ComponentProps;
42 |
43 | const PaginationLink = ({
44 | className,
45 | isActive,
46 | size = "icon",
47 | ...props
48 | }: PaginationLinkProps) => (
49 |
60 | );
61 | PaginationLink.displayName = "PaginationLink";
62 |
63 | const PaginationPrevious = ({
64 | className,
65 | ...props
66 | }: React.ComponentProps) => (
67 |
73 |
74 | Previous
75 |
76 | );
77 | PaginationPrevious.displayName = "PaginationPrevious";
78 |
79 | const PaginationNext = ({
80 | className,
81 | ...props
82 | }: React.ComponentProps) => (
83 |
89 | Next
90 |
91 |
92 | );
93 | PaginationNext.displayName = "PaginationNext";
94 |
95 | const PaginationEllipsis = ({
96 | className,
97 | ...props
98 | }: React.ComponentProps<"span">) => (
99 |
104 |
105 | More pages
106 |
107 | );
108 | PaginationEllipsis.displayName = "PaginationEllipsis";
109 |
110 | export {
111 | Pagination,
112 | PaginationContent,
113 | PaginationEllipsis,
114 | PaginationItem,
115 | PaginationLink,
116 | PaginationNext,
117 | PaginationPrevious,
118 | };
119 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ))
135 | SelectItem.displayName = SelectPrimitive.Item.displayName
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ))
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | }
161 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
141 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ToastPrimitives from "@radix-ui/react-toast"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const ToastProvider = ToastPrimitives.Provider
11 |
12 | const ToastViewport = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
24 | ))
25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
26 |
27 | const toastVariants = cva(
28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
29 | {
30 | variants: {
31 | variant: {
32 | default: "border bg-background text-foreground",
33 | destructive:
34 | "destructive group border-destructive bg-destructive text-destructive-foreground",
35 | },
36 | },
37 | defaultVariants: {
38 | variant: "default",
39 | },
40 | }
41 | )
42 |
43 | const Toast = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef &
46 | VariantProps
47 | >(({ className, variant, ...props }, ref) => {
48 | return (
49 |
54 | )
55 | })
56 | Toast.displayName = ToastPrimitives.Root.displayName
57 |
58 | const ToastAction = React.forwardRef<
59 | React.ElementRef,
60 | React.ComponentPropsWithoutRef
61 | >(({ className, ...props }, ref) => (
62 |
70 | ))
71 | ToastAction.displayName = ToastPrimitives.Action.displayName
72 |
73 | const ToastClose = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, ...props }, ref) => (
77 |
86 |
87 |
88 | ))
89 | ToastClose.displayName = ToastPrimitives.Close.displayName
90 |
91 | const ToastTitle = React.forwardRef<
92 | React.ElementRef,
93 | React.ComponentPropsWithoutRef
94 | >(({ className, ...props }, ref) => (
95 |
100 | ))
101 | ToastTitle.displayName = ToastPrimitives.Title.displayName
102 |
103 | const ToastDescription = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | ToastDescription.displayName = ToastPrimitives.Description.displayName
114 |
115 | type ToastProps = React.ComponentPropsWithoutRef
116 |
117 | type ToastActionElement = React.ReactElement
118 |
119 | export {
120 | type ToastProps,
121 | type ToastActionElement,
122 | ToastProvider,
123 | ToastViewport,
124 | Toast,
125 | ToastTitle,
126 | ToastDescription,
127 | ToastClose,
128 | ToastAction,
129 | }
130 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useToast } from "@/hooks/use-toast"
4 | import {
5 | Toast,
6 | ToastClose,
7 | ToastDescription,
8 | ToastProvider,
9 | ToastTitle,
10 | ToastViewport,
11 | } from "@/components/ui/toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title} }
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/src/env.ts:
--------------------------------------------------------------------------------
1 | import { createEnv } from "@t3-oss/env-nextjs";
2 | import { z } from "zod";
3 |
4 | export const env = createEnv({
5 | server: {
6 | WIX_API_KEY: z.string().min(1),
7 | },
8 | client: {
9 | NEXT_PUBLIC_BASE_URL: z.string().url(),
10 | NEXT_PUBLIC_WIX_CLIENT_ID: z.string().min(1),
11 | NEXT_PUBLIC_WIX_SITE_ID: z.string().min(1),
12 | },
13 | experimental__runtimeEnv: {
14 | NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
15 | NEXT_PUBLIC_WIX_CLIENT_ID: process.env.NEXT_PUBLIC_WIX_CLIENT_ID,
16 | NEXT_PUBLIC_WIX_SITE_ID: process.env.NEXT_PUBLIC_WIX_SITE_ID,
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/src/hooks/auth.ts:
--------------------------------------------------------------------------------
1 | import { WIX_OAUTH_DATA_COOKIE, WIX_SESSION_COOKIE } from "@/lib/constants";
2 | import { wixBrowserClient } from "@/lib/wix-client.browser";
3 | import { generateOAuthData, getLoginUrl, getLogoutUrl } from "@/wix-api/auth";
4 | import Cookies from "js-cookie";
5 | import { usePathname } from "next/navigation";
6 | import { useToast } from "./use-toast";
7 |
8 | export default function useAuth() {
9 | const pathname = usePathname();
10 |
11 | const { toast } = useToast();
12 |
13 | async function login() {
14 | try {
15 | const oAuthData = await generateOAuthData(wixBrowserClient, pathname);
16 |
17 | Cookies.set(WIX_OAUTH_DATA_COOKIE, JSON.stringify(oAuthData), {
18 | secure: process.env.NODE_ENV === "production",
19 | expires: new Date(Date.now() + 60 * 10 * 1000),
20 | });
21 |
22 | const redirectUrl = await getLoginUrl(wixBrowserClient, oAuthData);
23 |
24 | window.location.href = redirectUrl;
25 | } catch (error) {
26 | console.error(error);
27 | toast({
28 | variant: "destructive",
29 | description: "Failed to log in. Please try again.",
30 | });
31 | }
32 | }
33 |
34 | async function logout() {
35 | try {
36 | const logoutUrl = await getLogoutUrl(wixBrowserClient);
37 |
38 | Cookies.remove(WIX_SESSION_COOKIE);
39 |
40 | window.location.href = logoutUrl;
41 | } catch (error) {
42 | console.error(error);
43 | toast({
44 | variant: "destructive",
45 | description: "Failed to log out. Please try again.",
46 | });
47 | }
48 | }
49 |
50 | return { login, logout };
51 | }
52 |
--------------------------------------------------------------------------------
/src/hooks/back-in-stock.ts:
--------------------------------------------------------------------------------
1 | import { wixBrowserClient } from "@/lib/wix-client.browser";
2 | import {
3 | BackInStockNotificationRequestValues,
4 | createBackInStockNotificationRequest,
5 | } from "@/wix-api/backInStockNotifications";
6 | import { useMutation } from "@tanstack/react-query";
7 | import { useToast } from "./use-toast";
8 |
9 | export function useCreateBackInStockNotificationRequest() {
10 | const { toast } = useToast();
11 |
12 | return useMutation({
13 | mutationFn: (values: BackInStockNotificationRequestValues) =>
14 | createBackInStockNotificationRequest(wixBrowserClient, values),
15 | onError(error) {
16 | console.error(error);
17 | if (
18 | (error as any).details.applicationError.code ===
19 | "BACK_IN_STOCK_NOTIFICATION_REQUEST_ALREADY_EXISTS"
20 | ) {
21 | toast({
22 | variant: "destructive",
23 | description: "You are already subscribed to this product.",
24 | });
25 | } else {
26 | toast({
27 | variant: "destructive",
28 | description: "Something went wrong. Please try again.",
29 | });
30 | }
31 | },
32 | });
33 | }
34 |
--------------------------------------------------------------------------------
/src/hooks/cart.ts:
--------------------------------------------------------------------------------
1 | import { wixBrowserClient } from "@/lib/wix-client.browser";
2 | import {
3 | addToCart,
4 | AddToCartValues,
5 | clearCart,
6 | getCart,
7 | removeCartItem,
8 | updateCartItemQuantity,
9 | UpdateCartItemQuantityValues,
10 | } from "@/wix-api/cart";
11 | import {
12 | MutationKey,
13 | QueryKey,
14 | useMutation,
15 | useQuery,
16 | useQueryClient,
17 | } from "@tanstack/react-query";
18 | import { currentCart } from "@wix/ecom";
19 | import { useToast } from "./use-toast";
20 |
21 | const queryKey: QueryKey = ["cart"];
22 |
23 | export function useCart(initialData: currentCart.Cart | null) {
24 | return useQuery({
25 | queryKey,
26 | queryFn: () => getCart(wixBrowserClient),
27 | initialData,
28 | });
29 | }
30 |
31 | export function useAddItemToCart() {
32 | const queryClient = useQueryClient();
33 |
34 | const { toast } = useToast();
35 |
36 | return useMutation({
37 | mutationFn: (values: AddToCartValues) =>
38 | addToCart(wixBrowserClient, values),
39 | onSuccess(data) {
40 | toast({ description: "Item added to cart" });
41 | queryClient.cancelQueries({ queryKey });
42 | queryClient.setQueryData(queryKey, data.cart);
43 | },
44 | onError(error) {
45 | console.error(error);
46 | toast({
47 | variant: "destructive",
48 | description: "Failed to add item to cart. Please try again.",
49 | });
50 | },
51 | });
52 | }
53 |
54 | export function useUpdateCartItemQuantity() {
55 | const queryClient = useQueryClient();
56 |
57 | const { toast } = useToast();
58 |
59 | const mutationKey: MutationKey = ["updateCartItemQuantity"];
60 |
61 | return useMutation({
62 | mutationKey,
63 | mutationFn: (values: UpdateCartItemQuantityValues) =>
64 | updateCartItemQuantity(wixBrowserClient, values),
65 | onMutate: async ({ productId, newQuantity }) => {
66 | await queryClient.cancelQueries({ queryKey });
67 |
68 | const previousState =
69 | queryClient.getQueryData(queryKey);
70 |
71 | queryClient.setQueryData(queryKey, (oldData) => ({
72 | ...oldData,
73 | lineItems: oldData?.lineItems?.map((lineItem) =>
74 | lineItem._id === productId
75 | ? { ...lineItem, quantity: newQuantity }
76 | : lineItem,
77 | ),
78 | }));
79 |
80 | return { previousState };
81 | },
82 | onError(error, variables, context) {
83 | queryClient.setQueryData(queryKey, context?.previousState);
84 | console.error(error);
85 | toast({
86 | variant: "destructive",
87 | description: "Something went wrong. Please try again.",
88 | });
89 | },
90 | onSettled() {
91 | if (queryClient.isMutating({ mutationKey }) === 1) {
92 | queryClient.invalidateQueries({ queryKey });
93 | }
94 | },
95 | });
96 | }
97 |
98 | export function useRemoveCartItem() {
99 | const queryClient = useQueryClient();
100 |
101 | const { toast } = useToast();
102 |
103 | return useMutation({
104 | mutationFn: (productId: string) =>
105 | removeCartItem(wixBrowserClient, productId),
106 | onMutate: async (productId) => {
107 | await queryClient.cancelQueries({ queryKey });
108 |
109 | const previousState =
110 | queryClient.getQueryData(queryKey);
111 |
112 | queryClient.setQueryData(queryKey, (oldData) => ({
113 | ...oldData,
114 | lineItems: oldData?.lineItems?.filter(
115 | (lineItem) => lineItem._id !== productId,
116 | ),
117 | }));
118 |
119 | return { previousState };
120 | },
121 | onError(error, variables, context) {
122 | queryClient.setQueryData(queryKey, context?.previousState);
123 | console.error(error);
124 | toast({
125 | variant: "destructive",
126 | description: "Something went wrong. Please try again.",
127 | });
128 | },
129 | onSettled() {
130 | queryClient.invalidateQueries({ queryKey });
131 | },
132 | });
133 | }
134 |
135 | export function useClearCart() {
136 | const queryClient = useQueryClient();
137 |
138 | return useMutation({
139 | mutationFn: () => clearCart(wixBrowserClient),
140 | onSuccess() {
141 | queryClient.setQueryData(queryKey, null);
142 | queryClient.invalidateQueries({ queryKey });
143 | },
144 | retry: 3,
145 | });
146 | }
147 |
--------------------------------------------------------------------------------
/src/hooks/checkout.ts:
--------------------------------------------------------------------------------
1 | import { wixBrowserClient } from "@/lib/wix-client.browser";
2 | import {
3 | getCheckoutUrlForCurrentCart,
4 | getCheckoutUrlForProduct,
5 | GetCheckoutUrlForProductValues,
6 | } from "@/wix-api/checkout";
7 | import { useState } from "react";
8 | import { useToast } from "./use-toast";
9 |
10 | export function useCartCheckout() {
11 | const { toast } = useToast();
12 |
13 | const [pending, setPending] = useState(false);
14 |
15 | async function startCheckoutFlow() {
16 | setPending(true);
17 |
18 | try {
19 | const checkoutUrl = await getCheckoutUrlForCurrentCart(wixBrowserClient);
20 | window.location.href = checkoutUrl;
21 | } catch (error) {
22 | setPending(false);
23 | console.error(error);
24 | toast({
25 | variant: "destructive",
26 | description: "Failed to load checkout. Please try again.",
27 | });
28 | }
29 | }
30 |
31 | return { startCheckoutFlow, pending };
32 | }
33 |
34 | export function useQuickBuy() {
35 | const { toast } = useToast();
36 |
37 | const [pending, setPending] = useState(false);
38 |
39 | async function startCheckoutFlow(values: GetCheckoutUrlForProductValues) {
40 | setPending(true);
41 |
42 | try {
43 | const checkoutUrl = await getCheckoutUrlForProduct(
44 | wixBrowserClient,
45 | values,
46 | );
47 | window.location.href = checkoutUrl;
48 | } catch (error) {
49 | setPending(false);
50 | console.error(error);
51 | toast({
52 | variant: "destructive",
53 | description: "Failed to load checkout. Please try again.",
54 | });
55 | }
56 | }
57 |
58 | return { startCheckoutFlow, pending };
59 | }
60 |
--------------------------------------------------------------------------------
/src/hooks/members.ts:
--------------------------------------------------------------------------------
1 | import { wixBrowserClient } from "@/lib/wix-client.browser";
2 | import { updateMemberInfo, UpdateMemberInfoValues } from "@/wix-api/members";
3 | import { useMutation } from "@tanstack/react-query";
4 | import { useRouter } from "next/navigation";
5 | import { useToast } from "./use-toast";
6 |
7 | export function useUpdateMember() {
8 | const { toast } = useToast();
9 |
10 | const router = useRouter();
11 |
12 | return useMutation({
13 | mutationFn: (variables: UpdateMemberInfoValues) =>
14 | updateMemberInfo(wixBrowserClient, variables),
15 | onSuccess() {
16 | toast({
17 | description: "Profile updated",
18 | });
19 | setTimeout(() => {
20 | router.refresh();
21 | }, 2000);
22 | },
23 | onError(error) {
24 | console.error(error);
25 | toast({
26 | variant: "destructive",
27 | description: "Failed to update profile. Please try again.",
28 | });
29 | },
30 | });
31 | }
32 |
--------------------------------------------------------------------------------
/src/hooks/reviews.ts:
--------------------------------------------------------------------------------
1 | import { wixBrowserClient } from "@/lib/wix-client.browser";
2 | import {
3 | createProductReview,
4 | CreateProductReviewValues,
5 | } from "@/wix-api/reviews";
6 | import { useMutation } from "@tanstack/react-query";
7 | import { useToast } from "./use-toast";
8 |
9 | export function useCreateProductReview() {
10 | const { toast } = useToast();
11 |
12 | return useMutation({
13 | mutationFn: (values: CreateProductReviewValues) =>
14 | createProductReview(wixBrowserClient, values),
15 | onError(error) {
16 | console.error(error);
17 | toast({
18 | variant: "destructive",
19 | description: "Failed to create review. Please try again.",
20 | });
21 | },
22 | });
23 | }
24 |
--------------------------------------------------------------------------------
/src/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | // Inspired by react-hot-toast library
4 | import * as React from "react"
5 |
6 | import type {
7 | ToastActionElement,
8 | ToastProps,
9 | } from "@/components/ui/toast"
10 |
11 | const TOAST_LIMIT = 1
12 | const TOAST_REMOVE_DELAY = 1000000
13 |
14 | type ToasterToast = ToastProps & {
15 | id: string
16 | title?: React.ReactNode
17 | description?: React.ReactNode
18 | action?: ToastActionElement
19 | }
20 |
21 | const actionTypes = {
22 | ADD_TOAST: "ADD_TOAST",
23 | UPDATE_TOAST: "UPDATE_TOAST",
24 | DISMISS_TOAST: "DISMISS_TOAST",
25 | REMOVE_TOAST: "REMOVE_TOAST",
26 | } as const
27 |
28 | let count = 0
29 |
30 | function genId() {
31 | count = (count + 1) % Number.MAX_SAFE_INTEGER
32 | return count.toString()
33 | }
34 |
35 | type ActionType = typeof actionTypes
36 |
37 | type Action =
38 | | {
39 | type: ActionType["ADD_TOAST"]
40 | toast: ToasterToast
41 | }
42 | | {
43 | type: ActionType["UPDATE_TOAST"]
44 | toast: Partial
45 | }
46 | | {
47 | type: ActionType["DISMISS_TOAST"]
48 | toastId?: ToasterToast["id"]
49 | }
50 | | {
51 | type: ActionType["REMOVE_TOAST"]
52 | toastId?: ToasterToast["id"]
53 | }
54 |
55 | interface State {
56 | toasts: ToasterToast[]
57 | }
58 |
59 | const toastTimeouts = new Map>()
60 |
61 | const addToRemoveQueue = (toastId: string) => {
62 | if (toastTimeouts.has(toastId)) {
63 | return
64 | }
65 |
66 | const timeout = setTimeout(() => {
67 | toastTimeouts.delete(toastId)
68 | dispatch({
69 | type: "REMOVE_TOAST",
70 | toastId: toastId,
71 | })
72 | }, TOAST_REMOVE_DELAY)
73 |
74 | toastTimeouts.set(toastId, timeout)
75 | }
76 |
77 | export const reducer = (state: State, action: Action): State => {
78 | switch (action.type) {
79 | case "ADD_TOAST":
80 | return {
81 | ...state,
82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83 | }
84 |
85 | case "UPDATE_TOAST":
86 | return {
87 | ...state,
88 | toasts: state.toasts.map((t) =>
89 | t.id === action.toast.id ? { ...t, ...action.toast } : t
90 | ),
91 | }
92 |
93 | case "DISMISS_TOAST": {
94 | const { toastId } = action
95 |
96 | // ! Side effects ! - This could be extracted into a dismissToast() action,
97 | // but I'll keep it here for simplicity
98 | if (toastId) {
99 | addToRemoveQueue(toastId)
100 | } else {
101 | state.toasts.forEach((toast) => {
102 | addToRemoveQueue(toast.id)
103 | })
104 | }
105 |
106 | return {
107 | ...state,
108 | toasts: state.toasts.map((t) =>
109 | t.id === toastId || toastId === undefined
110 | ? {
111 | ...t,
112 | open: false,
113 | }
114 | : t
115 | ),
116 | }
117 | }
118 | case "REMOVE_TOAST":
119 | if (action.toastId === undefined) {
120 | return {
121 | ...state,
122 | toasts: [],
123 | }
124 | }
125 | return {
126 | ...state,
127 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
128 | }
129 | }
130 | }
131 |
132 | const listeners: Array<(state: State) => void> = []
133 |
134 | let memoryState: State = { toasts: [] }
135 |
136 | function dispatch(action: Action) {
137 | memoryState = reducer(memoryState, action)
138 | listeners.forEach((listener) => {
139 | listener(memoryState)
140 | })
141 | }
142 |
143 | type Toast = Omit
144 |
145 | function toast({ ...props }: Toast) {
146 | const id = genId()
147 |
148 | const update = (props: ToasterToast) =>
149 | dispatch({
150 | type: "UPDATE_TOAST",
151 | toast: { ...props, id },
152 | })
153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
154 |
155 | dispatch({
156 | type: "ADD_TOAST",
157 | toast: {
158 | ...props,
159 | id,
160 | open: true,
161 | onOpenChange: (open) => {
162 | if (!open) dismiss()
163 | },
164 | },
165 | })
166 |
167 | return {
168 | id: id,
169 | dismiss,
170 | update,
171 | }
172 | }
173 |
174 | function useToast() {
175 | const [state, setState] = React.useState(memoryState)
176 |
177 | React.useEffect(() => {
178 | listeners.push(setState)
179 | return () => {
180 | const index = listeners.indexOf(setState)
181 | if (index > -1) {
182 | listeners.splice(index, 1)
183 | }
184 | }
185 | }, [state])
186 |
187 | return {
188 | ...state,
189 | toast,
190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
191 | }
192 | }
193 |
194 | export { useToast, toast }
195 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const WIX_STORES_APP_ID = "215238eb-22a5-4c36-9e7b-e7c08025e04e";
2 | export const WIX_STORES_APP_ID_BACK_IN_STOCK_NOTIFICATIONS =
3 | "1380b703-ce81-ff05-f115-39571d94dfcd";
4 | export const WIX_SESSION_COOKIE = "wix_session";
5 | export const WIX_OAUTH_DATA_COOKIE = "wix_oauthdata";
6 | export const SUPPORT_EMAIL = "support@nextflowshop.com";
7 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { products } from "@wix/stores";
2 | import { clsx, type ClassValue } from "clsx";
3 | import { twMerge } from "tailwind-merge";
4 | import resolveConfig from "tailwindcss/resolveConfig";
5 | import tailwindConfig from "../../tailwind.config";
6 |
7 | export const twConfig = resolveConfig(tailwindConfig);
8 |
9 | export function cn(...inputs: ClassValue[]) {
10 | return twMerge(clsx(inputs));
11 | }
12 |
13 | export async function delay(ms: number) {
14 | return new Promise((resolve) => setTimeout(resolve, ms));
15 | }
16 |
17 | export function formatCurrency(
18 | price: number | string = 0,
19 | currency: string = "USD",
20 | ) {
21 | return Intl.NumberFormat("en", { style: "currency", currency }).format(
22 | Number(price),
23 | );
24 | }
25 |
26 | export function findVariant(
27 | product: products.Product,
28 | selectedOptions: Record,
29 | ) {
30 | if (!product.manageVariants) return null;
31 |
32 | return (
33 | product.variants?.find((variant) => {
34 | return Object.entries(selectedOptions).every(
35 | ([key, value]) => variant.choices?.[key] === value,
36 | );
37 | }) || null
38 | );
39 | }
40 |
41 | export function checkInStock(
42 | product: products.Product,
43 | selectedOptions: Record,
44 | ) {
45 | const variant = findVariant(product, selectedOptions);
46 |
47 | return variant
48 | ? variant.stock?.quantity !== 0 && variant.stock?.inStock
49 | : product.stock?.inventoryStatus === products.InventoryStatus.IN_STOCK ||
50 | product.stock?.inventoryStatus ===
51 | products.InventoryStatus.PARTIALLY_OUT_OF_STOCK;
52 | }
53 |
--------------------------------------------------------------------------------
/src/lib/validation.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const requiredString = z.string().trim().min(1, "Required");
4 |
--------------------------------------------------------------------------------
/src/lib/wix-client.base.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import {
3 | backInStockNotifications,
4 | checkout,
5 | currentCart,
6 | orders,
7 | recommendations,
8 | } from "@wix/ecom";
9 | import { files } from "@wix/media";
10 | import { members } from "@wix/members";
11 | import { redirects } from "@wix/redirects";
12 | import { reviews } from "@wix/reviews";
13 | import { createClient, OAuthStrategy, Tokens } from "@wix/sdk";
14 | import { collections, products } from "@wix/stores";
15 |
16 | export function getWixClient(tokens: Tokens | undefined) {
17 | return createClient({
18 | modules: {
19 | products,
20 | collections,
21 | currentCart,
22 | checkout,
23 | redirects,
24 | orders,
25 | recommendations,
26 | backInStockNotifications,
27 | reviews,
28 | members,
29 | files,
30 | },
31 | auth: OAuthStrategy({
32 | clientId: env.NEXT_PUBLIC_WIX_CLIENT_ID,
33 | tokens,
34 | }),
35 | });
36 | }
37 |
38 | export type WixClient = ReturnType;
39 |
--------------------------------------------------------------------------------
/src/lib/wix-client.browser.ts:
--------------------------------------------------------------------------------
1 | import { Tokens } from "@wix/sdk";
2 | import Cookies from "js-cookie";
3 | import { WIX_SESSION_COOKIE } from "./constants";
4 | import { getWixClient } from "./wix-client.base";
5 |
6 | const tokens: Tokens = JSON.parse(Cookies.get(WIX_SESSION_COOKIE) || "{}");
7 |
8 | export const wixBrowserClient = getWixClient(tokens);
9 |
--------------------------------------------------------------------------------
/src/lib/wix-client.server.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import { files } from "@wix/media";
3 | import { ApiKeyStrategy, createClient, Tokens } from "@wix/sdk";
4 | import { cookies } from "next/headers";
5 | import { cache } from "react";
6 | import { WIX_SESSION_COOKIE } from "./constants";
7 | import { getWixClient } from "./wix-client.base";
8 |
9 | export const getWixServerClient = cache(() => {
10 | let tokens: Tokens | undefined;
11 |
12 | try {
13 | tokens = JSON.parse(cookies().get(WIX_SESSION_COOKIE)?.value || "{}");
14 | } catch (error) {}
15 |
16 | return getWixClient(tokens);
17 | });
18 |
19 | export const getWixAdminClient = cache(() => {
20 | const wixClient = createClient({
21 | modules: {
22 | files,
23 | },
24 | auth: ApiKeyStrategy({
25 | apiKey: env.WIX_API_KEY,
26 | siteId: env.NEXT_PUBLIC_WIX_SITE_ID,
27 | }),
28 | });
29 |
30 | return wixClient;
31 | });
32 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { createClient, OAuthStrategy, Tokens } from "@wix/sdk";
2 | import { NextRequest, NextResponse } from "next/server";
3 | import { env } from "./env";
4 | import { WIX_SESSION_COOKIE } from "./lib/constants";
5 |
6 | const wixClient = createClient({
7 | auth: OAuthStrategy({ clientId: env.NEXT_PUBLIC_WIX_CLIENT_ID }),
8 | });
9 |
10 | export async function middleware(request: NextRequest) {
11 | const cookies = request.cookies;
12 | const sessionCookie = cookies.get(WIX_SESSION_COOKIE);
13 |
14 | let sessionTokens = sessionCookie
15 | ? (JSON.parse(sessionCookie.value) as Tokens)
16 | : await wixClient.auth.generateVisitorTokens();
17 |
18 | if (sessionTokens.accessToken.expiresAt < Math.floor(Date.now() / 1000)) {
19 | try {
20 | sessionTokens = await wixClient.auth.renewToken(
21 | sessionTokens.refreshToken,
22 | );
23 | } catch (error) {
24 | sessionTokens = await wixClient.auth.generateVisitorTokens();
25 | }
26 | }
27 |
28 | request.cookies.set(WIX_SESSION_COOKIE, JSON.stringify(sessionTokens));
29 |
30 | const res = NextResponse.next({ request });
31 |
32 | res.cookies.set(WIX_SESSION_COOKIE, JSON.stringify(sessionTokens), {
33 | maxAge: 60 * 60 * 24 * 14,
34 | secure: process.env.NODE_ENV === "production",
35 | });
36 |
37 | return res;
38 | }
39 |
40 | export const config = {
41 | matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
42 | };
43 |
--------------------------------------------------------------------------------
/src/wix-api/auth.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import { WixClient } from "@/lib/wix-client.base";
3 | import { OauthData } from "@wix/sdk";
4 |
5 | export async function generateOAuthData(
6 | wixClient: WixClient,
7 | originPath?: string,
8 | ) {
9 | return wixClient.auth.generateOAuthData(
10 | env.NEXT_PUBLIC_BASE_URL + "/api/auth/callback/wix",
11 | env.NEXT_PUBLIC_BASE_URL + "/" + (originPath || ""),
12 | );
13 | }
14 |
15 | export async function getLoginUrl(wixClient: WixClient, oAuthData: OauthData) {
16 | const { authUrl } = await wixClient.auth.getAuthUrl(oAuthData, {
17 | responseMode: "query",
18 | });
19 |
20 | return authUrl;
21 | }
22 |
23 | export async function getLogoutUrl(wixClient: WixClient) {
24 | const { logoutUrl } = await wixClient.auth.logout(env.NEXT_PUBLIC_BASE_URL);
25 |
26 | return logoutUrl;
27 | }
28 |
--------------------------------------------------------------------------------
/src/wix-api/backInStockNotifications.ts:
--------------------------------------------------------------------------------
1 | import { WIX_STORES_APP_ID_BACK_IN_STOCK_NOTIFICATIONS } from "@/lib/constants";
2 | import { findVariant } from "@/lib/utils";
3 | import { WixClient } from "@/lib/wix-client.base";
4 | import { products } from "@wix/stores";
5 |
6 | export interface BackInStockNotificationRequestValues {
7 | email: string;
8 | itemUrl: string;
9 | product: products.Product;
10 | selectedOptions: Record;
11 | }
12 |
13 | export async function createBackInStockNotificationRequest(
14 | wixClient: WixClient,
15 | {
16 | email,
17 | itemUrl,
18 | product,
19 | selectedOptions,
20 | }: BackInStockNotificationRequestValues,
21 | ) {
22 | const selectedVariant = findVariant(product, selectedOptions);
23 |
24 | await wixClient.backInStockNotifications.createBackInStockNotificationRequest(
25 | {
26 | email,
27 | itemUrl,
28 | catalogReference: {
29 | appId: WIX_STORES_APP_ID_BACK_IN_STOCK_NOTIFICATIONS,
30 | catalogItemId: product._id,
31 | options: selectedVariant
32 | ? {
33 | variantId: selectedVariant._id,
34 | }
35 | : { options: selectedOptions },
36 | },
37 | },
38 | {
39 | name: product.name || undefined,
40 | price: product.priceData?.discountedPrice?.toFixed(2),
41 | image: product.media?.mainMedia?.image?.url,
42 | },
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/wix-api/cart.ts:
--------------------------------------------------------------------------------
1 | import { WIX_STORES_APP_ID } from "@/lib/constants";
2 | import { findVariant } from "@/lib/utils";
3 | import { WixClient } from "@/lib/wix-client.base";
4 | import { products } from "@wix/stores";
5 |
6 | export async function getCart(wixClient: WixClient) {
7 | try {
8 | return await wixClient.currentCart.getCurrentCart();
9 | } catch (error) {
10 | if (
11 | (error as any).details.applicationError.code === "OWNED_CART_NOT_FOUND"
12 | ) {
13 | return null;
14 | } else {
15 | throw error;
16 | }
17 | }
18 | }
19 |
20 | export interface AddToCartValues {
21 | product: products.Product;
22 | selectedOptions: Record;
23 | quantity: number;
24 | }
25 |
26 | export async function addToCart(
27 | wixClient: WixClient,
28 | { product, selectedOptions, quantity }: AddToCartValues,
29 | ) {
30 | const selectedVariant = findVariant(product, selectedOptions);
31 |
32 | return wixClient.currentCart.addToCurrentCart({
33 | lineItems: [
34 | {
35 | catalogReference: {
36 | appId: WIX_STORES_APP_ID,
37 | catalogItemId: product._id,
38 | options: selectedVariant
39 | ? {
40 | variantId: selectedVariant._id,
41 | }
42 | : { options: selectedOptions },
43 | },
44 | quantity,
45 | },
46 | ],
47 | });
48 | }
49 |
50 | export interface UpdateCartItemQuantityValues {
51 | productId: string;
52 | newQuantity: number;
53 | }
54 |
55 | export async function updateCartItemQuantity(
56 | wixClient: WixClient,
57 | { productId, newQuantity }: UpdateCartItemQuantityValues,
58 | ) {
59 | return wixClient.currentCart.updateCurrentCartLineItemQuantity([
60 | {
61 | _id: productId,
62 | quantity: newQuantity,
63 | },
64 | ]);
65 | }
66 |
67 | export async function removeCartItem(wixClient: WixClient, productId: string) {
68 | return wixClient.currentCart.removeLineItemsFromCurrentCart([productId]);
69 | }
70 |
71 | export async function clearCart(wixClient: WixClient) {
72 | try {
73 | return await wixClient.currentCart.deleteCurrentCart();
74 | } catch (error) {
75 | if (
76 | (error as any).details.applicationError.code === "OWNED_CART_NOT_FOUND"
77 | ) {
78 | return;
79 | } else {
80 | throw error;
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/wix-api/checkout.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 | import { WIX_STORES_APP_ID } from "@/lib/constants";
3 | import { findVariant } from "@/lib/utils";
4 | import { WixClient } from "@/lib/wix-client.base";
5 | import { checkout } from "@wix/ecom";
6 | import { products } from "@wix/stores";
7 |
8 | export async function getCheckoutUrlForCurrentCart(wixClient: WixClient) {
9 | const { checkoutId } =
10 | await wixClient.currentCart.createCheckoutFromCurrentCart({
11 | channelType: checkout.ChannelType.WEB,
12 | });
13 |
14 | const { redirectSession } = await wixClient.redirects.createRedirectSession({
15 | ecomCheckout: { checkoutId },
16 | callbacks: {
17 | postFlowUrl: window.location.href,
18 | thankYouPageUrl: env.NEXT_PUBLIC_BASE_URL + "/checkout-success",
19 | },
20 | });
21 |
22 | if (!redirectSession) {
23 | throw Error("Failed to create redirect session");
24 | }
25 |
26 | return redirectSession.fullUrl;
27 | }
28 |
29 | export interface GetCheckoutUrlForProductValues {
30 | product: products.Product;
31 | quantity: number;
32 | selectedOptions: Record;
33 | }
34 |
35 | export async function getCheckoutUrlForProduct(
36 | wixClient: WixClient,
37 | { product, quantity, selectedOptions }: GetCheckoutUrlForProductValues,
38 | ) {
39 | const selectedVariant = findVariant(product, selectedOptions);
40 |
41 | const { _id } = await wixClient.checkout.createCheckout({
42 | channelType: checkout.ChannelType.WEB,
43 | lineItems: [
44 | {
45 | catalogReference: {
46 | appId: WIX_STORES_APP_ID,
47 | catalogItemId: product._id,
48 | options: selectedVariant
49 | ? {
50 | variantId: selectedVariant._id,
51 | }
52 | : { options: selectedOptions },
53 | },
54 | quantity,
55 | },
56 | ],
57 | });
58 |
59 | if (!_id) {
60 | throw new Error("Failed to create checkout");
61 | }
62 |
63 | const { redirectSession } = await wixClient.redirects.createRedirectSession({
64 | ecomCheckout: { checkoutId: _id },
65 | callbacks: {
66 | postFlowUrl: window.location.href,
67 | thankYouPageUrl: env.NEXT_PUBLIC_BASE_URL + "/checkout-success",
68 | },
69 | });
70 |
71 | if (!redirectSession) {
72 | throw Error("Failed to create redirect session");
73 | }
74 |
75 | return redirectSession.fullUrl;
76 | }
77 |
--------------------------------------------------------------------------------
/src/wix-api/collections.ts:
--------------------------------------------------------------------------------
1 | import { WixClient } from "@/lib/wix-client.base";
2 | import { collections } from "@wix/stores";
3 | import { cache } from "react";
4 |
5 | export const getCollectionBySlug = cache(
6 | async (wixClient: WixClient, slug: string) => {
7 | const { collection } =
8 | await wixClient.collections.getCollectionBySlug(slug);
9 |
10 | return collection || null;
11 | },
12 | );
13 |
14 | export const getCollections = cache(
15 | async (wixClient: WixClient): Promise => {
16 | const collections = await wixClient.collections
17 | .queryCollections()
18 | .ne("_id", "00000000-000000-000000-000000000001") // all products
19 | .ne("_id", "32510e2b-cc3b-8d4e-ebe5-2797c8b9ad49") // featured products
20 | .find();
21 |
22 | return collections.items;
23 | },
24 | );
25 |
--------------------------------------------------------------------------------
/src/wix-api/members.ts:
--------------------------------------------------------------------------------
1 | import { WixClient } from "@/lib/wix-client.base";
2 | import { members } from "@wix/members";
3 | import { cache } from "react";
4 |
5 | export const getLoggedInMember = cache(
6 | async (wixClient: WixClient): Promise => {
7 | if (!wixClient.auth.loggedIn()) {
8 | return null;
9 | }
10 |
11 | const memberData = await wixClient.members.getCurrentMember({
12 | fieldsets: [members.Set.FULL],
13 | });
14 |
15 | return memberData.member || null;
16 | },
17 | );
18 |
19 | export interface UpdateMemberInfoValues {
20 | firstName: string;
21 | lastName: string;
22 | }
23 |
24 | export async function updateMemberInfo(
25 | wixClient: WixClient,
26 | { firstName, lastName }: UpdateMemberInfoValues,
27 | ) {
28 | const loggedInMember = await getLoggedInMember(wixClient);
29 |
30 | if (!loggedInMember?._id) {
31 | throw Error("No member ID found");
32 | }
33 |
34 | return wixClient.members.updateMember(loggedInMember._id, {
35 | contact: {
36 | firstName,
37 | lastName,
38 | },
39 | });
40 | }
41 |
--------------------------------------------------------------------------------
/src/wix-api/orders.ts:
--------------------------------------------------------------------------------
1 | import { WixClient } from "@/lib/wix-client.base";
2 |
3 | export async function getOrder(wixClient: WixClient, orderId: string) {
4 | try {
5 | return await wixClient.orders.getOrder(orderId);
6 | } catch (error) {
7 | if ((error as any).details.applicationError.code === "NOT_FOUND") {
8 | return null;
9 | } else {
10 | throw error;
11 | }
12 | }
13 | }
14 |
15 | export interface GetUserOrderFilters {
16 | limit?: number;
17 | cursor?: string | null;
18 | }
19 |
20 | export async function getUserOrders(
21 | wixClient: WixClient,
22 | { limit, cursor }: GetUserOrderFilters,
23 | ) {
24 | return wixClient.orders.searchOrders({
25 | search: {
26 | cursorPaging: {
27 | limit,
28 | cursor,
29 | },
30 | },
31 | });
32 | }
33 |
--------------------------------------------------------------------------------
/src/wix-api/products.ts:
--------------------------------------------------------------------------------
1 | import { WIX_STORES_APP_ID } from "@/lib/constants";
2 | import { WixClient } from "@/lib/wix-client.base";
3 | import { cache } from "react";
4 |
5 | export type ProductsSort = "last_updated" | "price_asc" | "price_desc";
6 |
7 | interface QueryProductsFilter {
8 | q?: string;
9 | collectionIds?: string[] | string;
10 | sort?: ProductsSort;
11 | priceMin?: number;
12 | priceMax?: number;
13 | skip?: number;
14 | limit?: number;
15 | }
16 |
17 | export async function queryProducts(
18 | wixClient: WixClient,
19 | {
20 | q,
21 | collectionIds,
22 | sort = "last_updated",
23 | priceMin,
24 | priceMax,
25 | skip,
26 | limit,
27 | }: QueryProductsFilter,
28 | ) {
29 | let query = wixClient.products.queryProducts();
30 |
31 | if (q) {
32 | query = query.startsWith("name", q);
33 | }
34 |
35 | const collectionIdsArray = collectionIds
36 | ? Array.isArray(collectionIds)
37 | ? collectionIds
38 | : [collectionIds]
39 | : [];
40 |
41 | if (collectionIdsArray.length > 0) {
42 | query = query.hasSome("collectionIds", collectionIdsArray);
43 | }
44 |
45 | switch (sort) {
46 | case "price_asc":
47 | query = query.ascending("price");
48 | break;
49 | case "price_desc":
50 | query = query.descending("price");
51 | break;
52 | case "last_updated":
53 | query = query.descending("lastUpdated");
54 | break;
55 | }
56 |
57 | if (priceMin) {
58 | query = query.ge("priceData.price", priceMin);
59 | }
60 |
61 | if (priceMax) {
62 | query = query.le("priceData.price", priceMax);
63 | }
64 |
65 | if (limit) query = query.limit(limit);
66 | if (skip) query = query.skip(skip);
67 |
68 | return query.find();
69 | }
70 |
71 | export const getProductBySlug = cache(
72 | async (wixClient: WixClient, slug: string) => {
73 | const { items } = await wixClient.products
74 | .queryProducts()
75 | .eq("slug", slug)
76 | .limit(1)
77 | .find();
78 |
79 | const product = items[0];
80 |
81 | if (!product || !product.visible) {
82 | return null;
83 | }
84 |
85 | return product;
86 | },
87 | );
88 |
89 | export async function getProductById(wixClient: WixClient, productId: string) {
90 | const result = await wixClient.products.getProduct(productId);
91 | return result.product;
92 | }
93 |
94 | export async function getRelatedProducts(
95 | wixClient: WixClient,
96 | productId: string,
97 | ) {
98 | const result = await wixClient.recommendations.getRecommendation(
99 | [
100 | {
101 | _id: "68ebce04-b96a-4c52-9329-08fc9d8c1253", // "From the same categories"
102 | appId: WIX_STORES_APP_ID,
103 | },
104 | {
105 | _id: "d5aac1e1-2e53-4d11-85f7-7172710b4783", // "Frequenly bought together"
106 | appId: WIX_STORES_APP_ID,
107 | },
108 | ],
109 | {
110 | items: [
111 | {
112 | appId: WIX_STORES_APP_ID,
113 | catalogItemId: productId,
114 | },
115 | ],
116 | minimumRecommendedItems: 3,
117 | },
118 | );
119 |
120 | const productIds = result.recommendation?.items
121 | .map((item) => item.catalogItemId)
122 | .filter((id) => id !== undefined);
123 |
124 | if (!productIds || !productIds.length) return [];
125 |
126 | const productsResult = await wixClient.products
127 | .queryProducts()
128 | .in("_id", productIds)
129 | .limit(4)
130 | .find();
131 |
132 | return productsResult.items;
133 | }
134 |
--------------------------------------------------------------------------------
/src/wix-api/reviews.ts:
--------------------------------------------------------------------------------
1 | import { WixClient } from "@/lib/wix-client.base";
2 | import { getLoggedInMember } from "./members";
3 |
4 | export interface CreateProductReviewValues {
5 | productId: string;
6 | title: string;
7 | body: string;
8 | rating: number;
9 | media: { url: string; type: "image" | "video" }[];
10 | }
11 |
12 | export async function createProductReview(
13 | wixClient: WixClient,
14 | { productId, title, body, rating, media }: CreateProductReviewValues,
15 | ) {
16 | const member = await getLoggedInMember(wixClient);
17 |
18 | if (!member) {
19 | throw Error("Must be logged in to create a review");
20 | }
21 |
22 | const authorName =
23 | member.contact?.firstName && member.contact?.lastName
24 | ? `${member.contact.firstName} ${member.contact.lastName}`
25 | : member.contact?.firstName ||
26 | member.contact?.lastName ||
27 | member.profile?.nickname ||
28 | "Anonymous";
29 |
30 | return wixClient.reviews.createReview({
31 | author: {
32 | authorName,
33 | contactId: member.contactId,
34 | },
35 | entityId: productId,
36 | namespace: "stores",
37 | content: {
38 | title,
39 | body,
40 | rating,
41 | media: media.map(({ url, type }) =>
42 | type === "image" ? { image: url } : { video: url },
43 | ),
44 | },
45 | });
46 | }
47 |
48 | interface GetProductReviewsFilters {
49 | productId: string;
50 | contactId?: string;
51 | limit?: number;
52 | cursor?: string | null;
53 | }
54 |
55 | export async function getProductReviews(
56 | wixClient: WixClient,
57 | { productId, contactId, limit, cursor }: GetProductReviewsFilters,
58 | ) {
59 | let query = wixClient.reviews.queryReviews().eq("entityId", productId);
60 |
61 | if (contactId) {
62 | // @ts-expect-error
63 | query = query.eq("author.contactId", contactId);
64 | }
65 |
66 | if (limit) {
67 | query = query.limit(limit);
68 | }
69 |
70 | if (cursor) {
71 | query = query.skipTo(cursor);
72 | }
73 |
74 | return query.find();
75 | }
76 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | extend: {
12 | colors: {
13 | background: "hsl(var(--background))",
14 | foreground: "hsl(var(--foreground))",
15 | card: {
16 | DEFAULT: "hsl(var(--card))",
17 | foreground: "hsl(var(--card-foreground))",
18 | },
19 | popover: {
20 | DEFAULT: "hsl(var(--popover))",
21 | foreground: "hsl(var(--popover-foreground))",
22 | },
23 | primary: {
24 | DEFAULT: "hsl(var(--primary))",
25 | foreground: "hsl(var(--primary-foreground))",
26 | },
27 | secondary: {
28 | DEFAULT: "hsl(var(--secondary))",
29 | foreground: "hsl(var(--secondary-foreground))",
30 | },
31 | muted: {
32 | DEFAULT: "hsl(var(--muted))",
33 | foreground: "hsl(var(--muted-foreground))",
34 | },
35 | accent: {
36 | DEFAULT: "hsl(var(--accent))",
37 | foreground: "hsl(var(--accent-foreground))",
38 | },
39 | destructive: {
40 | DEFAULT: "hsl(var(--destructive))",
41 | foreground: "hsl(var(--destructive-foreground))",
42 | },
43 | border: "hsl(var(--border))",
44 | input: "hsl(var(--input))",
45 | ring: "hsl(var(--ring))",
46 | chart: {
47 | "1": "hsl(var(--chart-1))",
48 | "2": "hsl(var(--chart-2))",
49 | "3": "hsl(var(--chart-3))",
50 | "4": "hsl(var(--chart-4))",
51 | "5": "hsl(var(--chart-5))",
52 | },
53 | },
54 | fontFamily: {
55 | sans: ["var(--font-geist-sans)"],
56 | mono: ["var(--font-geist-mono)"],
57 | },
58 | borderRadius: {
59 | lg: "var(--radius)",
60 | md: "calc(var(--radius) - 2px)",
61 | sm: "calc(var(--radius) - 4px)",
62 | },
63 | keyframes: {
64 | "accordion-down": {
65 | from: {
66 | height: "0",
67 | },
68 | to: {
69 | height: "var(--radix-accordion-content-height)",
70 | },
71 | },
72 | "accordion-up": {
73 | from: {
74 | height: "var(--radix-accordion-content-height)",
75 | },
76 | to: {
77 | height: "0",
78 | },
79 | },
80 | },
81 | animation: {
82 | "accordion-down": "accordion-down 0.2s ease-out",
83 | "accordion-up": "accordion-up 0.2s ease-out",
84 | },
85 | },
86 | },
87 | plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
88 | };
89 | export default config;
90 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------