├── .env.example
├── .eslintrc.js
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .vscode
├── launch.json
└── settings.json
├── README.md
├── app
├── (checkout)
│ ├── cart
│ │ └── page.tsx
│ └── checkout
│ │ ├── information
│ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── payment
│ │ └── page.tsx
│ │ ├── place-order
│ │ └── page.tsx
│ │ └── shipping
│ │ └── page.tsx
├── (customer)
│ └── customer
│ │ ├── forget-password
│ │ └── page.tsx
│ │ ├── login
│ │ └── page.tsx
│ │ └── register
│ │ └── page.tsx
├── [page]
│ ├── layout.tsx
│ ├── opengraph-image.tsx
│ └── page.tsx
├── api
│ ├── auth
│ │ └── [...nextauth]
│ │ │ └── route.ts
│ └── revalidate
│ │ └── route.ts
├── context
│ └── store.tsx
├── error.tsx
├── favicon.ico
├── globals.css
├── icon.png
├── layout.tsx
├── next-auth-provider.tsx
├── opengraph-image.tsx
├── page.tsx
├── product
│ └── [handle]
│ │ ├── layout.tsx
│ │ └── page.tsx
├── providers.tsx
├── robots.ts
├── search
│ ├── [collection]
│ │ ├── opengraph-image.tsx
│ │ └── page.tsx
│ ├── layout.tsx
│ ├── loading.tsx
│ └── page.tsx
└── sitemap.ts
├── auth.ts
├── changelog.md
├── components
├── carousel.tsx
├── cart
│ ├── actions.ts
│ ├── add-to-cart.tsx
│ ├── close-cart.tsx
│ ├── delete-item-button.tsx
│ ├── edit-item-quantity-button.tsx
│ ├── index.tsx
│ ├── modal.tsx
│ └── open-cart.tsx
├── checkout
│ ├── action.ts
│ ├── cart
│ │ ├── actions.ts
│ │ ├── cart-item-accordian.tsx
│ │ ├── cart.tsx
│ │ ├── empty-cart.tsx
│ │ ├── event-button.tsx
│ │ ├── input.tsx
│ │ ├── order-detail.tsx
│ │ └── proceed-to-checkout.tsx
│ ├── information
│ │ └── checkout-form.tsx
│ ├── loading.tsx
│ ├── next-breadcrumb.tsx
│ ├── payment
│ │ └── index.tsx
│ ├── place-holder.tsx
│ ├── place-order
│ │ └── index.tsx
│ ├── region-drop-down.tsx
│ ├── select-box.tsx
│ └── shipping
│ │ └── index.tsx
├── customer
│ ├── index.tsx
│ ├── lib
│ │ └── action.ts
│ ├── login
│ │ ├── forget-password.tsx
│ │ ├── loading-button.tsx
│ │ ├── login-form.tsx
│ │ └── registration-form.tsx
│ ├── modal.tsx
│ └── open-auth.tsx
├── grid
│ ├── index.tsx
│ ├── three-items.tsx
│ └── tile.tsx
├── icons
│ ├── check-sign.tsx
│ ├── logo.tsx
│ ├── right-arrow.tsx
│ ├── seprator.tsx
│ ├── shopping-cart.tsx
│ └── wallet-logo.tsx
├── label.tsx
├── layout
│ ├── footer-menu.tsx
│ ├── footer.tsx
│ ├── navbar
│ │ ├── index.tsx
│ │ ├── mobile-menu.tsx
│ │ └── search.tsx
│ ├── product-grid-items.tsx
│ └── search
│ │ ├── collections.tsx
│ │ └── filter
│ │ ├── dropdown.tsx
│ │ ├── index.tsx
│ │ └── item.tsx
├── loading-dots.tsx
├── logo-square.tsx
├── opengraph-image.tsx
├── price.tsx
├── product
│ ├── gallery.tsx
│ ├── product-description.tsx
│ └── variant-selector.tsx
└── prose.tsx
├── fonts
└── Inter-Bold.ttf
├── lib
├── bagisto
│ ├── fragments
│ │ ├── cart.ts
│ │ ├── image.ts
│ │ ├── product.ts
│ │ └── seo.ts
│ ├── index.ts
│ ├── mutations
│ │ ├── cart.ts
│ │ ├── customer-login.ts
│ │ ├── customer-register.ts
│ │ ├── payment-method.ts
│ │ ├── place-order.ts
│ │ ├── recover-password.ts
│ │ ├── shipping-address.ts
│ │ └── shipping-method.ts
│ ├── queries
│ │ ├── cart.ts
│ │ ├── channel.ts
│ │ ├── collection.ts
│ │ ├── filter-attribute.ts
│ │ ├── menu.ts
│ │ ├── page.ts
│ │ ├── payment-methods.ts
│ │ ├── product.ts
│ │ └── shipping-method.ts
│ └── types.ts
├── constants.ts
├── type-guards.ts
└── utils.ts
├── license.md
├── middleware.ts
├── next.config.js
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── prettier.config.js
├── public
└── image
│ └── placeholder.webp
├── tailwind.config.js
├── tsconfig.json
├── types
└── next-auth.d.ts
└── yarn.lock
/.env.example:
--------------------------------------------------------------------------------
1 | COMPANY_NAME="Vercel Inc."
2 | TWITTER_CREATOR="@vercel"
3 | TWITTER_SITE="https://nextjs.org/commerce"
4 | SITE_NAME="Next.js Commerce"
5 | BAGISTO_REVALIDATION_SECRET=""
6 | BAGISTO_STOREFRONT_ACCESS_TOKEN=""
7 | BAGISTO_PROTOCOL=""
8 | BAGISTO_STORE_DOMAIN="[your-bagisto-store-subdomain].mybagisto.com"
9 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['next', 'prettier'],
3 | plugins: ['unicorn'],
4 | rules: {
5 | 'no-unused-vars': [
6 | 'error',
7 | {
8 | args: 'after-used',
9 | caughtErrors: 'none',
10 | ignoreRestSiblings: true,
11 | vars: 'all'
12 | }
13 | ],
14 | 'prefer-const': 'error',
15 | 'react-hooks/exhaustive-deps': 'error',
16 | 'unicorn/filename-case': [
17 | 'error',
18 | {
19 | case: 'kebabCase'
20 | }
21 | ]
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/.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 |
8 | # testing
9 | /coverage
10 | .playwright
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 | .pnpm-debug.log*
28 |
29 | # local env files
30 | .env*
31 | !.env.example
32 |
33 | # vercel
34 | .vercel
35 |
36 | # typescript
37 | *.tsbuildinfo
38 | next-env.d.ts
39 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 18
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .vercel
2 | .next
3 | pnpm-lock.yaml
4 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Next.js: debug server-side",
6 | "type": "node-terminal",
7 | "request": "launch",
8 | "command": "pnpm dev"
9 | },
10 | {
11 | "name": "Next.js: debug client-side",
12 | "type": "chrome",
13 | "request": "launch",
14 | "url": "http://localhost:3000"
15 | },
16 | {
17 | "name": "Next.js: debug full stack",
18 | "type": "node-terminal",
19 | "request": "launch",
20 | "command": "pnpm dev",
21 | "serverReadyAction": {
22 | "pattern": "started server on .+, url: (https?://.+)",
23 | "uriFormat": "%s",
24 | "action": "debugWithChrome"
25 | }
26 | }
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib",
3 | "typescript.enablePromptUseWorkspaceTsdk": true,
4 | "editor.codeActionsOnSave": {
5 | "source.fixAll": "explicit",
6 | "source.organizeImports": "explicit",
7 | "source.sortMembers": "explicit"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next.js Commerce Bagisto
2 |
3 | A Next.js 14 and App Router-ready ecommerce template featuring:
4 |
5 | - Next.js App Router
6 | - Optimized for SEO using Next.js's Metadata
7 | - React Server Components (RSCs) and Suspense
8 | - Server Actions for mutations
9 | - Edge Runtime
10 | - New fetching and caching paradigms
11 | - Dynamic OG images
12 | - Styling with Tailwind CSS
13 | - Checkout and payments with Bagisto
14 | - Automatic light/dark mode based on system settings
15 |
16 | Demo live at: [Bagisto NextJs Commerce](https://v2-bagisto-demo.vercel.app)
17 |
18 | ## Configuration
19 |
20 | ### Setup Bagisto Store
21 |
22 | - For `BAGISTO_PROTOCOL`, `BAGISTO_STOREFRONT_ACCESS_TOKEN`, `BAGISTO_REVALIDATION_SECRET` and `BAGISTO_STORE_DOMAIN`, you need to install the [Bagisto](https://github.com/bagisto/bagisto).
23 | - Then, you need to install the [Bagisto Headless Extension](https://github.com/bagisto/headless-ecommerce) in the Bagisto.
24 | - Now you need to host the full application so that you have store endpoint and if you are in development mode then you can use Ngrok also.
25 | - After that you can proceed with setting up Next.js commerce.
26 |
27 | ## Running locally
28 |
29 | You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.
30 |
31 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Bagisto store.
32 |
33 | 1. Install Vercel CLI: `npm i -g vercel`
34 | 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
35 | 3. Download your environment variables: `vercel env pull`
36 |
37 | ```bash
38 | pnpm install
39 | pnpm dev
40 | ```
41 |
42 | Your app should now be running on [localhost:3000](http://localhost:3000/).
43 |
--------------------------------------------------------------------------------
/app/(checkout)/cart/page.tsx:
--------------------------------------------------------------------------------
1 | import EmptyCartPage from 'components/checkout/cart/empty-cart';
2 | const CartPage = () => {
3 | return ;
4 | };
5 |
6 | export default CartPage;
7 |
--------------------------------------------------------------------------------
/app/(checkout)/checkout/information/page.tsx:
--------------------------------------------------------------------------------
1 | import FormPlaceHolder from 'components/checkout/place-holder';
2 | import { getCart, getCountryList } from 'lib/bagisto';
3 | import { Metadata } from 'next';
4 | import dynamic from 'next/dynamic';
5 | const GuestCheckOutForm = dynamic(() => import('components/checkout/information/checkout-form'), {
6 | loading: () => ,
7 | ssr: false
8 | });
9 | export default async function Information() {
10 | const countryList = await getCountryList();
11 | const cart = await getCart();
12 | return ;
13 | }
14 | export const metadata: Metadata = {
15 | title: 'Checkout',
16 | description: 'Checkout with store items'
17 | };
18 |
--------------------------------------------------------------------------------
/app/(checkout)/checkout/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Divider } from '@heroui/divider';
2 | import Cart from 'components/checkout/cart/cart';
3 | import Loading from 'components/checkout/loading';
4 | import NextBreadcrumb from 'components/checkout/next-breadcrumb';
5 | import FormPlaceHolder from 'components/checkout/place-holder';
6 | import LogoSquare from 'components/logo-square';
7 | import Link from 'next/link';
8 | import { Suspense } from 'react';
9 | const { SITE_NAME } = process.env;
10 | export default async function CheckoutLayout({ children }: React.PropsWithChildren) {
11 | return (
12 |
13 |
14 |
15 |
16 |
30 |
}> {children}
31 |
32 |
33 |
34 | All rights reserved Dev Vercel Shop.
35 |
36 |
37 |
38 | }>
39 |
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/app/(checkout)/checkout/payment/page.tsx:
--------------------------------------------------------------------------------
1 | import FormPlaceHolder from 'components/checkout/place-holder';
2 | import { getCart, getPaymentMethod } from 'lib/bagisto';
3 |
4 | import dynamic from 'next/dynamic';
5 |
6 | const PaymentPage = dynamic(() => import('components/checkout/payment'), {
7 | loading: () => ,
8 | ssr: false
9 | });
10 | const payment = async () => {
11 | const cart = await getCart();
12 |
13 | const methods = await getPaymentMethod({
14 | shippingMethod: cart?.selectedShippingRate?.method || ''
15 | });
16 | return (
17 |
23 | );
24 | };
25 |
26 | export default payment;
27 |
--------------------------------------------------------------------------------
/app/(checkout)/checkout/place-order/page.tsx:
--------------------------------------------------------------------------------
1 | import { getCart } from 'lib/bagisto';
2 | import dynamic from 'next/dynamic';
3 | const PlaceOrderPage = dynamic(() => import('components/checkout/place-order'), {
4 | ssr: false
5 | });
6 | export default async function palaceOrder() {
7 | const cart = await getCart();
8 | return (
9 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/app/(checkout)/checkout/shipping/page.tsx:
--------------------------------------------------------------------------------
1 | import FormPlaceHolder from 'components/checkout/place-holder';
2 | import { getCart, getShippingMethod } from 'lib/bagisto';
3 | import type { Metadata } from 'next';
4 | import dynamic from 'next/dynamic';
5 | const ShippingMethod = dynamic(() => import('components/checkout/shipping'), {
6 | loading: () => ,
7 | ssr: false
8 | });
9 | const Shipping = async () => {
10 | const cart = await getCart();
11 | const shippingMethod = await getShippingMethod();
12 |
13 | return ;
14 | };
15 | export default Shipping;
16 | export const metadata: Metadata = {
17 | title: 'Checkout',
18 | description: 'Checkout with store items'
19 | };
20 |
--------------------------------------------------------------------------------
/app/(customer)/customer/forget-password/page.tsx:
--------------------------------------------------------------------------------
1 | import { ForgetPasswordForm } from 'components/customer/login/forget-password';
2 | import Link from 'next/link';
3 | import LogoSquare from '../../../../components/logo-square';
4 |
5 | export const metadata = {
6 | title: 'Search',
7 | description: 'Search for products in the store.'
8 | };
9 | const { SITE_NAME } = process.env;
10 | export default function ForgetPasswordPage() {
11 | return (
12 |
13 |
14 |
15 |
19 |
20 | {SITE_NAME}
21 |
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/app/(customer)/customer/login/page.tsx:
--------------------------------------------------------------------------------
1 | import { LoginForm } from 'components/customer/login/login-form';
2 | import Link from 'next/link';
3 | import LogoSquare from '../../../../components/logo-square';
4 |
5 | export const metadata = { title: 'Search', description: 'Search for products in the store.' };
6 | const { SITE_NAME } = process.env;
7 |
8 | export default async function LoginPage() {
9 | return (
10 |
11 |
12 |
13 |
17 |
18 | {SITE_NAME}
19 |
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/app/(customer)/customer/register/page.tsx:
--------------------------------------------------------------------------------
1 | import RegistrationForm from 'components/customer/login/registration-form';
2 | import LogoSquare from 'components/logo-square';
3 | import Link from 'next/link';
4 |
5 | export const metadata = {
6 | title: 'Registration Form',
7 | description: 'Customer registration page'
8 | };
9 | const { SITE_NAME } = process.env;
10 | export default async function Register() {
11 | return (
12 |
13 |
14 |
15 |
19 |
20 | {SITE_NAME}
21 |
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/app/[page]/layout.tsx:
--------------------------------------------------------------------------------
1 | import Footer from 'components/layout/footer';
2 | import Navbar from 'components/layout/navbar';
3 |
4 | export default function SearchLayout({ children }: { children: React.ReactNode }) {
5 | return (
6 | <>
7 |
11 |
12 | >
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/app/[page]/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import OpenGraphImage from 'components/opengraph-image';
2 | import { getPage } from 'lib/bagisto';
3 |
4 | export default async function Image({ params }: { params: { page: string } }) {
5 | const page = await getPage({ urlKey: params.page });
6 | const pageData = page?.data?.[0]?.translations?.[0];
7 | const title = pageData?.metaTitle || pageData?.pageTitle;
8 |
9 | return await OpenGraphImage({ title });
10 | }
11 |
--------------------------------------------------------------------------------
/app/[page]/page.tsx:
--------------------------------------------------------------------------------
1 | import Prose from 'components/prose';
2 | import { getPage } from 'lib/bagisto';
3 | import type { Metadata } from 'next';
4 | import { notFound } from 'next/navigation';
5 |
6 | export async function generateMetadata({
7 | params
8 | }: {
9 | params: { page: string };
10 | }): Promise {
11 | const page = await getPage({ urlKey: params.page });
12 | if (!page?.data?.length) return notFound();
13 |
14 | const pageData = page?.data?.[0]?.translations?.[0];
15 | return {
16 | title: pageData?.metaTitle || pageData?.pageTitle,
17 | description: pageData?.metaDescription || pageData?.htmlContent,
18 | openGraph: {
19 | publishedTime: page?.data?.[0]?.createdAt || '---',
20 | modifiedTime: page?.data?.[0]?.updatedAt || '---',
21 | type: 'article'
22 | }
23 | };
24 | }
25 |
26 | export default async function Page({ params }: { params: { page: string } }) {
27 | const page = await getPage({ urlKey: params.page });
28 |
29 | if (!page?.data?.length) return notFound();
30 |
31 | const pageData = page?.data?.[0]?.translations?.[0];
32 | return (
33 | <>
34 | {pageData?.pageTitle}
35 |
36 |
37 | {`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
38 | year: 'numeric',
39 | month: 'long',
40 | day: 'numeric'
41 | })?.format(new Date(page?.data?.[0]?.updatedAt || '---'))}.`}
42 |
43 | >
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | import { handler } from 'auth';
2 | export { handler as GET, handler as POST };
3 |
--------------------------------------------------------------------------------
/app/api/revalidate/route.ts:
--------------------------------------------------------------------------------
1 | import { revalidate } from 'lib/bagisto';
2 | import { NextRequest, NextResponse } from 'next/server';
3 | export async function POST(req: NextRequest): Promise {
4 | return revalidate(req);
5 | }
6 |
--------------------------------------------------------------------------------
/app/context/store.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { Dispatch, SetStateAction, createContext, useContext, useState } from 'react';
3 | interface ContextProps {
4 | countryCode: string;
5 | setCountryCode: Dispatch>;
6 | }
7 | const GlobalContext = createContext({
8 | countryCode: '',
9 | setCountryCode: (): string => ''
10 | });
11 |
12 | export const GlobalContextProvider = ({ children }: { children: React.ReactNode }) => {
13 | const [countryCode, setCountryCode] = useState('');
14 |
15 | return (
16 |
17 | {children}
18 |
19 | );
20 | };
21 | export const useGlobalContext = () => useContext(GlobalContext);
22 |
--------------------------------------------------------------------------------
/app/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | export default function Error({ reset }: { reset: () => void }) {
4 | return (
5 |
6 |
Oh no!
7 |
8 | There was an issue with our storefront. This could be a temporary issue, please try your
9 | action again.
10 |
11 |
reset()}
14 | >
15 | Try Again
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bagisto/nextjs-commerce/d3b59621fc5027898416cf2add9b4d65a67eecc0/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | *::-webkit-scrollbar-track {
6 | -webkit-box-shadow: none;
7 | @apply border-opacity-100 bg-white dark:bg-black;
8 | }
9 |
10 | *::-webkit-scrollbar {
11 | @apply my-5 w-[6px] bg-gray-300;
12 | }
13 |
14 | *::-webkit-scrollbar-thumb {
15 | @apply my-5 rounded-full bg-gray-500 dark:bg-gray-300;
16 | }
17 | /* input auto complete background color */
18 | input:-webkit-autofill,
19 | input:-webkit-autofill:focus {
20 | transition:
21 | background-color 600000s 0s,
22 | color 600000s 0s;
23 | }
24 | input[data-autocompleted] {
25 | background-color: transparent !important;
26 | }
27 | .hiddenScrollBar::-webkit-scrollbar {
28 | @apply my-5 h-[6px] bg-gray-300;
29 | }
30 | @media (prefers-color-scheme: dark) {
31 | html {
32 | color-scheme: dark;
33 | }
34 | }
35 | @supports (font: -apple-system-body) and (-webkit-appearance: none) {
36 | img[loading='lazy'] {
37 | clip-path: inset(0.6px);
38 | }
39 | }
40 | .text-foreground-500 {
41 | color: white !important;
42 | }
43 | a,
44 | .input,
45 | button {
46 | @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
47 | }
48 |
--------------------------------------------------------------------------------
/app/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bagisto/nextjs-commerce/d3b59621fc5027898416cf2add9b4d65a67eecc0/app/icon.png
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import './globals.css';
2 | import { GeistSans } from 'geist/font';
3 | import { getChannel } from 'lib/bagisto';
4 | import { ensureStartsWith, getBaseUrl } from 'lib/utils';
5 | import { ReactNode } from 'react';
6 | import { GlobalContextProvider } from './context/store';
7 | import NextAuthProvider from './next-auth-provider';
8 | import { Providers } from './providers';
9 | export const dynamic = 'force-dynamic';
10 | const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
11 | const baseUrl = getBaseUrl(process.env.NEXT_PUBLIC_VERCEL_URL);
12 | const twitterCreator = TWITTER_CREATOR ? ensureStartsWith(TWITTER_CREATOR, '@') : undefined;
13 | const twitterSite = TWITTER_SITE ? ensureStartsWith(TWITTER_SITE, 'https://') : undefined;
14 |
15 | export const metadata = {
16 | metadataBase: new URL(baseUrl),
17 | title: {
18 | default: SITE_NAME!,
19 | template: `%s | ${SITE_NAME}`
20 | },
21 | robots: {
22 | follow: true,
23 | index: true
24 | },
25 | ...(twitterCreator &&
26 | twitterSite && {
27 | twitter: {
28 | card: 'summary_large_image',
29 | creator: twitterCreator,
30 | site: twitterSite
31 | }
32 | })
33 | };
34 |
35 | export default async function RootLayout({ children }: { children: ReactNode }) {
36 | const storeConfig = await getChannel();
37 | return (
38 |
39 |
40 |
41 |
42 |
46 |
47 |
48 |
49 | {children} {' '}
50 |
51 |
52 |
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/app/next-auth-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { SessionProvider } from 'next-auth/react';
3 | import { ReactNode } from 'react';
4 |
5 | export default function NextAuthProvider({ children }: { children: ReactNode }) {
6 | return {children} ;
7 | }
8 |
--------------------------------------------------------------------------------
/app/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import OpenGraphImage from 'components/opengraph-image';
2 |
3 | export default async function Image() {
4 | return await OpenGraphImage();
5 | }
6 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Carousel } from 'components/carousel';
2 | import { ThreeItemGrid } from 'components/grid/three-items';
3 | import Footer from 'components/layout/footer';
4 | import Navbar from 'components/layout/navbar';
5 |
6 | export const metadata = {
7 | description: 'High-performance ecommerce store built with Next.js, Vercel, and Bagisto.',
8 | openGraph: {
9 | type: 'website'
10 | }
11 | };
12 |
13 | export default async function HomePage() {
14 | return (
15 | <>
16 |
17 |
18 |
19 |
20 | >
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/app/product/[handle]/layout.tsx:
--------------------------------------------------------------------------------
1 | import Footer from 'components/layout/footer';
2 | import Navbar from 'components/layout/navbar';
3 | import { Suspense } from 'react';
4 | export default function SearchLayout({ children }: { children: React.ReactNode }) {
5 | return (
6 |
7 |
8 | {children}
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/app/product/[handle]/page.tsx:
--------------------------------------------------------------------------------
1 | import { GridTileImage } from 'components/grid/tile';
2 | import { Gallery } from 'components/product/gallery';
3 | import { ProductDescription } from 'components/product/product-description';
4 | import { getCollectionProducts } from 'lib/bagisto';
5 | import type { ImageInfo, RelatedProducts } from 'lib/bagisto/types';
6 | import { BASE_SCHEMA_URL, PRODUCT_OFFER_TYPE, PRODUCT_TYPE } from 'lib/constants';
7 | import { isArray } from 'lib/type-guards';
8 | import type { Metadata } from 'next';
9 | import Link from 'next/link';
10 | import { notFound } from 'next/navigation';
11 | import { Suspense } from 'react';
12 | export async function generateMetadata({
13 | params
14 | }: {
15 | params: { handle: string };
16 | }): Promise {
17 | const product = await getCollectionProducts({ collection: params.handle, page: 'product' });
18 | if (!product) return notFound();
19 | const data = product[0];
20 | const { url, altText: alt } = data?.images?.[0] || {};
21 |
22 | const { width, height = '100', name, description } = data || {};
23 | const indexable = true;
24 |
25 | return {
26 | title: name,
27 | description: description,
28 | robots: {
29 | index: indexable,
30 | follow: indexable,
31 | googleBot: {
32 | index: indexable,
33 | follow: indexable
34 | }
35 | },
36 | openGraph: url
37 | ? {
38 | images: [
39 | {
40 | url,
41 | width,
42 | height,
43 | alt
44 | }
45 | ]
46 | }
47 | : null
48 | };
49 | }
50 |
51 | export default async function ProductPage({ params }: { params: { handle: string } }) {
52 | const product = await getCollectionProducts({ collection: params.handle, page: 'product' });
53 | if (!product) return notFound();
54 | const data = product[0];
55 |
56 | const productJsonLd = {
57 | '@context': BASE_SCHEMA_URL,
58 | '@type': PRODUCT_TYPE,
59 | name: data?.name,
60 | description: data?.description,
61 | image: data?.images?.[0]?.url,
62 | offers: {
63 | '@type': PRODUCT_OFFER_TYPE,
64 | availability:
65 | data?.inventories?.[0]?.qty || 0 > 0
66 | ? `${BASE_SCHEMA_URL}/InStock`
67 | : `${BASE_SCHEMA_URL}/OutOfStock`,
68 | priceCurrency: data?.priceHtml.currencyCode,
69 | highPrice: data?.priceHtml?.regularPrice,
70 | lowPrice: data?.priceHtml?.regularPrice
71 | }
72 | };
73 |
74 | return (
75 | <>
76 |
82 |
83 |
84 |
85 |
88 | }
89 | >
90 | {isArray(data?.images) ? (
91 | ({
94 | src: image?.url || '',
95 | altText: image?.path || ''
96 | })) || []
97 | }
98 | />
99 | ) : (
100 |
108 | )}
109 |
110 |
111 |
114 |
115 |
116 |
117 | >
118 | );
119 | }
120 |
121 | async function RelatedProducts({ relatedProduct }: { relatedProduct: RelatedProducts[] }) {
122 | if (!relatedProduct.length) return null;
123 |
124 | return (
125 |
126 |
Related Products
127 |
128 | {relatedProduct.map((item) => (
129 |
133 |
134 |
145 |
146 |
147 | ))}
148 |
149 |
150 | );
151 | }
152 |
--------------------------------------------------------------------------------
/app/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { HeroUIProvider } from '@heroui/react';
3 |
4 | export function Providers({ children }: { children: React.ReactNode }) {
5 | return {children} ;
6 | }
7 |
--------------------------------------------------------------------------------
/app/robots.ts:
--------------------------------------------------------------------------------
1 | import { getBaseUrl } from 'lib/utils';
2 | const baseUrl = getBaseUrl(process.env.NEXT_PUBLIC_VERCEL_URL);
3 |
4 | export default function robots() {
5 | return {
6 | rules: [
7 | {
8 | userAgent: '*'
9 | }
10 | ],
11 | sitemap: `${baseUrl}/sitemap.xml`,
12 | host: baseUrl
13 | };
14 | }
15 |
--------------------------------------------------------------------------------
/app/search/[collection]/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import OpenGraphImage from 'components/opengraph-image';
2 | export default async function Image() {
3 | const title = 'Test SEO details';
4 | return await OpenGraphImage({ title });
5 | }
6 |
--------------------------------------------------------------------------------
/app/search/[collection]/page.tsx:
--------------------------------------------------------------------------------
1 | import Grid from 'components/grid';
2 | import ProductGridItems from 'components/layout/product-grid-items';
3 | import { getCollection, getCollectionProducts, getMenu } from 'lib/bagisto';
4 | import { defaultSort, sorting } from 'lib/constants';
5 | import { Metadata } from 'next';
6 | import { notFound } from 'next/navigation';
7 |
8 | export async function generateMetadata({
9 | params
10 | }: {
11 | params: { collection: string };
12 | }): Promise {
13 | const collections = await getMenu('header-menu');
14 | const categoryItem = collections.filter((item) => item.path == `/search/${params.collection}`);
15 | const collection = await getCollection(categoryItem?.[0]?.id || '');
16 | if (!collection) return notFound();
17 |
18 | const firstP = collection[0];
19 |
20 | return {
21 | title: firstP?.metaTitle || firstP?.name,
22 | description: firstP?.metaDescription || firstP?.description || `${firstP?.name} products`
23 | };
24 | }
25 |
26 | export default async function CategoryPage({
27 | params,
28 | searchParams
29 | }: {
30 | params: { collection: string };
31 | searchParams?: { [key: string]: string | string[] | undefined };
32 | }) {
33 | const collections = await getMenu('header-menu');
34 | const categoryItem = collections.filter((item) => item.path == `/search/${params.collection}`);
35 | const { sort } = searchParams as { [key: string]: string };
36 | const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
37 | const products = await getCollectionProducts({
38 | collection: categoryItem?.at(0)?.id || '',
39 | sortKey,
40 | reverse
41 | });
42 |
43 | return (
44 |
45 | {products.length === 0 ? (
46 | {`No products found in this collection`}
47 | ) : (
48 |
49 |
50 |
51 | )}
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/app/search/layout.tsx:
--------------------------------------------------------------------------------
1 | import Footer from 'components/layout/footer';
2 | import Navbar from 'components/layout/navbar';
3 | import Collections from 'components/layout/search/collections';
4 | import FilterList from 'components/layout/search/filter';
5 | import { sorting } from 'lib/constants';
6 |
7 | export default async function SearchLayout({ children }: { children: React.ReactNode }) {
8 | return (
9 | <>
10 |
11 |
12 |
13 |
14 |
15 |
{children}
16 |
17 |
18 |
19 |
20 |
21 | >
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/app/search/loading.tsx:
--------------------------------------------------------------------------------
1 | import Grid from 'components/grid';
2 |
3 | export default function Loading() {
4 | return (
5 |
6 | {Array(12)
7 | .fill(0)
8 | .map((_, index) => {
9 | return (
10 |
11 | );
12 | })}
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/app/search/page.tsx:
--------------------------------------------------------------------------------
1 | import Grid from 'components/grid';
2 | import ProductGridItems from 'components/layout/product-grid-items';
3 | import { getProducts } from 'lib/bagisto';
4 | import { defaultSort, sorting } from 'lib/constants';
5 |
6 | export const metadata = {
7 | title: 'Search',
8 | description: 'Search for products in the store.'
9 | };
10 |
11 | export default async function SearchPage({
12 | searchParams
13 | }: {
14 | searchParams?: { [key: string]: string | string[] | undefined };
15 | }) {
16 | const { sort, q: searchValue } = searchParams as { [key: string]: string };
17 | const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
18 | const products = await getProducts({ sortKey, reverse, query: searchValue });
19 | const resultsText = products.length > 1 ? 'results' : 'result';
20 |
21 | return (
22 | <>
23 | {searchValue ? (
24 |
25 | {products.length === 0
26 | ? 'There are no products that match '
27 | : `Showing ${products.length} ${resultsText} for `}
28 | "{searchValue}"
29 |
30 | ) : null}
31 | {products.length > 0 ? (
32 |
33 |
34 |
35 | ) : null}
36 | >
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import { getHomeCategories, getPages, getProducts } from 'lib/bagisto';
2 | import { validateEnvironmentVariables, getBaseUrl } from 'lib/utils';
3 | import { MetadataRoute } from 'next';
4 | export const dynamic = 'force-dynamic';
5 | type Route = {
6 | url: string;
7 | lastModified: string;
8 | };
9 |
10 | const baseUrl = getBaseUrl(process.env.NEXT_PUBLIC_VERCEL_URL);
11 |
12 | export default async function sitemap(): Promise {
13 | validateEnvironmentVariables();
14 |
15 | const routesMap = [''].map((route) => ({
16 | url: `${baseUrl}${route}`,
17 | lastModified: new Date().toISOString()
18 | }));
19 |
20 | const collectionsPromise = getHomeCategories().then((collections) =>
21 | collections.map((collection) => ({
22 | url: `${baseUrl}${collection.path}`,
23 | lastModified: collection.updatedAt
24 | }))
25 | );
26 |
27 | const productsPromise = getProducts({}).then((products) =>
28 | products.map((product) => ({
29 | url: `${baseUrl}/product/${product.urlKey}`,
30 | lastModified: product?.updatedAt || ''
31 | }))
32 | );
33 |
34 | const pagesPromise = getPages().then(
35 | (pages) =>
36 | pages?.data?.map((page) => ({
37 | url: `${baseUrl}/${page?.translations?.[0]?.urlKey || ''}`,
38 | lastModified: page?.updatedAt || ''
39 | }))
40 | );
41 |
42 | let fetchedRoutes: Route[] = [];
43 |
44 | try {
45 | fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat();
46 | } catch (error) {
47 | throw JSON.stringify(error, null, 2);
48 | }
49 |
50 | return [...routesMap, ...fetchedRoutes];
51 | }
52 |
--------------------------------------------------------------------------------
/auth.ts:
--------------------------------------------------------------------------------
1 | import { bagistoFetch } from 'lib/bagisto';
2 | import { CustomerLogin } from 'lib/bagisto/mutations/customer-login';
3 | import { TOKEN } from 'lib/constants';
4 | import { isObject } from 'lib/type-guards';
5 | import NextAuth, { NextAuthOptions } from 'next-auth';
6 | import CredentialsProvider from 'next-auth/providers/credentials';
7 | import { cookies } from 'next/headers';
8 | export const authOptions: NextAuthOptions = {
9 | providers: [
10 | CredentialsProvider({
11 | name: 'Credentials',
12 | credentials: {
13 | username: { label: 'username', type: 'username', placeholder: 'jsmith' },
14 | password: { label: 'Password', type: 'password' }
15 | },
16 | authorize: async (
17 | credentials: Record<'password' | 'username', string> | undefined
18 | ): Promise => {
19 | /* Getting Token from generateCustomerToken */
20 | const input = {
21 | email: credentials?.username,
22 | password: credentials?.password
23 | };
24 |
25 | try {
26 | const res = await bagistoFetch({
27 | query: CustomerLogin,
28 | variables: {
29 | input
30 | },
31 | cache: 'no-store'
32 | });
33 |
34 | console.log(res);
35 |
36 | if (
37 | res?.status === 200 &&
38 | isObject(res?.body?.data) &&
39 | isObject(res?.body?.data?.customerLogin)
40 | ) {
41 | const customerInfo = res?.body?.data?.customerLogin;
42 | cookies().set(TOKEN, customerInfo?.accessToken);
43 | return {
44 | firstname: customerInfo.customer.firstName,
45 | lastname: customerInfo.customer.lastName,
46 | name: customerInfo.customer.name,
47 | token: customerInfo.accessToken,
48 | email: customerInfo.customer.email,
49 | tokenLifeTime: customerInfo.expiresIn
50 | };
51 | } else {
52 | throw new Error('Something went wrong.');
53 | }
54 | } catch (error: any) {
55 | throw new Error((error?.error?.message as string) || 'Something went wrong!');
56 | }
57 | }
58 | })
59 | ],
60 | secret: process.env.NEXTAUTH_SECRET,
61 | callbacks: {
62 | jwt: async ({ token, user }) => {
63 | if (isObject(user) && user.token) {
64 | token.accessToken = user.token;
65 | token.role = 'customer';
66 | }
67 | return token;
68 | },
69 | async session({ session, token }) {
70 | return {
71 | ...session,
72 | user: {
73 | ...session.user,
74 | accessToken: token.accessToken as string,
75 | role: token.role
76 | },
77 | error: token.error
78 | };
79 | }
80 | },
81 | pages: {
82 | signIn: '/customer/login',
83 | error: '/login'
84 | }
85 | };
86 | export const handler = NextAuth(authOptions);
87 |
--------------------------------------------------------------------------------
/changelog.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## [2.1.0] - 2025-02-24
4 |
5 | ### Added
6 |
7 | - Added `BAGISTO_PROTOCOL` key.
8 | - Added the new the query field.
9 |
10 | ### Changed
11 |
12 | - Replaced NextUI with HeroUI.
13 | - Updated the latest changes of the Next Commerce theme.
14 |
15 | ### Fixed
16 |
17 | - Resolved login issue with OAuth.
18 | - Improved performance for large images.
19 | - Made compatible with the latest Bagisto APIs (version 2.2.3).
20 |
21 | ## [2.0.0] - 2024-06-06
22 |
23 | ### Added
24 |
25 | - Added the NextUI.
26 |
27 | ### Changed
28 |
29 | - Updated the query field.
30 | - Modified the `.env.example` file to reflect correct environment variable usage.
31 |
32 | ### Fixed
33 |
34 | - Made compatible with Bagisto APIs when using the Next Commerce theme.
35 |
36 | ## [1.0.0] - 2024-04-03
37 |
38 | ### Changed
39 |
40 | - Made compatible with Next Commerce v2.
41 |
--------------------------------------------------------------------------------
/components/carousel.tsx:
--------------------------------------------------------------------------------
1 | import { getCollectionProducts } from 'lib/bagisto';
2 | import Link from 'next/link';
3 | import { GridTileImage } from './grid/tile';
4 |
5 | export async function Carousel() {
6 | // Collections that start with `hidden-*` are hidden from the search page.
7 | const products = await getCollectionProducts({ collection: '' });
8 | if (!products?.length) return null;
9 |
10 | // Purposefully duplicating products to make the carousel loop and not run out of products on wide screens.
11 | const carouselProducts = [...products, ...products, ...products];
12 |
13 | return (
14 |
15 |
16 | {carouselProducts.map((product, i) => (
17 |
21 |
22 |
33 |
34 |
35 | ))}
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/components/cart/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 | import { addToCart, getCart, removeFromCart, updateCart } from 'lib/bagisto';
3 | import { SuperAttribute } from 'lib/bagisto/types';
4 | import { BAGISTO_SESSION, TAGS } from 'lib/constants';
5 | import { revalidateTag } from 'next/cache';
6 | import { cookies } from 'next/headers';
7 | import { redirect } from 'next/navigation';
8 | export async function addItem(
9 | prevState: any,
10 | input: {
11 | selectedVariantId: string | undefined;
12 | selectedConfigurableOption: number;
13 | superAttribute: SuperAttribute[];
14 | }
15 | ) {
16 | const cartId = cookies().get(BAGISTO_SESSION)?.value;
17 | if (cartId) {
18 | await getCart();
19 | } else {
20 | cookies().set(BAGISTO_SESSION, generateCookieValue(40), {
21 | httpOnly: true,
22 | secure: false
23 | });
24 | }
25 |
26 | if (!input.selectedVariantId) {
27 | return 'Missing product variant ID';
28 | }
29 |
30 | const selectedConfigurableOption = input.selectedConfigurableOption;
31 | const superAttribute = input.superAttribute;
32 | try {
33 | await addToCart({
34 | productId: Number(input?.selectedVariantId),
35 | quantity: 1,
36 | selectedConfigurableOption,
37 | superAttribute
38 | });
39 | revalidateTag(TAGS.cart);
40 | } catch (e) {
41 | return 'Error adding item to cart';
42 | }
43 | }
44 |
45 | function generateCookieValue(length: number) {
46 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
47 | let cookieValue = '';
48 | for (let i = 0; i < length; i++) {
49 | cookieValue += characters.charAt(Math.floor(Math.random() * characters.length));
50 | }
51 | return cookieValue;
52 | }
53 |
54 | export async function removeItem(prevState: any, lineId: number) {
55 | const cartId = cookies().get(BAGISTO_SESSION)?.value;
56 |
57 | if (!cartId) {
58 | return 'Missing cart ID';
59 | }
60 |
61 | try {
62 | await removeFromCart(Number(lineId));
63 | revalidateTag(TAGS.cart);
64 | } catch (e) {
65 | return 'Error removing item from cart';
66 | }
67 | }
68 |
69 | export async function updateItemQuantity(
70 | prevState: any,
71 | payload: {
72 | lineId: number;
73 | quantity: number;
74 | }
75 | ) {
76 | const cartId = cookies().get(BAGISTO_SESSION)?.value;
77 |
78 | if (!cartId) {
79 | return 'Missing cart ID';
80 | }
81 |
82 | const { lineId, quantity } = payload;
83 |
84 | try {
85 | if (quantity === 0) {
86 | await removeFromCart(Number(lineId));
87 | revalidateTag(TAGS.cart);
88 | return;
89 | }
90 |
91 | await updateCart([
92 | {
93 | cartItemId: lineId,
94 | quantity
95 | }
96 | ]);
97 | revalidateTag(TAGS.cart);
98 | } catch (e) {
99 | return 'Error updating item quantity';
100 | }
101 | }
102 |
103 | export async function redirectToCheckout(formData: FormData) {
104 | const url = formData.get('url') as string;
105 | redirect(url);
106 | }
107 |
--------------------------------------------------------------------------------
/components/cart/add-to-cart.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { PlusIcon } from '@heroicons/react/24/outline';
4 | import clsx from 'clsx';
5 | import { addItem } from 'components/cart/actions';
6 | import LoadingDots from 'components/loading-dots';
7 | import { ConfigurableProductData, ConfigurableProductIndexData } from 'lib/bagisto/types';
8 | import { useSearchParams } from 'next/navigation';
9 | import { useFormState, useFormStatus } from 'react-dom';
10 |
11 | function SubmitButton({
12 | availableForSale,
13 | selectedVariantId
14 | }: {
15 | availableForSale: boolean;
16 | selectedVariantId: boolean;
17 | }) {
18 | const { pending } = useFormStatus();
19 | const buttonClasses =
20 | 'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
21 | const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
22 |
23 | if (!availableForSale) {
24 | return (
25 |
26 | Out Of Stock
27 |
28 | );
29 | }
30 |
31 | if (!selectedVariantId) {
32 | return (
33 |
38 |
41 | Add To Cart
42 |
43 | );
44 | }
45 |
46 | return (
47 | ) => {
49 | if (pending) e.preventDefault();
50 | }}
51 | aria-label="Add to cart"
52 | aria-disabled={pending}
53 | className={clsx(buttonClasses, {
54 | 'hover:opacity-90': true,
55 | [disabledClasses]: pending
56 | })}
57 | >
58 |
61 | Add To Cart
62 |
63 | );
64 | }
65 |
66 | export function AddToCart({
67 | variants,
68 | availableForSale,
69 | index,
70 | productId
71 | }: {
72 | variants: ConfigurableProductData[];
73 | availableForSale: boolean;
74 | productId: string;
75 | index: ConfigurableProductIndexData[];
76 | }) {
77 | const [message, formAction] = useFormState(addItem, null);
78 | const searchParams = useSearchParams();
79 | // Function to convert URLSearchParams to object
80 | const searchParamsToObject = (searchParams: any) => {
81 | const paramsObject: any = {};
82 | for (const [key, value] of searchParams.entries()) {
83 | paramsObject[key] = value;
84 | }
85 | return paramsObject;
86 | };
87 |
88 | // Convert searchParams to object
89 | const searchParamsObject = searchParamsToObject(searchParams);
90 |
91 | // Function to find the object matching the search value
92 | const findMatchingObject = (searchParamsObject: any, index: ConfigurableProductIndexData[]) => {
93 | for (const data of index) {
94 | let match = true;
95 | const attributeOptionIds = [];
96 | for (const attributeOption of data.attributeOptionIds) {
97 | const attributeCode = attributeOption.attributeCode;
98 | const attributeOptionId = attributeOption.attributeOptionId;
99 | if (searchParamsObject[attributeCode] !== attributeOptionId) {
100 | match = false;
101 | break;
102 | }
103 | attributeOptionIds.push({
104 | attributeId: Number(attributeOption.attributeId),
105 | attributeOptionId: Number(attributeOption.attributeOptionId)
106 | });
107 | }
108 | if (match) {
109 | // Update the data with the new attributeOptionIds
110 | return { ...data, attributeOptionIds };
111 | }
112 | }
113 | return null;
114 | };
115 |
116 | // Call the function to find the matching object
117 | const matchingObject = findMatchingObject(searchParamsObject, index);
118 |
119 | const defaultVariantId = variants.length === 1 ? variants[0]?.id : productId;
120 | // This code checked configurable product is selected or not
121 | const buttonStatus = variants.length > 1 ? (matchingObject?.id ? true : false) : true;
122 | const variant = variants.find((variant: ConfigurableProductData) =>
123 | variant.options.every((option) => option.id === searchParams.get(variant.code.toLowerCase()))
124 | );
125 |
126 | const selectedVariantId = matchingObject?.id || defaultVariantId;
127 | const selectedConfigurableOption = Number(
128 | Object.keys(searchParamsObject).length
129 | ? matchingObject
130 | ? matchingObject?.id
131 | : ''
132 | : variant?.id || defaultVariantId
133 | );
134 | const superAttribute = matchingObject?.attributeOptionIds || [];
135 | const actionWithVariant = formAction.bind(null, {
136 | selectedVariantId,
137 | selectedConfigurableOption,
138 | superAttribute
139 | });
140 |
141 | return (
142 |
148 | );
149 | }
150 |
--------------------------------------------------------------------------------
/components/cart/close-cart.tsx:
--------------------------------------------------------------------------------
1 | import { XMarkIcon } from '@heroicons/react/24/outline';
2 | import clsx from 'clsx';
3 |
4 | export default function CloseCart({ className }: { className?: string }) {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/components/cart/delete-item-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { XMarkIcon } from '@heroicons/react/24/outline';
4 | import clsx from 'clsx';
5 | import { removeItem } from 'components/cart/actions';
6 | import LoadingDots from 'components/loading-dots';
7 | import type { CartItem } from 'lib/bagisto/types';
8 | import { useFormState, useFormStatus } from 'react-dom';
9 |
10 | function SubmitButton() {
11 | const { pending } = useFormStatus();
12 |
13 | return (
14 | ) => {
17 | if (pending) e.preventDefault();
18 | }}
19 | aria-label="Remove cart item"
20 | aria-disabled={pending}
21 | className={clsx(
22 | 'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200',
23 | {
24 | 'cursor-not-allowed px-0': pending
25 | }
26 | )}
27 | >
28 | {pending ? (
29 |
30 | ) : (
31 |
32 | )}
33 |
34 | );
35 | }
36 |
37 | export function DeleteItemButton({ item }: { item: CartItem }) {
38 | const [message, formAction] = useFormState(removeItem, null);
39 | const itemId = item.id;
40 | const actionWithVariant = formAction.bind(null, Number(itemId));
41 |
42 | return (
43 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/components/cart/edit-item-quantity-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
4 | import clsx from 'clsx';
5 | import { updateItemQuantity } from 'components/cart/actions';
6 | import LoadingDots from 'components/loading-dots';
7 | import type { CartItem } from 'lib/bagisto/types';
8 | import { useFormState, useFormStatus } from 'react-dom';
9 |
10 | function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
11 | const { pending } = useFormStatus();
12 |
13 | return (
14 | ) => {
17 | if (pending) e.preventDefault();
18 | }}
19 | aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
20 | aria-disabled={pending}
21 | className={clsx(
22 | 'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
23 | {
24 | 'cursor-not-allowed': pending,
25 | 'ml-auto': type === 'minus'
26 | }
27 | )}
28 | >
29 | {pending ? (
30 |
31 | ) : type === 'plus' ? (
32 |
33 | ) : (
34 |
35 | )}
36 |
37 | );
38 | }
39 |
40 | export function EditItemQuantityButton({ item, type }: { item: CartItem; type: 'plus' | 'minus' }) {
41 | const [message, formAction] = useFormState(updateItemQuantity, null);
42 | const payload = {
43 | lineId: Number(item.id),
44 | quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
45 | };
46 | const actionWithVariant = formAction.bind(null, payload);
47 |
48 | return (
49 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/components/cart/index.tsx:
--------------------------------------------------------------------------------
1 | import { getCart } from 'lib/bagisto';
2 | import { cookies } from 'next/headers';
3 | import CartModal from './modal';
4 | import { BAGISTO_SESSION } from 'lib/constants';
5 | export default async function Cart() {
6 | const cartId = cookies().get(BAGISTO_SESSION)?.value;
7 | let cart;
8 | if (cartId) {
9 | cart = await getCart();
10 | }
11 |
12 | return ;
13 | }
14 |
--------------------------------------------------------------------------------
/components/cart/open-cart.tsx:
--------------------------------------------------------------------------------
1 | import { ShoppingCartIcon } from '@heroicons/react/24/outline';
2 | import clsx from 'clsx';
3 |
4 | export default function OpenCart({
5 | className,
6 | quantity
7 | }: {
8 | className?: string;
9 | quantity?: number | string;
10 | }) {
11 | return (
12 |
13 |
16 |
17 | {quantity ? (
18 |
19 | {quantity}
20 |
21 | ) : null}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/components/checkout/action.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 | import {
3 | addCheckoutAddress,
4 | addPaymentMethod,
5 | addShippingMethod,
6 | createPlaceOrder
7 | } from 'lib/bagisto';
8 | import { ORDER_ID, TAGS } from 'lib/constants';
9 | import { isObject } from 'lib/type-guards';
10 | import { revalidateTag } from 'next/cache';
11 | import { cookies } from 'next/headers';
12 | import { redirect } from 'next/navigation';
13 | import { z } from 'zod';
14 |
15 | const schema = z.object({
16 | email: z
17 | .string({
18 | required_error: 'Email is required'
19 | })
20 | .email('This is not a valid email.'),
21 | country: z
22 | .string({ required_error: 'Country is required' })
23 | .min(1, { message: 'Country is required' }),
24 | companyName: z.string({ required_error: 'Country is required' }),
25 | firstName: z
26 | .string({ required_error: 'First Name is required' })
27 | .min(1, { message: 'Please Enter First name' }),
28 | lastName: z
29 | .string({ required_error: 'Last Name is required' })
30 | .min(1, { message: 'Please Enter Last name' }),
31 | address: z
32 | .string({ required_error: 'Address is required' })
33 | .min(1, { message: 'Address is required!' }),
34 | city: z.string({ required_error: 'City is required' }).min(1, { message: 'City is required' }),
35 | state: z.string({ required_error: 'State is required' }).min(1, { message: 'State is required' }),
36 | postcode: z
37 | .string({ required_error: 'Zip code is required' })
38 | .min(1, { message: 'Zip code is required' }),
39 | phone: z
40 | .string({ required_error: 'Phone Number is required' })
41 | .min(1, { message: 'Phone Number is required' })
42 | });
43 | export async function createCheckoutAddress(prevState: any, formData: FormData) {
44 | const adressData = {
45 | email: formData.get('email'),
46 | firstName: formData.get('firstName'),
47 | lastName: formData.get('lastName'),
48 | companyName: formData.get('companyName'),
49 | address: formData.get('address'),
50 | country: formData.get('country'),
51 | state: formData.get('state'),
52 | city: formData.get('city'),
53 | postcode: formData.get('postcode'),
54 | phone: formData.get('phone')
55 | };
56 | console.log(adressData);
57 | const validatedFields = schema.safeParse({
58 | ...adressData
59 | });
60 |
61 | if (!validatedFields.success) {
62 | return {
63 | errors: validatedFields.error.flatten().fieldErrors
64 | };
65 | }
66 | const moreInfo = {
67 | useForShipping: false,
68 | saveAddress: false,
69 | defaultAddress: false
70 | };
71 | const checkoutInfo = {
72 | billing: {
73 | ...adressData,
74 | ...moreInfo
75 | },
76 | shipping: {
77 | ...adressData,
78 | ...moreInfo
79 | }
80 | };
81 |
82 | const result = await addCheckoutAddress(checkoutInfo);
83 |
84 | if (isObject(result)) {
85 | revalidateTag(TAGS.cart);
86 | redirect('/checkout/shipping');
87 | }
88 | }
89 |
90 | const shippingSchema = z.object({
91 | shippingMethod: z
92 | .string({ required_error: 'City is required' })
93 | .min(1, { message: 'City is required' })
94 | });
95 |
96 | export async function createShippingMethod(prevState: any, formData: FormData) {
97 | const method = { shippingMethod: formData.get('shippingMethod') };
98 | const validatedFields = shippingSchema.safeParse({
99 | ...method
100 | });
101 |
102 | if (!validatedFields.success) {
103 | return {
104 | errors: validatedFields.error.flatten().fieldErrors
105 | };
106 | }
107 |
108 | const result = await addShippingMethod({
109 | method: method.shippingMethod as string
110 | });
111 | if (isObject(result)) {
112 | revalidateTag(TAGS.cart);
113 | redirect('/checkout/payment');
114 | }
115 | }
116 |
117 | const methodSchema = z.object({
118 | method: z
119 | .string({ required_error: 'Method is required' })
120 | .min(1, { message: 'Method is required' })
121 | });
122 |
123 | export async function createPaymentMethod(prevState: any, formData: FormData) {
124 | const validatedFields = methodSchema.safeParse({
125 | method: formData.get('method')
126 | });
127 | if (!validatedFields.success) {
128 | return {
129 | errors: validatedFields.error.flatten().fieldErrors
130 | };
131 | }
132 | const result = await addPaymentMethod({ method: formData.get('method') as string });
133 |
134 | if (isObject(result)) {
135 | revalidateTag(TAGS.cart);
136 | redirect('/checkout/place-order');
137 | }
138 | }
139 |
140 | export async function placeOrder() {
141 | const result = await createPlaceOrder();
142 | if (isObject(result?.order)) {
143 | const cookieStore = await cookies();
144 | const orderId = result?.order?.id;
145 | cookieStore.set({
146 | name: ORDER_ID,
147 | value: orderId,
148 | httpOnly: true,
149 | path: '/'
150 | });
151 | revalidateTag(TAGS.cart);
152 | redirect(`/cart?order=${orderId}`);
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/components/checkout/cart/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { addToCart, getCart, removeFromCart, updateCart } from 'lib/bagisto';
4 | import { SuperAttribute } from 'lib/bagisto/types';
5 | import { TAGS } from 'lib/constants';
6 | import { revalidateTag } from 'next/cache';
7 | import { cookies } from 'next/headers';
8 | import { BAGISTO_SESSION } from 'lib/constants';
9 | export async function addItem(
10 | prevState: any,
11 | input: {
12 | selectedVariantId: string | undefined;
13 | selectedConfigurableOption: number;
14 | superAttribute: SuperAttribute[];
15 | }
16 | ) {
17 | const cartId = cookies().get(BAGISTO_SESSION)?.value;
18 | if (cartId) {
19 | await getCart();
20 | } else {
21 | cookies().set(BAGISTO_SESSION, generateCookieValue(40), {
22 | httpOnly: true,
23 | secure: false
24 | });
25 | }
26 |
27 | if (!input.selectedVariantId) {
28 | return 'Missing product variant ID';
29 | }
30 |
31 | const selectedConfigurableOption = input.selectedConfigurableOption;
32 | const superAttribute = input.superAttribute;
33 |
34 | try {
35 | await addToCart({
36 | productId: Number(input?.selectedVariantId),
37 | quantity: 1,
38 | selectedConfigurableOption,
39 | superAttribute
40 | });
41 | revalidateTag(TAGS.cart);
42 | } catch (e) {
43 | return 'Error adding item to cart';
44 | }
45 | }
46 |
47 | function generateCookieValue(length: number) {
48 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
49 | let cookieValue = '';
50 | for (let i = 0; i < length; i++) {
51 | cookieValue += characters.charAt(Math.floor(Math.random() * characters.length));
52 | }
53 | return cookieValue;
54 | }
55 |
56 | export async function removeItem(prevState: any, lineId: number) {
57 | const cartId = cookies().get(BAGISTO_SESSION)?.value;
58 |
59 | if (!cartId) {
60 | return 'Missing cart ID';
61 | }
62 |
63 | try {
64 | await removeFromCart(Number(lineId));
65 | revalidateTag(TAGS.cart);
66 | } catch (e) {
67 | return 'Error removing item from cart';
68 | }
69 | }
70 |
71 | export async function updateItemQuantity(
72 | prevState: any,
73 | payload: {
74 | lineId: number;
75 | quantity: number;
76 | }
77 | ) {
78 | const cartId = cookies().get(BAGISTO_SESSION)?.value;
79 |
80 | if (!cartId) {
81 | return 'Missing cart ID';
82 | }
83 |
84 | const { lineId, quantity } = payload;
85 |
86 | try {
87 | if (quantity === 0) {
88 | await removeFromCart(Number(lineId));
89 | revalidateTag(TAGS.cart);
90 | return;
91 | }
92 |
93 | await updateCart([
94 | {
95 | cartItemId: lineId,
96 | quantity
97 | }
98 | ]);
99 | revalidateTag(TAGS.cart);
100 | } catch (e) {
101 | return 'Error updating item quantity';
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/components/checkout/cart/cart.tsx:
--------------------------------------------------------------------------------
1 | import LogoSquare from 'components/logo-square';
2 | import Price from 'components/price';
3 | import { getCart } from 'lib/bagisto';
4 | import { DEFAULT_OPTION, BAGISTO_SESSION } from 'lib/constants';
5 | import { isObject } from 'lib/type-guards';
6 | import { createUrl } from 'lib/utils';
7 | import { cookies } from 'next/headers';
8 | import Image from 'next/image';
9 | import Link from 'next/link';
10 | import CartItemAccordion from './cart-item-accordian';
11 | const { SITE_NAME } = process.env;
12 | type MerchandiseSearchParams = {
13 | [key: string]: string;
14 | };
15 | export default async function Cart() {
16 | const cartId = cookies().get(BAGISTO_SESSION)?.value;
17 |
18 | let cart;
19 | if (cartId) {
20 | cart = await getCart();
21 | }
22 | return (
23 | <>
24 |
25 |
26 |
27 |
28 | {SITE_NAME}
29 |
30 |
31 |
32 |
33 |
34 |
35 | {cart?.items?.map((item, i) => {
36 | const merchandiseSearchParams = {} as MerchandiseSearchParams;
37 | const merchandiseUrl = createUrl(
38 | `/product/${item?.product.sku}`,
39 | new URLSearchParams(merchandiseSearchParams)
40 | );
41 | return (
42 |
43 |
44 |
45 |
46 | {item.quantity}
47 |
48 |
49 |
50 |
51 |
58 |
59 |
60 |
{item.product.name}
61 | {item.name !== DEFAULT_OPTION ? (
62 |
63 | {item.name}
64 |
65 | ) : null}
66 |
67 |
68 |
75 |
76 |
77 | );
78 | })}
79 |
80 |
81 |
89 |
90 |
Shipping
91 | {isObject(cart?.selectedShippingRate) ? (
92 |
97 | ) : (
98 |
Calculated at next step
99 | )}
100 |
101 |
109 |
110 |
111 | >
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/components/checkout/cart/empty-cart.tsx:
--------------------------------------------------------------------------------
1 | import CheckSign from 'components/icons/check-sign';
2 | import dynamic from 'next/dynamic';
3 | import { EventButton } from './event-button';
4 | const OrderDetail = dynamic(() => import('./order-detail'), {
5 | loading: () => (
6 |
10 | ),
11 | ssr: false
12 | });
13 | const EmptyCartPage = () => {
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | };
24 | export default EmptyCartPage;
25 |
--------------------------------------------------------------------------------
/components/checkout/cart/event-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import clsx from 'clsx';
3 | import { useRouter } from 'next/navigation';
4 | function SubmitButton({ buttonName, redirectNav }: { buttonName: string; redirectNav: string }) {
5 | const router = useRouter();
6 | return (
7 | router.push(redirectNav)}
9 | aria-label={buttonName}
10 | type="button"
11 | className={clsx(
12 | ' my-3 w-auto items-center justify-center rounded-md border-white bg-blue-600 px-12 py-4 text-sm font-bold tracking-wide text-white',
13 | {
14 | 'hover:opacity-90': true
15 | }
16 | )}
17 | >
18 | {buttonName}
19 |
20 | );
21 | }
22 |
23 | export function EventButton({ buttonName, redirect }: { buttonName: string; redirect: string }) {
24 | return (
25 | <>
26 |
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/components/checkout/cart/input.tsx:
--------------------------------------------------------------------------------
1 | import { ExclamationCircleIcon } from '@heroicons/react/24/outline';
2 | import { Input } from '@heroui/input';
3 | import clsx from 'clsx';
4 | import { isArray } from 'lib/type-guards';
5 | export default function InputText({
6 | className,
7 | label,
8 | name,
9 | errorMsg, // Add errorMsg prop to handle error messages
10 | defaultValue,
11 | typeName,
12 | placeholder
13 | }: {
14 | className: string;
15 | label: string;
16 | name: string;
17 | errorMsg?: [] | any; // Make errorMsg prop optional
18 | defaultValue?: string;
19 | typeName?: string;
20 | placeholder?: string;
21 | }) {
22 | return (
23 |
24 |
43 | {isArray(errorMsg) && (
44 |
45 | {errorMsg?.map((msg: string | any, index: any) => (
46 |
47 |
48 |
{msg}
49 |
50 | ))}
51 |
52 | )}
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/components/checkout/cart/order-detail.tsx:
--------------------------------------------------------------------------------
1 | import { ORDER_ID } from 'lib/constants';
2 | import { cookies } from 'next/headers';
3 |
4 | export default async function OrderDetail() {
5 | const cookieStore = await cookies();
6 | const getOrder = cookieStore.get(ORDER_ID);
7 |
8 | return (
9 |
10 |
Your order number: #{getOrder?.value}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/components/checkout/cart/proceed-to-checkout.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import clsx from 'clsx';
3 | import LoadingDots from 'components/loading-dots';
4 | import { useFormStatus } from 'react-dom';
5 | function SubmitButton({
6 | availableForSale,
7 | buttonName
8 | }: {
9 | availableForSale: boolean;
10 | buttonName: string;
11 | }) {
12 | const { pending } = useFormStatus();
13 | const buttonClasses =
14 | 'relative w-full text-sm font-bold border-white items-center justify-center rounded-md bg-blue-600 py-4 px-4 sm:px-4 tracking-wide text-white';
15 | const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
16 |
17 | if (!availableForSale) {
18 | return (
19 |
20 | Processing...
21 |
22 | );
23 | }
24 |
25 | return (
26 | ) => {
28 | if (pending) e.preventDefault();
29 | }}
30 | aria-label="Proceed to checkout"
31 | aria-disabled={pending}
32 | className={clsx(buttonClasses, {
33 | 'hover:opacity-90': true,
34 | disabledClasses: pending
35 | })}
36 | >
37 |
38 | {pending && }
39 |
40 | {buttonName}
41 |
42 | );
43 | }
44 |
45 | export function ProceedToCheckout({ buttonName }: { buttonName: string }) {
46 | const { pending } = useFormStatus();
47 | return ;
48 | }
49 |
--------------------------------------------------------------------------------
/components/checkout/information/checkout-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { Checkbox } from '@heroui/checkbox';
3 | import { createCheckoutAddress } from 'components/checkout/action';
4 | import RegionDropDown from 'components/checkout/region-drop-down';
5 | import { CountryArrayDataType, ShippingAddressDataType } from 'lib/bagisto/types';
6 | import { useFormState } from 'react-dom';
7 | import InputText from '../cart/input';
8 | import { ProceedToCheckout } from '../cart/proceed-to-checkout';
9 | import SelectBox from '../select-box';
10 | const GuestCheckOutForm = ({
11 | countries,
12 | shippingAddress
13 | }: {
14 | countries: CountryArrayDataType[];
15 | shippingAddress?: ShippingAddressDataType;
16 | }) => {
17 | const initialState = {
18 | ...(shippingAddress || {})
19 | };
20 | const [state, formAction] = useFormState(createCheckoutAddress, initialState);
21 |
22 | return (
23 |
117 | );
118 | };
119 |
120 | export default GuestCheckOutForm;
121 |
--------------------------------------------------------------------------------
/components/checkout/loading.tsx:
--------------------------------------------------------------------------------
1 | const Loading = () => {
2 | return (
3 |
4 |
5 |
6 |
7 |
8 |
Loading...
9 |
10 |
11 | {Array(3)
12 | .fill(0)
13 | .map((_, index) => (
14 |
15 | ))}
16 |
17 |
18 | );
19 | };
20 |
21 | export default Loading;
22 |
--------------------------------------------------------------------------------
/components/checkout/next-breadcrumb.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs';
3 | import { isObject } from 'lib/type-guards';
4 | import Link from 'next/link';
5 | import { usePathname } from 'next/navigation';
6 | import { useEffect, useState } from 'react';
7 | type BreadCrumbType = {
8 | label: 'Information' | 'Shipping' | 'Payment' | 'Place Order';
9 | href: string;
10 | };
11 | type BreadcrumbArray = BreadCrumbType[];
12 | const breadCrumbs: BreadcrumbArray = new Array(
13 | {
14 | label: 'Information',
15 | href: '/checkout/information'
16 | },
17 | {
18 | label: 'Shipping',
19 | href: '/checkout/shipping'
20 | },
21 | {
22 | label: 'Payment',
23 | href: '/checkout/payment'
24 | },
25 | {
26 | label: 'Place Order',
27 | href: '/checkout/place-order'
28 | }
29 | );
30 | const NextBreadcrumb = () => {
31 | const paths = usePathname();
32 | const [currentPage, setCurrentPage] = useState('Information');
33 | const currentPath = breadCrumbs?.find((item) => item.href === paths);
34 | useEffect(() => {
35 | if (isObject(currentPath)) {
36 | setCurrentPage(currentPath.label);
37 | }
38 | }, [paths, currentPath]);
39 | let informationPassed = false;
40 | const BreadCrumbsArray: BreadcrumbArray = breadCrumbs.map((crumb) => {
41 | if (crumb.href === paths) {
42 | informationPassed = true;
43 | return { ...crumb, href: paths };
44 | } else if (informationPassed) {
45 | return { ...crumb, href: '#' };
46 | } else {
47 | return { ...crumb };
48 | }
49 | });
50 |
51 | return (
52 | setCurrentPage(value)}
54 | color="success"
55 | classNames={{
56 | list: 'gap-0'
57 | }}
58 | size="md"
59 | itemClasses={{
60 | item: [
61 | 'px-1 py-0.5 text-gray-700',
62 | 'data-[current=true]:border-foreground data-[disabled=true]:text-blue-500 data-[current=true]:text-black dark:data-[current=true]:text-white data-[current=true]:font-semibold'
63 | ],
64 | separator: 'block text-gray-500 dark:text-white'
65 | }}
66 | >
67 | {BreadCrumbsArray.map((item) => (
68 |
73 | {item.label}
74 |
75 | ))}
76 |
77 | );
78 | };
79 |
80 | export default NextBreadcrumb;
81 |
--------------------------------------------------------------------------------
/components/checkout/payment/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Radio, RadioGroup, cn } from '@heroui/react';
4 | import { ProceedToCheckout } from 'components/checkout/cart/proceed-to-checkout';
5 | import RightArrowIcon from 'components/icons/right-arrow';
6 | import WalletLogo from 'components/icons/wallet-logo';
7 | import { ShippingAddressDataType, selectedPaymentMethodType } from 'lib/bagisto/types';
8 | import { isArray, isObject } from 'lib/type-guards';
9 | import Link from 'next/link';
10 |
11 | import { useFormState } from 'react-dom';
12 | import { createPaymentMethod } from '../action';
13 |
14 | type CustomRadioProps = {
15 | children: React.ReactNode;
16 | description?: string;
17 | value: string;
18 | } & typeof Radio.defaultProps;
19 |
20 | export default function PaymentPage({
21 | selectedPayment,
22 | selectedShipping,
23 | shippingAddress,
24 | methods
25 | }: {
26 | selectedPayment?: selectedPaymentMethodType;
27 | selectedShipping?: any;
28 | shippingAddress?: ShippingAddressDataType;
29 | methods: any;
30 | }) {
31 | const initialState = {
32 | method: selectedPayment?.method || ''
33 | };
34 | const [state, formAction] = useFormState(createPaymentMethod, initialState);
35 |
36 | return (
37 |
38 |
39 | {isObject(shippingAddress) && (
40 |
41 |
42 |
43 | Contact
44 |
48 | {shippingAddress?.email}
49 |
50 |
51 |
55 | Change
56 |
57 |
58 |
59 |
60 | Ship to
61 |
65 | {shippingAddress?.firstName}, {shippingAddress?.lastName},{' '}
66 | {shippingAddress?.address}, {shippingAddress?.city}, {shippingAddress?.state},{' '}
67 | {shippingAddress?.postcode}, {shippingAddress?.country}
68 |
69 |
70 |
74 | Change
75 |
76 |
77 |
78 |
79 | Method
80 |
84 | {selectedShipping?.methodTitle}
85 |
86 |
87 |
91 | Change
92 |
93 |
94 |
95 |
96 |
97 | )}
98 |
99 |
100 |
Payment method
101 |
130 |
131 | {isArray(!methods) && (
132 |
133 |
Payment
134 |
All transactions are secure and encrypted.
135 |
136 |
137 |
138 | This store can’t accept payments right now.
139 |
140 |
141 |
142 | )}
143 |
144 | );
145 | }
146 |
147 | const CustomRadio = (props: CustomRadioProps) => {
148 | const { children, ...otherProps } = props;
149 | return (
150 |
160 | {children}
161 |
162 | );
163 | };
164 |
--------------------------------------------------------------------------------
/components/checkout/place-holder.tsx:
--------------------------------------------------------------------------------
1 | const FormPlaceHolder = () => {
2 | return (
3 |
4 |
5 |
6 |
7 |
Loading...
8 |
9 |
10 |
11 | {Array(5)
12 | .fill(0)
13 | .map((_, index) => (
14 |
15 | ))}
16 |
17 |
18 | );
19 | };
20 | export default FormPlaceHolder;
21 |
--------------------------------------------------------------------------------
/components/checkout/place-order/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import RightArrowIcon from 'components/icons/right-arrow';
3 | import { ShippingAddressDataType, selectedPaymentMethodType } from 'lib/bagisto/types';
4 | import { isObject } from 'lib/type-guards';
5 | import Link from 'next/link';
6 | import { useFormState } from 'react-dom';
7 | import { placeOrder } from '../action';
8 | import { ProceedToCheckout } from '../cart/proceed-to-checkout';
9 | export default function PlaceOrderPage({
10 | selectedPayment,
11 | shippingAddress,
12 | selectedShipping
13 | }: {
14 | selectedPayment?: selectedPaymentMethodType;
15 | shippingAddress?: ShippingAddressDataType;
16 | selectedShipping: any;
17 | }) {
18 | /* eslint-disable no-unused-vars */
19 | const [state, formAction] = useFormState(placeOrder, null);
20 |
21 | return (
22 |
23 |
24 | {isObject(shippingAddress) && (
25 |
26 |
27 |
28 | Contact
29 |
33 | {shippingAddress?.email}
34 |
35 |
36 |
40 | Change
41 |
42 |
43 |
44 |
45 | Ship to
46 |
50 | {shippingAddress?.firstName}, {shippingAddress?.lastName},{' '}
51 | {shippingAddress?.address}, {shippingAddress?.city}, {shippingAddress?.state},{' '}
52 | {shippingAddress?.postcode}, {shippingAddress?.country}
53 |
54 |
55 |
59 | Change
60 |
61 |
62 |
63 |
64 | Method
65 |
69 | {selectedShipping?.methodTitle}
70 |
71 |
72 |
76 | Change
77 |
78 |
79 |
80 |
81 | Payment
82 |
86 | {selectedPayment?.methodTitle}
87 |
88 |
89 |
93 | Change
94 |
95 |
96 |
97 |
98 |
99 | )}
100 |
101 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/components/checkout/region-drop-down.tsx:
--------------------------------------------------------------------------------
1 | import { Select, SelectItem } from '@heroui/select';
2 | import { useGlobalContext } from 'app/context/store';
3 | import clsx from 'clsx';
4 | import { CountryArrayDataType, StateArrayDataType } from 'lib/bagisto/types';
5 | import { isArray } from 'lib/type-guards';
6 | import InputText from './cart/input';
7 | export default function SelectBox({
8 | className,
9 | countries,
10 | label,
11 | errorMsg,
12 | defaultValue
13 | }: {
14 | className: string;
15 | label: string;
16 | countries: any;
17 | errorMsg?: string;
18 | defaultValue?: string;
19 | }) {
20 | const { countryCode } = useGlobalContext();
21 | const stateArray = countries.find(
22 | (country: CountryArrayDataType) => country.code === countryCode
23 | );
24 | const countryStates = stateArray?.states;
25 | return isArray(countryStates) ? (
26 |
27 |
48 | {(States: StateArrayDataType) => (
49 | {States.defaultName}
50 | )}
51 |
52 |
53 | ) : (
54 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/components/checkout/select-box.tsx:
--------------------------------------------------------------------------------
1 | import { Select, SelectItem } from '@heroui/select';
2 | import { useGlobalContext } from 'app/context/store';
3 | import clsx from 'clsx';
4 | import { CountryArrayDataType } from 'lib/bagisto/types';
5 | export default function SelectBox({
6 | className,
7 | countries,
8 | label,
9 | errorMsg,
10 | defaultValue,
11 | nameAttr
12 | }: {
13 | className: string;
14 | label: string;
15 | countries: any;
16 | errorMsg?: string;
17 | defaultValue?: string;
18 | nameAttr?: string;
19 | }) {
20 | const { setCountryCode } = useGlobalContext();
21 | const getKeyValue = (countryCode: string) => {
22 | setCountryCode(countryCode);
23 | };
24 |
25 | return (
26 |
27 | getKeyValue(e.target.value)}
42 | classNames={{
43 | base: 'text-black dark:text-gray-500',
44 | label: 'text-gray-500',
45 | mainWrapper: 'border-none outline-none',
46 | trigger: 'border border-[0.5px] rounded-md dark:border-gray-700',
47 | popoverContent: 'bg-white dark:bg-black'
48 | }}
49 | >
50 | {(countries: CountryArrayDataType) => (
51 | {countries.name}
52 | )}
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/components/checkout/shipping/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { Radio, RadioGroup, cn } from '@heroui/react';
3 | import RightArrowIcon from 'components/icons/right-arrow';
4 | import { ShippingAddressDataType } from 'lib/bagisto/types';
5 | import { isArray, isObject } from 'lib/type-guards';
6 | import Link from 'next/link';
7 | import { useFormState } from 'react-dom';
8 | import { createShippingMethod } from '../action';
9 | import { ProceedToCheckout } from '../cart/proceed-to-checkout';
10 |
11 | type CustomRadioProps = {
12 | children: React.ReactNode;
13 | description?: string;
14 | value: string;
15 | } & typeof Radio.defaultProps;
16 |
17 | export default function ShippingMethod({
18 | shippingAddress,
19 | shippingMethod
20 | }: {
21 | shippingAddress?: ShippingAddressDataType;
22 | shippingMethod: any;
23 | }) {
24 | const getCartShippingMethod = shippingMethod?.cart?.shippingMethod;
25 | const initialState = {
26 | shippingMethods: getCartShippingMethod || ''
27 | };
28 |
29 | const [state, formAction] = useFormState(createShippingMethod, initialState);
30 | const getShippingMethods = shippingMethod?.shippingMethods || [];
31 |
32 | return (
33 |
34 | {isObject(shippingAddress) && (
35 |
36 |
37 |
38 |
39 | Contact
40 |
44 | {shippingAddress?.email}
45 |
46 |
47 |
51 | Change
52 |
53 |
54 |
55 |
56 | Ship to
57 |
61 | {shippingAddress?.firstName}, {shippingAddress?.lastName},{' '}
62 | {shippingAddress?.address}, {shippingAddress?.city}, {shippingAddress?.state},{' '}
63 | {shippingAddress?.postcode}, {shippingAddress?.country}
64 |
65 |
66 |
70 | Change
71 |
72 |
73 |
74 |
75 |
76 |
77 | )}
78 |
112 |
113 | );
114 | }
115 |
116 | const CustomRadio = (props: CustomRadioProps) => {
117 | const { children, ...otherProps } = props;
118 |
119 | return (
120 |
130 | {children}
131 |
132 | );
133 | };
134 |
--------------------------------------------------------------------------------
/components/customer/index.tsx:
--------------------------------------------------------------------------------
1 | import CredentialModal from './modal';
2 |
3 | export default function UserAccount() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/components/customer/lib/action.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 | import { createUserToLogin, recoverUserLogin } from 'lib/bagisto';
3 | import { isObject } from 'lib/type-guards';
4 | import { cookies } from 'next/headers';
5 | import { redirect } from 'next/navigation';
6 | import { z } from 'zod';
7 |
8 | /**
9 | * Define schema and method for create form validation
10 | * @param prevState
11 | * @param formData
12 | * @returns
13 | */
14 |
15 | const schema = z
16 | .object({
17 | email: z.string().email({ message: 'Please enter a valid email.' }).trim(),
18 | firstName: z.string().min(2, { message: 'Name must be at least 2 characters long.' }).trim(),
19 | password: z
20 | .string()
21 | .min(8, { message: 'Be at least 8 characters long' })
22 | .regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
23 | .regex(/[0-9]/, { message: 'Contain at least one number.' })
24 | .regex(/[^a-zA-Z0-9]/, {
25 | message: 'Contain at least one special character.'
26 | })
27 | .trim(),
28 | passwordConfirmation: z
29 | .string()
30 | .min(8, { message: 'Be at least 8 characters long' })
31 | .regex(/[A-Z]/, { message: 'Contain at least one letter.' })
32 | .regex(/[0-9]/, { message: 'Contain at least one number.' })
33 | .regex(/[!@#$%^&*>.<]/, {
34 | message: 'Contain at least one special character.'
35 | })
36 | .trim()
37 | })
38 | .refine((data) => data.password === data.passwordConfirmation, {
39 | message: "Password and confirm password don't match",
40 | path: ['confirm']
41 | });
42 |
43 | export async function createUser(prevState: any, formData: FormData) {
44 | // Ensure formData is defined
45 | const createUserValues = {
46 | firstName: formData.get('firstName'),
47 | lastName: formData.get('lastName'),
48 | email: formData.get('email'),
49 | password: formData.get('password'),
50 | passwordConfirmation: formData.get('passwordConfirmation')
51 | };
52 |
53 | const validatedFields = schema.safeParse(createUserValues);
54 |
55 | if (!validatedFields.success) {
56 | return {
57 | errors: validatedFields.error.flatten().fieldErrors
58 | };
59 | }
60 |
61 | const result = await createUserToLogin(createUserValues);
62 | if (isObject(result?.error)) {
63 | return {
64 | errors: { apiError: result?.error?.message }
65 | };
66 | } else {
67 | redirect('/customer/login');
68 | }
69 | }
70 |
71 | /**
72 | * Define schema and method for forget Password validation
73 | * @param prevState
74 | * @param formData
75 | * @returns
76 | */
77 | const forgetSchema = z.object({
78 | email: z.string().email({ message: 'Please enter a valid email.' }).trim()
79 | });
80 | export async function recoverPassword(prevState: any, formData: FormData) {
81 | const data = {
82 | email: formData.get('email')
83 | };
84 | const validatedFields = forgetSchema.safeParse(data);
85 |
86 | if (!validatedFields.success) {
87 | return {
88 | errors: validatedFields.error.flatten().fieldErrors
89 | };
90 | }
91 |
92 | const result = await recoverUserLogin(data);
93 |
94 | if (isObject(result?.error)) {
95 | return {
96 | errors: {
97 | apiRes: {
98 | status: false,
99 | msg: result?.error?.message
100 | }
101 | }
102 | };
103 | }
104 | return {
105 | errors: {
106 | apiRes: {
107 | status: true,
108 | msg: result?.body?.data?.forgotPassword?.success
109 | }
110 | }
111 | };
112 | }
113 |
114 | export async function userLogoOut() {
115 | try {
116 | cookies().delete('token');
117 | return {
118 | error: false,
119 | success: true
120 | };
121 | } catch (e) {
122 | return {
123 | error: true,
124 | success: false
125 | };
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/components/customer/login/forget-password.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { CheckCircleIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline';
3 | import InputText from 'components/checkout/cart/input';
4 | import { isObject } from 'lib/type-guards';
5 | import Link from 'next/link';
6 | import { useFormState } from 'react-dom';
7 | import { recoverPassword } from '../lib/action';
8 | import { LoadingButton } from './loading-button';
9 | const forgetDefaultValue = {
10 | email: ''
11 | };
12 | export function ForgetPasswordForm() {
13 | const [errorStatus, formAction] = useFormState(recoverPassword, forgetDefaultValue);
14 | return (
15 |
16 |
17 | {isObject(errorStatus?.errors?.apiRes) && (
18 |
23 | {errorStatus?.errors?.apiRes?.status ? (
24 |
25 | ) : (
26 |
27 | )}
28 | {errorStatus?.errors?.apiRes?.msg?.split(':')?.[1]}
29 |
30 | )}
31 |
32 |
Recover Password
33 |
34 | If you forgot your password, recover it by entering your email address.
35 |
36 |
37 |
53 |
54 |
55 |
56 |
57 | Back to sign In ?{' '}
58 |
59 | {' '}
60 | Sign In
61 |
62 |
63 |
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/components/customer/login/loading-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import LoadingDots from 'components/loading-dots';
5 | import { useFormStatus } from 'react-dom';
6 |
7 | export function LoadingButton({ buttonName }: { buttonName: string }) {
8 | const { pending } = useFormStatus();
9 | const buttonClasses =
10 | 'relative flex w-full items-center justify-center rounded-md bg-blue-600 p-3 tracking-wide text-white';
11 |
12 | return (
13 | ) => {
15 | if (pending) e.preventDefault();
16 | }}
17 | aria-label="Add to cart"
18 | aria-disabled={pending}
19 | className={clsx(buttonClasses, {
20 | 'hover:opacity-90': true,
21 | disabledClasses: pending
22 | })}
23 | >
24 |
25 | {pending ? (
26 |
30 | ) : (
31 |
{buttonName}
32 | )}
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/components/customer/login/login-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { ExclamationCircleIcon } from '@heroicons/react/24/outline';
3 | import InputText from 'components/checkout/cart/input';
4 | import { signIn } from 'next-auth/react';
5 | import Link from 'next/link';
6 | import { useRouter } from 'next/navigation';
7 | import { useFormState } from 'react-dom';
8 | import { z } from 'zod';
9 | import { LoadingButton } from './loading-button';
10 |
11 | const loginDefaultValue = {
12 | username: '',
13 | password: ''
14 | };
15 | const loginSchema = z.object({
16 | username: z.string().email({ message: 'Please enter a valid email.' }).trim(),
17 | password: z
18 | .string()
19 | .min(8, { message: 'Be at least 8 characters long' })
20 | .regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
21 | .regex(/[0-9]/, { message: 'Contain at least one number.' })
22 | .regex(/[^a-zA-Z0-9]/, {
23 | message: 'Contain at least one special character.'
24 | })
25 | .trim()
26 | });
27 |
28 | export function LoginForm() {
29 | const router = useRouter();
30 | async function authenticate(prevState: any, formData: FormData) {
31 | try {
32 | const data = {
33 | username: formData.get('username'),
34 | password: formData.get('password')
35 | };
36 | const validatedFields = loginSchema.safeParse(data);
37 | if (!validatedFields.success) {
38 | return {
39 | errors: validatedFields.error.flatten().fieldErrors
40 | };
41 | }
42 |
43 | return await signIn('credentials', {
44 | redirect: false,
45 | ...data,
46 | callbackUrl: '/'
47 | })
48 | .then((result) => {
49 | if (result?.ok) {
50 | router.push('/');
51 | }
52 | return {
53 | errors: {
54 | apiError: result?.error
55 | }
56 | };
57 | })
58 | .catch((errr) => {
59 | console.log(errr);
60 | });
61 | } catch (error) {
62 | console.error('Something went wrong :', error);
63 | }
64 | }
65 | const [status, dispatch] = useFormState(authenticate, loginDefaultValue);
66 |
67 | return (
68 |
69 |
70 | {status?.errors?.apiError && (
71 |
72 | {status?.errors?.apiError}
73 |
74 | )}
75 |
76 |
Sign in to your account
77 |
78 | If you have an account, sign in with your email address.
79 |
80 |
81 |
118 |
119 |
120 |
121 | New customer?{' '}
122 |
126 | {' '}
127 | Create your account
128 |
129 |
130 |
131 |
132 |
133 |
134 | );
135 | }
136 |
--------------------------------------------------------------------------------
/components/customer/login/registration-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ExclamationCircleIcon } from '@heroicons/react/24/outline';
4 | import InputText from 'components/checkout/cart/input';
5 | import Link from 'next/link';
6 | import { useFormState } from 'react-dom';
7 | import { createUser } from '../lib/action';
8 | import { LoadingButton } from './loading-button';
9 | const initialState = {
10 | firstName: '',
11 | lastName: '',
12 | email: '',
13 | password: '',
14 | passwordConfirmation: ''
15 | };
16 | export default function RegistrationForm() {
17 | const [status, formAction] = useFormState(createUser, initialState);
18 | return (
19 |
20 |
21 | {status?.errors?.apiError && (
22 |
23 | {' '}
24 | {status?.errors?.apiError}
25 |
26 | )}
27 |
28 |
Become User
29 |
30 | If you are new to our store, we glad to have you as member.
31 |
32 |
33 |
34 |
102 |
103 |
104 |
105 | Already have an account ?{' '}
106 |
107 | Sign In
108 |
109 |
110 |
111 |
112 |
113 |
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/components/customer/modal.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import {
3 | Avatar,
4 | Card,
5 | CardBody,
6 | CardFooter,
7 | CardHeader,
8 | Popover,
9 | PopoverContent,
10 | PopoverTrigger
11 | } from '@heroui/react';
12 | import clsx from 'clsx';
13 | import LoadingDots from 'components/loading-dots';
14 | import { isObject } from 'lib/type-guards';
15 | import { signOut, useSession } from 'next-auth/react';
16 | import Link from 'next/link';
17 | import { useEffect, useState } from 'react';
18 | import { useFormState } from 'react-dom';
19 | import { userLogoOut } from './lib/action';
20 | import OpenAuth from './open-auth';
21 | export default function CredentialModal() {
22 | const [isLoading, setLoader] = useState('');
23 |
24 | const { data: session } = useSession();
25 | const [status, handleLogout] = useFormState(userLogoOut, undefined);
26 |
27 | useEffect(() => {
28 | if (status?.success) {
29 | signOut({ callbackUrl: '/customer/login', redirect: false });
30 | } else if (status?.error) {
31 | console.error('Something gone wrong !');
32 | }
33 | }, [status]);
34 | const loadStatusHandler = (type: string) => {
35 | if (typeof window !== 'undefined') {
36 | (window as any).isLogOutLoading = true;
37 | }
38 | isLoading === '' && setLoader(type);
39 | };
40 | return (
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | {isObject(session?.user) ? (
49 |
50 |
51 |
52 |
}
58 | />
59 |
60 |
61 | {session?.user?.name}
62 |
63 |
64 | {session?.user?.email}
65 |
66 |
67 |
68 |
69 |
70 |
71 | Manage Cart, Orders
72 |
73 | 🎉
74 |
75 |
76 |
77 |
78 |
99 |
100 |
101 | ) : (
102 |
103 |
104 |
105 |
106 | Welcome Guest
107 |
108 |
109 |
110 |
111 |
112 | Manage Cart, Orders
113 |
114 | 🎉
115 |
116 |
117 |
118 |
119 | loadStatusHandler('login')}
121 | href="/customer/login"
122 | className="w-full"
123 | >
124 |
131 |
132 | {isLoading === 'login' ? (
133 |
134 |
Loading
135 |
136 |
137 | ) : (
138 |
Sign In
139 | )}
140 |
141 |
142 |
143 |
144 | loadStatusHandler('signup')}
146 | type="button"
147 | className={clsx(
148 | 'mb-2 w-full rounded-full bg-gray-800 px-5 py-2.5 text-sm font-medium text-white hover:bg-gray-900 focus:outline-none focus:ring-4 focus:ring-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700 dark:focus:ring-gray-700',
149 | isLoading === 'signup' ? 'cursor-not-allowed' : ''
150 | )}
151 | >
152 |
153 | {isLoading === 'signup' ? (
154 |
155 |
Loading
156 |
157 |
158 | ) : (
159 |
Sign Up
160 | )}
161 |
162 |
163 |
164 |
165 |
166 | )}
167 |
168 |
169 | );
170 | }
171 |
--------------------------------------------------------------------------------
/components/customer/open-auth.tsx:
--------------------------------------------------------------------------------
1 | import { UserIcon } from '@heroicons/react/24/outline';
2 | import clsx from 'clsx';
3 |
4 | export default function OpenAuth({ className }: { className?: string }) {
5 | return (
6 |
7 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/components/grid/index.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | function Grid(props: React.ComponentProps<'ul'>) {
4 | return (
5 |
8 | );
9 | }
10 |
11 | function GridItem(props: React.ComponentProps<'li'>) {
12 | return (
13 |
14 | {props.children}
15 |
16 | );
17 | }
18 |
19 | Grid.Item = GridItem;
20 |
21 | export default Grid;
22 |
--------------------------------------------------------------------------------
/components/grid/three-items.tsx:
--------------------------------------------------------------------------------
1 | import { GridTileImage } from 'components/grid/tile';
2 | import { getCollectionProducts } from 'lib/bagisto';
3 | import type { Product } from 'lib/bagisto/types';
4 | import Link from 'next/link';
5 |
6 | async function ThreeItemGridItem({
7 | item,
8 | size,
9 | priority
10 | }: {
11 | item: Product;
12 | size: 'full' | 'half';
13 | priority?: boolean;
14 | }) {
15 | return (
16 |
19 |
20 |
35 |
36 |
37 | );
38 | }
39 |
40 | export async function ThreeItemGrid() {
41 | // Collections that start with `hidden-*` are hidden from the search page.
42 | const homepageItems = await getCollectionProducts({
43 | collection: ''
44 | });
45 |
46 | if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
47 |
48 | const [firstProduct, secondProduct, thirdProduct] = homepageItems;
49 |
50 | return (
51 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/components/grid/tile.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import clsx from 'clsx';
3 | import Image from 'next/image';
4 | import Label from '../label';
5 |
6 | export function GridTileImage({
7 | isInteractive = true,
8 | active,
9 | label,
10 | ...props
11 | }: {
12 | isInteractive?: boolean;
13 | active?: boolean;
14 | label?: {
15 | title: string;
16 | amount: string;
17 | currencyCode: string;
18 | position?: 'bottom' | 'center';
19 | };
20 | } & React.ComponentProps) {
21 | const handleError = (e: React.SyntheticEvent) => {
22 | e.currentTarget.src = '/image/placeholder.webp'; // Fallback image on error
23 | };
24 | return (
25 |
35 | {/* eslint-disable-next-line jsx-a11y/alt-text -- `alt` is inherited from `props`, which is
36 | being enforced with TypeScript */}
37 |
44 |
45 | {label ? (
46 |
52 | ) : null}
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/components/icons/check-sign.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | export default function CheckSign(props: React.ComponentProps<'svg'>) {
4 | return (
5 |
13 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/components/icons/logo.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | export default function LogoIcon(props: React.ComponentProps<'svg'>) {
4 | return (
5 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/components/icons/right-arrow.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | export default function RightArrowIcon(props: React.ComponentProps<'svg'>) {
3 | return (
4 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/components/icons/seprator.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | const SeparatorIcon = (props: React.ComponentProps<'svg'>) => {
3 | return (
4 |
12 |
13 |
14 | );
15 | };
16 | export default SeparatorIcon;
17 |
--------------------------------------------------------------------------------
/components/icons/shopping-cart.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | export default function ShoppingCartIcon(props: React.ComponentProps<'svg'>) {
4 | return (
5 |
13 |
18 |
19 | );
20 | }
21 |
22 | export function BasketCartIcon(props: React.ComponentProps<'svg'>) {
23 | return (
24 |
32 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/components/icons/wallet-logo.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | export default function WalletLogo(props: React.ComponentProps<'svg'>) {
4 | return (
5 |
14 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/components/label.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import Price from './price';
3 |
4 | const Label = ({
5 | title,
6 | amount,
7 | currencyCode,
8 | position = 'bottom'
9 | }: {
10 | title: string;
11 | amount: string;
12 | currencyCode: string;
13 | position?: 'bottom' | 'center';
14 | }) => {
15 | return (
16 |
31 | );
32 | };
33 |
34 | export default Label;
35 |
--------------------------------------------------------------------------------
/components/layout/footer-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import { ThemeCustomization, ThemeOptions } from 'lib/bagisto/types';
5 | import { isArray } from 'lib/type-guards';
6 | import Link from 'next/link';
7 | import { usePathname } from 'next/navigation';
8 | import { useEffect, useState } from 'react';
9 | const getUrlparams = (url: string) => {
10 | const splitUrl = url.split('/');
11 | if (isArray(splitUrl)) {
12 | const urlLenght = splitUrl.length;
13 | if (urlLenght >= 1) {
14 | return `/${splitUrl.at(urlLenght - 1)}`;
15 | }
16 | }
17 | return '/';
18 | };
19 | const FooterMenuItem = ({ item }: { item: ThemeOptions }) => {
20 | const pathname = usePathname();
21 | const [active, setActive] = useState(pathname === item.url);
22 |
23 | useEffect(() => {
24 | setActive(pathname === item.url);
25 | }, [pathname, item.url]);
26 |
27 | return (
28 |
29 |
38 | {item.title}
39 |
40 |
41 | );
42 | };
43 |
44 | export default function FooterMenu({ menu }: { menu: ThemeCustomization[] }) {
45 | if (!menu) return null;
46 | const menuList = menu.find((item) => item?.type === 'footer_links');
47 | const channels = menuList?.translations?.at(0)?.options;
48 |
49 | return (
50 | <>
51 |
52 |
53 | {channels?.column_1?.map((item: ThemeOptions, index: number) => {
54 | return ;
55 | })}
56 |
57 |
58 |
59 |
60 | {channels?.column_2?.map((item: ThemeOptions, index: number) => {
61 | return ;
62 | })}
63 |
64 |
65 |
66 |
67 | {channels?.column_3?.map((item: ThemeOptions, index: number) => {
68 | return ;
69 | })}
70 |
71 |
72 | >
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/components/layout/footer.tsx:
--------------------------------------------------------------------------------
1 | import FooterMenu from 'components/layout/footer-menu';
2 | import LogoSquare from 'components/logo-square';
3 | import { getThemeCustomization } from 'lib/bagisto';
4 | import Link from 'next/link';
5 | import { Suspense } from 'react';
6 | const { COMPANY_NAME, SITE_NAME } = process.env;
7 |
8 | export default async function Footer() {
9 | const currentYear = new Date().getFullYear();
10 | const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
11 | const skeleton = 'w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700';
12 | const menu = await getThemeCustomization('next-js-frontend-footer-menu');
13 | const copyrightName = COMPANY_NAME || SITE_NAME || '';
14 | return (
15 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/components/layout/navbar/index.tsx:
--------------------------------------------------------------------------------
1 | import Cart from 'components/cart';
2 | import OpenCart from 'components/cart/open-cart';
3 | import UserAccount from 'components/customer';
4 | import LogoSquare from 'components/logo-square';
5 | import { getMenu } from 'lib/bagisto';
6 | import { Menu } from 'lib/bagisto/types';
7 | import Link from 'next/link';
8 | import { Suspense } from 'react';
9 | import MobileMenu from './mobile-menu';
10 | import Search, { SearchSkeleton } from './search';
11 | const { SITE_NAME } = process.env;
12 | export default async function Navbar() {
13 | const menu = await getMenu('next-js-frontend-header-menu');
14 | const menuData = [{ id: '', path: '/search', title: 'All' }, ...menu];
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {SITE_NAME}
28 |
29 |
30 | {menu.length ? (
31 |
32 | {menuData.slice(0, 3).map((item: Menu) => (
33 |
34 |
38 | {item.title}
39 |
40 |
41 | ))}
42 |
43 | ) : null}
44 |
45 |
46 | }>
47 |
48 |
49 |
50 |
51 | }>
52 |
53 |
54 | }>
55 |
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/components/layout/navbar/mobile-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Dialog, Transition } from '@headlessui/react';
4 | import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
5 | import { Menu } from 'lib/bagisto/types';
6 | import Link from 'next/link';
7 | import { usePathname, useSearchParams } from 'next/navigation';
8 | import { Fragment, Suspense, useEffect, useState } from 'react';
9 | import Search, { SearchSkeleton } from './search';
10 |
11 | export default function MobileMenu({ menu }: { menu: Menu[] }) {
12 | const pathname = usePathname();
13 | const searchParams = useSearchParams();
14 | const [isOpen, setIsOpen] = useState(false);
15 | const openMobileMenu = () => setIsOpen(true);
16 | const closeMobileMenu = () => setIsOpen(false);
17 |
18 | useEffect(() => {
19 | const handleResize = () => {
20 | if (window.innerWidth > 768) {
21 | setIsOpen(false);
22 | }
23 | };
24 | window.addEventListener('resize', handleResize);
25 | return () => window.removeEventListener('resize', handleResize);
26 | }, [isOpen]);
27 |
28 | useEffect(() => {
29 | setIsOpen(false);
30 | }, [pathname, searchParams]);
31 |
32 | return (
33 | <>
34 |
39 |
40 |
41 |
42 |
43 |
52 |
53 |
54 |
63 |
64 |
65 |
70 |
71 |
72 |
73 |
74 | }>
75 |
76 |
77 |
78 | {menu.length ? (
79 |
80 | {menu.map((item: Menu) => (
81 |
85 |
86 | {item.title}
87 |
88 |
89 | ))}
90 |
91 | ) : null}
92 |
93 |
94 |
95 |
96 |
97 | >
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/components/layout/navbar/search.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
4 | import { createUrl } from 'lib/utils';
5 | import { useRouter, useSearchParams } from 'next/navigation';
6 |
7 | export default function Search() {
8 | const router = useRouter();
9 | const searchParams = useSearchParams();
10 |
11 | function onSubmit(e: React.FormEvent) {
12 | e.preventDefault();
13 |
14 | const val = e.target as HTMLFormElement;
15 | const search = val.search as HTMLInputElement;
16 | const newParams = new URLSearchParams(searchParams.toString());
17 |
18 | if (search.value) {
19 | newParams.set('q', search.value);
20 | } else {
21 | newParams.delete('q');
22 | }
23 |
24 | router.push(createUrl('/search', newParams));
25 | }
26 |
27 | return (
28 |
42 | );
43 | }
44 |
45 | export function SearchSkeleton() {
46 | return (
47 |
56 | );
57 | }
--------------------------------------------------------------------------------
/components/layout/product-grid-items.tsx:
--------------------------------------------------------------------------------
1 | import Grid from 'components/grid';
2 | import { GridTileImage } from 'components/grid/tile';
3 | import { Product } from 'lib/bagisto/types';
4 | import Link from 'next/link';
5 |
6 | export default function ProductGridItems({ products }: { products: Product[] }) {
7 | return (
8 | <>
9 | {products.map((product) => (
10 |
11 |
12 |
23 |
24 |
25 | ))}
26 | >
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/components/layout/search/collections.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { getMenu } from 'lib/bagisto';
3 | import { Suspense } from 'react';
4 | import FilterList from './filter';
5 | async function CollectionList() {
6 | const collections = await getMenu('header-menu');
7 | const menuData = [{ path: '/search', title: 'All' }, ...collections];
8 | return ;
9 | }
10 |
11 | const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
12 | const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
13 | const items = 'bg-neutral-400 dark:bg-neutral-700';
14 |
15 | export default function Collections() {
16 | return (
17 |
20 | {Array(2)
21 | .fill(0)
22 | .map((_, i) => (
23 |
24 | ))}
25 | {Array(8)
26 | .fill(0)
27 | .map((_, i) => (
28 |
29 | ))}
30 |
31 | }
32 | >
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/components/layout/search/filter/dropdown.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { usePathname, useSearchParams } from 'next/navigation';
4 | import { useEffect, useRef, useState } from 'react';
5 | import { ChevronDownIcon } from '@heroicons/react/24/outline';
6 | import type { ListItem } from '.';
7 | import { FilterItem } from './item';
8 |
9 | export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
10 | const pathname = usePathname();
11 | const searchParams = useSearchParams();
12 | const [active, setActive] = useState('');
13 | const [openSelect, setOpenSelect] = useState(false);
14 | const ref = useRef(null);
15 |
16 | useEffect(() => {
17 | const handleClickOutside = (event: MouseEvent) => {
18 | if (ref.current && !ref.current.contains(event.target as Node)) {
19 | setOpenSelect(false);
20 | }
21 | };
22 |
23 | window.addEventListener('click', handleClickOutside);
24 | return () => window.removeEventListener('click', handleClickOutside);
25 | }, []);
26 |
27 | useEffect(() => {
28 | list.forEach((listItem: ListItem) => {
29 | if (
30 | ('path' in listItem && pathname === listItem.path) ||
31 | ('slug' in listItem && searchParams.get('sort') === listItem.slug)
32 | ) {
33 | setActive(listItem.title);
34 | }
35 | });
36 | }, [pathname, list, searchParams]);
37 |
38 | return (
39 |
40 |
{
42 | setOpenSelect(!openSelect);
43 | }}
44 | className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30"
45 | >
46 |
{active}
47 |
48 |
49 | {openSelect && (
50 |
{
52 | setOpenSelect(false);
53 | }}
54 | className="absolute z-40 w-full rounded-b-md bg-white p-4 shadow-md dark:bg-black"
55 | >
56 | {list.map((item: ListItem, i) => (
57 |
58 | ))}
59 |
60 | )}
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/components/layout/search/filter/index.tsx:
--------------------------------------------------------------------------------
1 | import { SortFilterItem } from 'lib/constants';
2 | import { Suspense } from 'react';
3 | import FilterItemDropdown from './dropdown';
4 | import { FilterItem } from './item';
5 |
6 | export type ListItem = SortFilterItem | PathFilterItem;
7 | export type PathFilterItem = { title: string; path: string };
8 |
9 | function FilterItemList({ list }: { list: ListItem[] }) {
10 | return (
11 | <>
12 | {list.map((item: ListItem, i) => (
13 |
14 | ))}
15 | >
16 | );
17 | }
18 |
19 | export default async function FilterList({ list, title }: { list: ListItem[]; title?: string }) {
20 | return (
21 | <>
22 |
23 | {title ? (
24 |
25 | {title}
26 |
27 | ) : null}
28 |
33 |
38 |
39 | >
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/components/layout/search/filter/item.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import type { SortFilterItem } from 'lib/constants';
5 | import { createUrl } from 'lib/utils';
6 | import Link from 'next/link';
7 | import { usePathname, useSearchParams } from 'next/navigation';
8 | import type { ListItem, PathFilterItem } from '.';
9 |
10 | function PathFilterItem({ item }: { item: PathFilterItem }) {
11 | const pathname = usePathname();
12 | const searchParams = useSearchParams();
13 | const active = pathname === item.path;
14 | const newParams = new URLSearchParams(searchParams.toString());
15 | const DynamicTag = active ? 'p' : Link;
16 |
17 | newParams.delete('q');
18 |
19 | return (
20 |
21 |
30 | {item.title}
31 |
32 |
33 | );
34 | }
35 |
36 | function SortFilterItem({ item }: { item: SortFilterItem }) {
37 | const pathname = usePathname();
38 | const searchParams = useSearchParams();
39 | const active = searchParams.get('sort') === item.slug;
40 | const q = searchParams.get('q');
41 | const href = createUrl(
42 | pathname,
43 | new URLSearchParams({
44 | ...(q && { q }),
45 | ...(item.slug && item.slug.length && { sort: item.slug })
46 | })
47 | );
48 | const DynamicTag = active ? 'p' : Link;
49 |
50 | return (
51 |
52 |
59 | {item.title}
60 |
61 |
62 | );
63 | }
64 |
65 | export function FilterItem({ item }: { item: ListItem }) {
66 | return 'path' in item ? : ;
67 | }
68 |
--------------------------------------------------------------------------------
/components/loading-dots.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | const dots = 'mx-[1px] inline-block h-1 w-1 animate-blink rounded-md';
4 |
5 | const LoadingDots = ({ className }: { className: string }) => {
6 | return (
7 |
8 |
9 | {Array(2)
10 | .fill(0)
11 | .map((_, i) => (
12 |
13 | ))}
14 |
15 | );
16 | };
17 |
18 | export default LoadingDots;
19 |
--------------------------------------------------------------------------------
/components/logo-square.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import LogoIcon from './icons/logo';
3 |
4 | export default function LogoSquare({ size }: { size?: 'sm' | undefined }) {
5 | return (
6 |
15 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/components/opengraph-image.tsx:
--------------------------------------------------------------------------------
1 | import { readFile } from 'fs/promises';
2 | import { ImageResponse } from 'next/og';
3 | import { join } from 'path';
4 | import LogoIcon from './icons/logo';
5 |
6 | export type Props = {
7 | title?: string;
8 | };
9 |
10 | export default async function OpenGraphImage(props?: Props): Promise {
11 | const { title } = {
12 | ...{
13 | title: process.env.SITE_NAME
14 | },
15 | ...props
16 | };
17 | const file = await readFile(join(process.cwd(), './fonts/Inter-Bold.ttf'));
18 | const font = Uint8Array.from(file).buffer;
19 |
20 | return new ImageResponse(
21 | (
22 |
23 |
24 |
25 |
26 |
{title}
27 |
28 | ),
29 | {
30 | width: 1200,
31 | height: 630,
32 | fonts: [
33 | {
34 | name: 'Inter',
35 | data: font,
36 | style: 'normal',
37 | weight: 700
38 | }
39 | ]
40 | }
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/components/price.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | const Price = ({
4 | amount,
5 | className,
6 | currencyCode = 'USD',
7 | currencyCodeClassName
8 | }: {
9 | amount: string;
10 | className?: string;
11 | currencyCode: string;
12 | currencyCodeClassName?: string;
13 | } & React.ComponentProps<'p'>) => (
14 |
15 | {`${new Intl.NumberFormat(undefined, {
16 | style: 'currency',
17 | currency: currencyCode,
18 | currencyDisplay: 'narrowSymbol'
19 | }).format(parseFloat(amount))}`}
20 | {`${currencyCode}`}
21 |
22 | );
23 |
24 | export default Price;
25 |
--------------------------------------------------------------------------------
/components/product/gallery.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
4 | import { GridTileImage } from 'components/grid/tile';
5 | import { createUrl } from 'lib/utils';
6 | import Image from 'next/image';
7 | import Link from 'next/link';
8 | import { usePathname, useSearchParams } from 'next/navigation';
9 |
10 | export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
11 | const pathname = usePathname();
12 | const searchParams = useSearchParams();
13 | const imageSearchParam = searchParams.get('image');
14 | const imageIndex = imageSearchParam ? parseInt(imageSearchParam) : 0;
15 |
16 | const nextSearchParams = new URLSearchParams(searchParams.toString());
17 | const nextImageIndex = imageIndex + 1 < images?.length ? imageIndex + 1 : 0;
18 | nextSearchParams.set('image', nextImageIndex.toString());
19 | const nextUrl = createUrl(pathname, nextSearchParams);
20 |
21 | const previousSearchParams = new URLSearchParams(searchParams.toString());
22 | const previousImageIndex = imageIndex === 0 ? images?.length - 1 : imageIndex - 1;
23 | previousSearchParams.set('image', previousImageIndex.toString());
24 | const previousUrl = createUrl(pathname, previousSearchParams);
25 |
26 | const buttonClassName =
27 | 'flex h-full items-center justify-center px-6 transition-all ease-in-out hover:scale-110 hover:text-black dark:hover:text-white';
28 |
29 | return (
30 | <>
31 |
32 | {images?.[imageIndex] && (
33 |
41 | )}
42 |
43 | {images?.length > 1 ? (
44 |
45 |
46 |
52 |
53 |
54 |
55 |
61 |
62 |
63 |
64 |
65 | ) : null}
66 |
67 |
68 | {images?.length > 1 ? (
69 |
70 | {images.map((image, index) => {
71 | const isActive = index === imageIndex;
72 | const imageSearchParams = new URLSearchParams(searchParams.toString());
73 |
74 | imageSearchParams.set('image', index.toString());
75 |
76 | return (
77 |
78 |
84 |
91 |
92 |
93 | );
94 | })}
95 |
96 | ) : null}
97 | >
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/components/product/product-description.tsx:
--------------------------------------------------------------------------------
1 | import { AddToCart } from 'components/cart/add-to-cart';
2 | import Price from 'components/price';
3 | import Prose from 'components/prose';
4 | import { BagistoProductInfo } from 'lib/bagisto/types';
5 | import { notFound } from 'next/navigation';
6 | import { Suspense } from 'react';
7 | import { VariantSelector } from './variant-selector';
8 |
9 | export function ProductDescription({ product }: { product: BagistoProductInfo[] }) {
10 | if (!product.length) return notFound();
11 | const data = product[0];
12 | const configurableProductData = data?.configutableData?.attributes || [];
13 | const configurableProductIndexData = data?.configutableData?.index || [];
14 | const quantity = Number(data?.inventories?.[0]?.qty);
15 | return (
16 | <>
17 |
18 |
{data?.name}
19 |
25 |
26 |
27 |
28 |
29 | {data?.description ? (
30 |
34 | ) : null}
35 |
36 |
37 | 0 ? true : false}
42 | />
43 |
44 | >
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/components/product/variant-selector.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import clsx from 'clsx';
4 | import { ConfigurableProductData, ConfigurableProductIndexData } from 'lib/bagisto/types';
5 | import { createUrl } from 'lib/utils';
6 | import { usePathname, useRouter, useSearchParams } from 'next/navigation';
7 |
8 | type Combination = {
9 | id: string;
10 | availableForSale: boolean;
11 | [key: string]: string | boolean; // ie. { color: 'Red', size: 'Large', ... }
12 | };
13 |
14 | export function VariantSelector({
15 | variants,
16 | index
17 | }: {
18 | variants: ConfigurableProductData[];
19 | index: ConfigurableProductIndexData[];
20 | }) {
21 | const router = useRouter();
22 | const pathname = usePathname();
23 | const searchParams = useSearchParams();
24 | const hasNoOptionsOrJustOneOption =
25 | !variants?.length || (variants.length === 1 && variants[0]?.options.length === 1);
26 |
27 | if (hasNoOptionsOrJustOneOption) {
28 | return null;
29 | }
30 |
31 | const combinations: Combination[] = index?.map((variant) => ({
32 | id: variant.id,
33 | availableForSale: true,
34 | // Adds key / value pairs for each variant (ie. "color": "Black" and "size": 'M").
35 | ...variant?.attributeOptionIds.reduce(
36 | (accumulator, option) => ({
37 | ...accumulator,
38 | [option.attributeCode.toLowerCase()]: option.attributeOptionId
39 | }),
40 | {}
41 | )
42 | }));
43 |
44 | return variants.map((option) => (
45 |
46 | {option.label}
47 |
48 | {option.options.map((value) => {
49 | const optionNameLowerCase = option.label.toLowerCase();
50 |
51 | // Base option params on current params so we can preserve any other param state in the url.
52 | const optionSearchParams = new URLSearchParams(searchParams.toString());
53 |
54 | // Update the option params using the current option to reflect how the url *would* change,
55 | // if the option was clicked.
56 | optionSearchParams.set(optionNameLowerCase, value?.id);
57 | const optionUrl = createUrl(pathname, optionSearchParams);
58 |
59 | // In order to determine if an option is available for sale, we need to:
60 | //
61 | // 1. Filter out all other param state
62 | // 2. Filter out invalid options
63 | // 3. Check if the option combination is available for sale
64 | //
65 | // This is the "magic" that will cross check possible variant combinations and preemptively
66 | // disable combinations that are not available. For example, if the color gray is only available in size medium,
67 | // then all other sizes should be disabled.
68 | const filtered = Array.from(optionSearchParams.entries()).filter(([key, value]) =>
69 | variants.find(
70 | (option) =>
71 | option.label.toLowerCase() === key &&
72 | option.options.some((option) => option.label === value)
73 | )
74 | );
75 | const isAvailableForSale = combinations.find((combination) =>
76 | filtered.every(
77 | ([key, value]) => combination[key] === value && combination.availableForSale
78 | )
79 | );
80 |
81 | // The option is active if it's in the url params.
82 | const isActive = searchParams.get(optionNameLowerCase) === value?.id;
83 |
84 | return (
85 | {
90 | router.replace(optionUrl, { scroll: false });
91 | }}
92 | title={`${option.label} ${value.label}${
93 | !isAvailableForSale ? ' (Out of Stock)' : ''
94 | }`}
95 | className={clsx(
96 | 'flex min-w-[48px] items-center justify-center rounded-full border bg-neutral-100 px-2 py-1 text-sm dark:border-neutral-800 dark:bg-neutral-900',
97 | {
98 | 'cursor-default ring-2 ring-blue-600': isActive,
99 | 'ring-1 ring-transparent transition duration-300 ease-in-out hover:scale-110 hover:ring-blue-600 ':
100 | !isActive && isAvailableForSale,
101 | 'relative z-10 cursor-not-allowed overflow-hidden bg-neutral-100 text-neutral-500 ring-1 ring-neutral-300 before:absolute before:inset-x-0 before:-z-10 before:h-px before:-rotate-45 before:bg-neutral-300 before:transition-transform dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 before:dark:bg-neutral-700':
102 | !isAvailableForSale
103 | }
104 | )}
105 | >
106 | {value?.label}
107 |
108 | );
109 | })}
110 |
111 |
112 | ));
113 | }
114 |
--------------------------------------------------------------------------------
/components/prose.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import type { FunctionComponent } from 'react';
3 |
4 | interface TextProps {
5 | html: string;
6 | className?: string;
7 | }
8 |
9 | const Prose: FunctionComponent = ({ html, className }) => {
10 | return (
11 |
18 | );
19 | };
20 |
21 | export default Prose;
22 |
--------------------------------------------------------------------------------
/fonts/Inter-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bagisto/nextjs-commerce/d3b59621fc5027898416cf2add9b4d65a67eecc0/fonts/Inter-Bold.ttf
--------------------------------------------------------------------------------
/lib/bagisto/fragments/cart.ts:
--------------------------------------------------------------------------------
1 | import productFragment from './product';
2 |
3 | const cartFragment = /* GraphQL */ `
4 | fragment cart on Cart {
5 | {
6 | status
7 | message
8 | cart {
9 | id
10 | customerEmail
11 | customerFirstName
12 | customerLastName
13 | shippingMethod
14 | couponCode
15 | isGift
16 | itemsCount
17 | itemsQty
18 | exchangeRate
19 | globalCurrencyCode
20 | baseCurrencyCode
21 | channelCurrencyCode
22 | cartCurrencyCode
23 | grandTotal
24 | baseGrandTotal
25 | subTotal
26 | baseSubTotal
27 | taxTotal
28 | baseTaxTotal
29 | discountAmount
30 | baseDiscountAmount
31 | checkoutMethod
32 | isGuest
33 | isActive
34 | customerId
35 | channelId
36 | appliedCartRuleIds
37 | createdAt
38 | updatedAt
39 | }
40 | }
41 | ${productFragment}
42 | `;
43 |
44 | export default cartFragment;
45 |
--------------------------------------------------------------------------------
/lib/bagisto/fragments/image.ts:
--------------------------------------------------------------------------------
1 | const imageFragment = /* GraphQL */ `
2 | fragment image on Image {
3 | url
4 | altText
5 | width
6 | height
7 | }
8 | `;
9 |
10 | export default imageFragment;
11 |
--------------------------------------------------------------------------------
/lib/bagisto/fragments/seo.ts:
--------------------------------------------------------------------------------
1 | const seoFragment = /* GraphQL */ `
2 | fragment seo on SEO {
3 | description
4 | title
5 | }
6 | `;
7 |
8 | export default seoFragment;
9 |
--------------------------------------------------------------------------------
/lib/bagisto/mutations/cart.ts:
--------------------------------------------------------------------------------
1 | import cartFragment from '../fragments/cart';
2 |
3 | export const addToCartMutation = /* GraphQL */ `
4 | mutation addItemToCart($input: AddItemToCartInput!) {
5 | addItemToCart(input: $input) {
6 | message
7 | success
8 | cart {
9 | id
10 | customerEmail
11 | customerFirstName
12 | customerLastName
13 | shippingMethod
14 | couponCode
15 | isGift
16 | itemsCount
17 | itemsQty
18 | exchangeRate
19 | globalCurrencyCode
20 | baseCurrencyCode
21 | channelCurrencyCode
22 | cartCurrencyCode
23 | grandTotal
24 | baseGrandTotal
25 | subTotal
26 | baseSubTotal
27 | taxTotal
28 | baseTaxTotal
29 | discountAmount
30 | baseDiscountAmount
31 | shippingAmount
32 | baseShippingAmount
33 | shippingAmountInclTax
34 | baseShippingAmountInclTax
35 | subTotalInclTax
36 | baseSubTotalInclTax
37 | checkoutMethod
38 | isGuest
39 | isActive
40 | appliedCartRuleIds
41 | customerId
42 | channelId
43 | createdAt
44 | updatedAt
45 | status
46 | message
47 | }
48 | }
49 | }
50 | `;
51 |
52 | export const createCartMutation = /* GraphQL */ `
53 | mutation createCart($lineItems: [CartLineInput!]) {
54 | cartCreate(input: { lines: $lineItems }) {
55 | cart {
56 | ...cart
57 | }
58 | }
59 | }
60 | ${cartFragment}
61 | `;
62 |
63 | export const editCartItemsMutation = /* GraphQL */ `
64 | mutation updateItemToCart($input: UpdateItemToCartInput!) {
65 | updateItemToCart(input: $input) {
66 | cart {
67 | id
68 | customerEmail
69 | customerFirstName
70 | customerLastName
71 | shippingMethod
72 | couponCode
73 | isGift
74 | itemsCount
75 | itemsQty
76 | exchangeRate
77 | globalCurrencyCode
78 | baseCurrencyCode
79 | channelCurrencyCode
80 | cartCurrencyCode
81 | grandTotal
82 | baseGrandTotal
83 | subTotal
84 | baseSubTotal
85 | taxTotal
86 | baseTaxTotal
87 | discountAmount
88 | baseDiscountAmount
89 | checkoutMethod
90 | isGuest
91 | isActive
92 | customerId
93 | channelId
94 | appliedCartRuleIds
95 | createdAt
96 | updatedAt
97 | }
98 | }
99 | }
100 | `;
101 |
102 | export const removeFromCartMutation = /* GraphQL */ `
103 | mutation removeCartItem($lineIds: ID!) {
104 | removeCartItem(id: $lineIds) {
105 | cart {
106 | id
107 | customerEmail
108 | customerFirstName
109 | customerLastName
110 | shippingMethod
111 | couponCode
112 | isGift
113 | itemsCount
114 | itemsQty
115 | exchangeRate
116 | globalCurrencyCode
117 | baseCurrencyCode
118 | channelCurrencyCode
119 | cartCurrencyCode
120 | grandTotal
121 | baseGrandTotal
122 | subTotal
123 | baseSubTotal
124 | taxTotal
125 | baseTaxTotal
126 | discountAmount
127 | baseDiscountAmount
128 | checkoutMethod
129 | isGuest
130 | isActive
131 | customerId
132 | channelId
133 | appliedCartRuleIds
134 | createdAt
135 | updatedAt
136 | }
137 | }
138 | }
139 | `;
140 |
--------------------------------------------------------------------------------
/lib/bagisto/mutations/customer-login.ts:
--------------------------------------------------------------------------------
1 | export const CustomerLogin = /* GraphQL */ `
2 | mutation CustomerLogin($input: LoginInput!) {
3 | customerLogin(input: $input) {
4 | success
5 | message
6 | accessToken
7 | tokenType
8 | expiresIn
9 | customer {
10 | id
11 | firstName
12 | lastName
13 | name
14 | gender
15 | dateOfBirth
16 | email
17 | phone
18 | image
19 | imageUrl
20 | password
21 | apiToken
22 | customerGroupId
23 | isVerified
24 | isSuspended
25 | token
26 | status
27 | createdAt
28 | updatedAt
29 | }
30 | }
31 | }
32 | `;
33 |
--------------------------------------------------------------------------------
/lib/bagisto/mutations/customer-register.ts:
--------------------------------------------------------------------------------
1 | export const CustomerRegister = /* GraphQL */ `
2 | mutation CustomerSignUp($input: SignUpInput!) {
3 | customerSignUp(input: $input) {
4 | success
5 | message
6 | accessToken
7 | tokenType
8 | expiresIn
9 | }
10 | }
11 | `;
12 |
--------------------------------------------------------------------------------
/lib/bagisto/mutations/payment-method.ts:
--------------------------------------------------------------------------------
1 | export const savePaymentMutation = /* GraphQL */ `
2 | mutation savePayment($input: savePaymentMethodInput!) {
3 | savePayment(input: $input) {
4 | message
5 | cart {
6 | id
7 | customerEmail
8 | customerFirstName
9 | customerLastName
10 | shippingMethod
11 | couponCode
12 | isGift
13 | itemsCount
14 | itemsQty
15 | exchangeRate
16 | globalCurrencyCode
17 | baseCurrencyCode
18 | channelCurrencyCode
19 | cartCurrencyCode
20 | grandTotal
21 | baseGrandTotal
22 | subTotal
23 | baseSubTotal
24 | taxTotal
25 | baseTaxTotal
26 | discountAmount
27 | baseDiscountAmount
28 | shippingAmount
29 | baseShippingAmount
30 | shippingAmountInclTax
31 | baseShippingAmountInclTax
32 | subTotalInclTax
33 | baseSubTotalInclTax
34 | checkoutMethod
35 | isGuest
36 | isActive
37 | appliedCartRuleIds
38 | customerId
39 | channelId
40 | createdAt
41 | updatedAt
42 | status
43 | message
44 | appliedTaxRates {
45 | taxName
46 | totalAmount
47 | }
48 | formattedPrice {
49 | adjustmentFee
50 | adjustmentRefund
51 | amountRefunded
52 | baseAdjustmentFee
53 | baseAdjustmentRefund
54 | baseAmountRefunded
55 | baseDiscount
56 | baseDiscountAmount
57 | baseDiscountInvoiced
58 | baseDiscountRefunded
59 | baseDiscountedSubTotal
60 | baseGrandTotal
61 | baseGrandTotalInvoiced
62 | baseGrandTotalRefunded
63 | basePrice
64 | basePriceInclTax
65 | baseShippingAmount
66 | baseShippingAmountInclTax
67 | baseShippingDiscountAmount
68 | baseShippingInvoiced
69 | baseShippingRefunded
70 | baseShippingTaxAmount
71 | baseShippingTaxRefunded
72 | baseSubTotal
73 | baseSubTotalInclTax
74 | baseSubTotalInvoiced
75 | baseTaxAmount
76 | baseTaxAmountInvoiced
77 | baseTaxAmountRefunded
78 | baseTaxTotal
79 | baseTotal
80 | baseTotalInclTax
81 | baseTotalInvoiced
82 | customPrice
83 | discount
84 | discountAmount
85 | discountInvoiced
86 | discountRefunded
87 | discountedSubTotal
88 | grandTotal
89 | grandTotalInvoiced
90 | grandTotalRefunded
91 | price
92 | priceInclTax
93 | shippingAmount
94 | shippingAmountInclTax
95 | shippingDiscountAmount
96 | shippingInvoiced
97 | shippingRefunded
98 | shippingTaxAmount
99 | shippingTaxRefunded
100 | subTotal
101 | subTotalInclTax
102 | subTotalInvoiced
103 | subTotalRefunded
104 | taxAmount
105 | taxAmountInvoiced
106 | taxAmountRefunded
107 | taxTotal
108 | total
109 | totalInclTax
110 | totalInvoiced
111 | }
112 | items {
113 | id
114 | quantity
115 | sku
116 | type
117 | name
118 | couponCode
119 | weight
120 | totalWeight
121 | baseTotalWeight
122 | price
123 | basePrice
124 | customPrice
125 | total
126 | baseTotal
127 | taxPercent
128 | taxAmount
129 | baseTaxAmount
130 | discountPercent
131 | discountAmount
132 | baseDiscountAmount
133 | priceInclTax
134 | basePriceInclTax
135 | totalInclTax
136 | baseTotalInclTax
137 | appliedTaxRate
138 | parentId
139 | productId
140 | cartId
141 | taxCategoryId
142 | appliedCartRuleIds
143 | createdAt
144 | updatedAt
145 | }
146 | allItems {
147 | id
148 | quantity
149 | sku
150 | type
151 | name
152 | couponCode
153 | weight
154 | totalWeight
155 | baseTotalWeight
156 | price
157 | basePrice
158 | customPrice
159 | total
160 | baseTotal
161 | taxPercent
162 | taxAmount
163 | baseTaxAmount
164 | discountPercent
165 | discountAmount
166 | baseDiscountAmount
167 | priceInclTax
168 | basePriceInclTax
169 | totalInclTax
170 | baseTotalInclTax
171 | appliedTaxRate
172 | parentId
173 | productId
174 | cartId
175 | taxCategoryId
176 | appliedCartRuleIds
177 | createdAt
178 | updatedAt
179 | }
180 | billingAddress {
181 | id
182 | addressType
183 | parentAddressId
184 | customerId
185 | cartId
186 | orderId
187 | firstName
188 | lastName
189 | gender
190 | companyName
191 | address
192 | city
193 | state
194 | stateName
195 | country
196 | countryName
197 | postcode
198 | email
199 | phone
200 | vatId
201 | defaultAddress
202 | useForShipping
203 | createdAt
204 | updatedAt
205 | }
206 | shippingAddress {
207 | id
208 | addressType
209 | parentAddressId
210 | customerId
211 | cartId
212 | orderId
213 | firstName
214 | lastName
215 | gender
216 | companyName
217 | address
218 | city
219 | state
220 | stateName
221 | country
222 | countryName
223 | postcode
224 | email
225 | phone
226 | vatId
227 | defaultAddress
228 | useForShipping
229 | createdAt
230 | updatedAt
231 | }
232 | selectedShippingRate {
233 | id
234 | carrier
235 | carrierTitle
236 | method
237 | methodTitle
238 | methodDescription
239 | price
240 | basePrice
241 | discountAmount
242 | baseDiscountAmount
243 | taxPercent
244 | taxAmount
245 | baseTaxAmount
246 | priceInclTax
247 | basePriceInclTax
248 | appliedTaxRate
249 | isCalculateTax
250 | cartAddressId
251 | createdAt
252 | updatedAt
253 | }
254 | payment {
255 | id
256 | method
257 | methodTitle
258 | cartId
259 | createdAt
260 | updatedAt
261 | }
262 | }
263 | }
264 | }
265 | `;
266 |
--------------------------------------------------------------------------------
/lib/bagisto/mutations/place-order.ts:
--------------------------------------------------------------------------------
1 | export const savePlaceOrder = /* GraphQL */ `
2 | mutation placeOrder {
3 | placeOrder {
4 | success
5 | redirectUrl
6 | selectedMethod
7 | order {
8 | id
9 | customerEmail
10 | customerFirstName
11 | customerLastName
12 | }
13 | }
14 | }
15 | `;
16 |
--------------------------------------------------------------------------------
/lib/bagisto/mutations/recover-password.ts:
--------------------------------------------------------------------------------
1 | export const ForgetPassword = /* GraphQL */ `
2 | mutation ForgotPassword($email: String!) {
3 | forgotPassword(email: $email) {
4 | success
5 | message
6 | }
7 | }
8 | `;
9 |
--------------------------------------------------------------------------------
/lib/bagisto/mutations/shipping-address.ts:
--------------------------------------------------------------------------------
1 | export const addShippingAddressMutation = /* GraphQL */ `
2 | mutation saveCheckoutAddresses($input: SaveShippingAddressInput!) {
3 | saveCheckoutAddresses(input: $input) {
4 | shippingMethods {
5 | title
6 | methods {
7 | code
8 | label
9 | price
10 | formattedPrice
11 | basePrice
12 | formattedBasePrice
13 | }
14 | }
15 | cart {
16 | id
17 | customerEmail
18 | customerFirstName
19 | customerLastName
20 | shippingMethod
21 | couponCode
22 | isGift
23 | itemsCount
24 | itemsQty
25 | }
26 | }
27 | }
28 | `;
29 |
--------------------------------------------------------------------------------
/lib/bagisto/mutations/shipping-method.ts:
--------------------------------------------------------------------------------
1 | export const addShippingMethodMutation = /* GraphQL */ `
2 | mutation saveShipping($input: saveShippingMethodInput!) {
3 | saveShipping(input: $input) {
4 | message
5 | cart {
6 | id
7 | customerEmail
8 | customerFirstName
9 | customerLastName
10 | shippingMethod
11 | couponCode
12 | isGift
13 | itemsCount
14 | itemsQty
15 | }
16 | }
17 | }
18 | `;
19 |
--------------------------------------------------------------------------------
/lib/bagisto/queries/channel.ts:
--------------------------------------------------------------------------------
1 | export const getChannelQuery = /* GraphQL */ `
2 | query Channel {
3 | getDefaultChannel {
4 | id
5 | code
6 | name
7 | description
8 | hostname
9 | logoUrl
10 | faviconUrl
11 | }
12 | }
13 | `;
14 |
--------------------------------------------------------------------------------
/lib/bagisto/queries/collection.ts:
--------------------------------------------------------------------------------
1 | import seoFragment from '../fragments/seo';
2 |
3 | const collectionFragment = /* GraphQL */ `
4 | fragment collection on Collection {
5 | handle
6 | title
7 | description
8 | seo {
9 | ...seo
10 | }
11 | updatedAt
12 | }
13 | ${seoFragment}
14 | `;
15 |
16 | export const getCollectionQuery = /* GraphQL */ `
17 | query getCollection($handle: String!) {
18 | collection(handle: $handle) {
19 | ...collection
20 | }
21 | }
22 | ${collectionFragment}
23 | `;
24 |
25 | export const getCollectionsQuery = /* GraphQL */ `
26 | query getCollections {
27 | collections(first: 100, sortKey: TITLE) {
28 | edges {
29 | node {
30 | ...collection
31 | }
32 | }
33 | }
34 | }
35 | ${collectionFragment}
36 | `;
37 |
38 | import { productInfoFragment } from '../fragments/product';
39 |
40 | export const getCollectionProductsQuery = /* GraphQL */ `
41 | query allProducts($input: [FilterHomeCategoriesInput]) {
42 | allProducts(input: $input) {
43 | paginatorInfo {
44 | count
45 | currentPage
46 | lastPage
47 | total
48 | }
49 | data {
50 | ...productInfo
51 | }
52 | }
53 | }
54 |
55 | ${productInfoFragment}
56 | `;
57 |
--------------------------------------------------------------------------------
/lib/bagisto/queries/filter-attribute.ts:
--------------------------------------------------------------------------------
1 | export const getFilterAttribute = /* GraphQL */ `
2 | query GetFilterAttribute {
3 | getFilterAttribute {
4 | filterData {
5 | key
6 | value
7 | }
8 | sortOrders {
9 | key
10 | title
11 | value
12 | sort
13 | order
14 | position
15 | }
16 | }
17 | }
18 | `;
19 |
--------------------------------------------------------------------------------
/lib/bagisto/queries/menu.ts:
--------------------------------------------------------------------------------
1 | export const getMenuQuery = /* GraphQL */ `
2 | query homeCategories {
3 | homeCategories(
4 | input: [
5 | { key: "parent_id", value: "1" }
6 | { key: "locale", value: "en" }
7 | { key: "status", value: "1" }
8 | ]
9 | ) {
10 | id
11 | # categoryId
12 | position
13 | logoPath
14 | logoUrl
15 | status
16 | displayMode
17 | lft
18 | rgt
19 | parentId
20 | additional
21 | bannerPath
22 | bannerUrl
23 | name
24 | slug
25 | urlPath
26 | description
27 | metaTitle
28 | metaDescription
29 | metaKeywords
30 | localeId
31 | createdAt
32 | updatedAt
33 | filterableAttributes {
34 | id
35 | adminName
36 | code
37 | type
38 | position
39 | }
40 | children {
41 | id
42 | name
43 | description
44 | slug
45 | urlPath
46 | logoPath
47 | logoUrl
48 | bannerPath
49 | bannerUrl
50 | metaTitle
51 | metaDescription
52 | metaKeywords
53 | position
54 | status
55 | displayMode
56 | parentId
57 | }
58 | }
59 | }
60 | `;
61 |
--------------------------------------------------------------------------------
/lib/bagisto/queries/page.ts:
--------------------------------------------------------------------------------
1 | export const getPageQuery = /* GraphQL */ `
2 | query cmsPages($input: FilterCmsPageInput) {
3 | cmsPages(input: $input) {
4 | data {
5 | id
6 | layout
7 | createdAt
8 | updatedAt
9 | translations {
10 | id
11 | urlKey
12 | metaDescription
13 | metaTitle
14 | pageTitle
15 | metaKeywords
16 | htmlContent
17 | locale
18 | cmsPageId
19 | }
20 | }
21 | }
22 | }
23 | `;
24 |
25 | export const getPagesQuery = /* GraphQL */ `
26 | query CmsPages {
27 | cmsPages(first: 100) {
28 | data {
29 | updatedAt
30 | translations {
31 | urlKey
32 | }
33 | }
34 | }
35 | }
36 | `;
37 |
38 | export const getCountryQuery = /* GraphQL */ `
39 | query Countries {
40 | countries {
41 | id
42 | code
43 | name
44 | states {
45 | id
46 | countryCode
47 | code
48 | defaultName
49 | countryId
50 | }
51 | }
52 | }
53 | `;
54 |
55 | export const getThemeCustomizationQuery = /* GraphQL */ `
56 | query themeCustomization {
57 | themeCustomization {
58 | id
59 | type
60 | name
61 | status
62 | translations {
63 | id
64 | options {
65 | column_1 {
66 | url
67 | title
68 | sortOrder
69 | }
70 | column_2 {
71 | url
72 | title
73 | sortOrder
74 | }
75 | column_3 {
76 | url
77 | title
78 | sortOrder
79 | }
80 | }
81 | }
82 | }
83 | }
84 | `;
85 |
--------------------------------------------------------------------------------
/lib/bagisto/queries/payment-methods.ts:
--------------------------------------------------------------------------------
1 | export const getPaymentMethodsQuery = /* GraphQL */ `
2 | query PaymentMethods($input: PaymentMethodsInput!) {
3 | paymentMethods(input: $input) {
4 | paymentMethods {
5 | method
6 | methodTitle
7 | description
8 | sort
9 | image
10 | }
11 | message
12 | }
13 | }
14 | `;
15 |
--------------------------------------------------------------------------------
/lib/bagisto/queries/product.ts:
--------------------------------------------------------------------------------
1 | import productFragment from '../fragments/product';
2 |
3 | export const getProductsQuery = /* GraphQL */ `
4 | query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
5 | products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
6 | edges {
7 | node {
8 | ...product
9 | }
10 | }
11 | }
12 | }
13 | ${productFragment}
14 | `;
15 |
--------------------------------------------------------------------------------
/lib/bagisto/queries/shipping-method.ts:
--------------------------------------------------------------------------------
1 | export const getShippingMethodQuery = /* GraphQL */ `
2 | query shippingMethods {
3 | shippingMethods {
4 | message
5 | shippingMethods {
6 | title
7 | methods {
8 | code
9 | label
10 | price
11 | formattedPrice
12 | }
13 | }
14 | }
15 | }
16 | `;
17 |
--------------------------------------------------------------------------------
/lib/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Sorting & filtration constants
3 | */
4 | export type SortFilterItem = {
5 | key: string;
6 | title: string;
7 | slug: string | null;
8 | sortKey: 'name' | 'created_at' | 'price' | 'PRICE';
9 | reverse: boolean;
10 | position: string;
11 | };
12 |
13 | export const defaultSort: SortFilterItem = {
14 | key: '0',
15 | title: 'From A-Z',
16 | slug: 'name-asc',
17 | sortKey: 'name',
18 | reverse: false, //asc
19 | position: '1'
20 | };
21 |
22 | export const sorting: SortFilterItem[] = [
23 | defaultSort,
24 | {
25 | key: '1',
26 | title: 'From Z-A',
27 | slug: 'name-desc',
28 | sortKey: 'name',
29 | reverse: true, //'desc',
30 | position: '2'
31 | },
32 | {
33 | key: '2',
34 | title: 'Newest First',
35 | slug: 'created_at-desc',
36 | sortKey: 'created_at',
37 | reverse: true, //'desc',
38 | position: '3'
39 | },
40 | {
41 | key: '3',
42 | title: 'Oldest First',
43 | slug: 'created_at-asc',
44 | sortKey: 'created_at',
45 | reverse: false, // 'asc',
46 | position: '4'
47 | },
48 | {
49 | key: '4',
50 | title: 'Cheapest First',
51 | slug: 'price-asc',
52 | sortKey: 'price',
53 | reverse: false, //'asc',
54 | position: '5'
55 | },
56 | {
57 | key: '5',
58 | title: 'Expensive First',
59 | slug: 'price-desc',
60 | sortKey: 'price',
61 | reverse: true, //'desc',
62 | position: '6'
63 | }
64 | ];
65 |
66 | export const TAGS = {
67 | collections: 'collections',
68 | products: 'products',
69 | cart: 'cart'
70 | };
71 |
72 | export const FILTER_ATTRIBUTE = {
73 | sorting: 'sortings'
74 | };
75 | export const CHECKOUT = {
76 | shipping: 'collections',
77 | method: 'products',
78 | cart: 'cart'
79 | };
80 | export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
81 | export const DEFAULT_OPTION = 'Default Title';
82 | export const BAGISTO_GRAPHQL_API_ENDPOINT = '/graphql';
83 | export const ORDER_ID = 'order-id';
84 | /**
85 | * local storage constant keys
86 | */
87 | export const SAVED_LOCAL_STORAGE = 'shippingAddress';
88 | export const REVIEW_ORDER = 'reviewOrder';
89 | export const CHECKOUT_DATA = 'checkOutData';
90 | /**
91 | productJsonLd constant
92 | **/
93 | export const BASE_SCHEMA_URL = 'https://schema.org';
94 | export const PRODUCT_TYPE = 'Product';
95 | export const PRODUCT_OFFER_TYPE = 'AggregateOffer';
96 |
97 | /**
98 | * cookies constant
99 | */
100 |
101 | export const BAGISTO_SESSION = 'bagisto_session';
102 | export const TOKEN = 'token';
103 | // next.js-frontend.vercel.app
104 | export const BASE_URL = 'https://nextjs-frontend.vercel.app';
105 |
--------------------------------------------------------------------------------
/lib/type-guards.ts:
--------------------------------------------------------------------------------
1 | export interface BagistoErrorLike {
2 | status: number;
3 | message: Error;
4 | cause?: Error;
5 | }
6 |
7 | export const isObject = (object: unknown): object is Record => {
8 | return typeof object === 'object' && object !== null && !Array.isArray(object);
9 | };
10 |
11 | export const isArray = (arr: any) => {
12 | return arr && Array.isArray(arr) && arr.length > 0;
13 | };
14 |
15 | export const isBagistoError = (error: unknown): error is BagistoErrorLike => {
16 | if (!isObject(error)) return false;
17 |
18 | if (error instanceof Error) return true;
19 |
20 | return findError(error);
21 | };
22 |
23 | function findError(error: T): boolean {
24 | if (Object.prototype.toString.call(error) === '[object Error]') {
25 | return true;
26 | }
27 |
28 | const prototype = Object.getPrototypeOf(error) as T | null;
29 |
30 | return prototype === null ? false : findError(prototype);
31 | }
32 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { ReadonlyURLSearchParams } from 'next/navigation';
2 | import { isArray, isObject } from './type-guards';
3 | import { CHECKOUT_DATA } from './constants';
4 | export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
5 | const paramsString = params.toString();
6 | const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
7 |
8 | return `${pathname}${queryString}`;
9 | };
10 |
11 | export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
12 | stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
13 |
14 | export const validateEnvironmentVariables = () => {
15 | const requiredEnvironmentVariables = ['BAGISTO_STORE_DOMAIN'];
16 | const missingEnvironmentVariables = [] as string[];
17 |
18 | requiredEnvironmentVariables.forEach((envVar) => {
19 | if (!process.env[envVar]) {
20 | missingEnvironmentVariables.push(envVar);
21 | }
22 | });
23 |
24 | if (missingEnvironmentVariables.length) {
25 | throw new Error(
26 | `The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/BAGISTO#configure-environment-variables\n\n${missingEnvironmentVariables.join(
27 | '\n'
28 | )}\n`
29 | );
30 | }
31 |
32 | if (
33 | process.env.BAGISTO_STORE_DOMAIN?.includes('[') ||
34 | process.env.BAGISTO_STORE_DOMAIN?.includes(']')
35 | ) {
36 | throw new Error(
37 | 'Your `BAGISTO_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.'
38 | );
39 | }
40 | };
41 |
42 | export const setLocalStorage = (key: string, data: any) => {
43 | if (typeof window !== 'undefined') {
44 | if (isArray(data) || isObject(data)) {
45 | data = JSON.stringify(data);
46 | }
47 | if (typeof data === 'string') {
48 | localStorage.setItem(key, data);
49 | }
50 | }
51 | };
52 |
53 | export const getLocalStorage = (key: string | any, needParsedData = false) => {
54 | if (typeof window !== 'undefined') {
55 | const data = localStorage.getItem(key);
56 | if (!data || typeof data === 'undefined') return null;
57 | if (needParsedData) return JSON.parse(data);
58 | return data;
59 | }
60 | };
61 |
62 | export const createCheckoutProcess = (responseValues: object) => {
63 | const getShippingAddress = getLocalStorage(CHECKOUT_DATA, true);
64 | if (localStorage.getItem(CHECKOUT_DATA) === null) {
65 | setLocalStorage(CHECKOUT_DATA, { ...responseValues });
66 | }
67 | if (isObject(getShippingAddress) && localStorage.getItem(CHECKOUT_DATA) !== null) {
68 | setLocalStorage(CHECKOUT_DATA, { ...getShippingAddress, ...responseValues });
69 | }
70 | };
71 |
72 | /**
73 | * Remove data from local storage
74 | *
75 | * @param {string} storageKey - Key for the storage
76 | * @returns void
77 | */
78 | export const removeFromLocalStorage = (storageKey: string | any) => {
79 | if (typeof window !== 'undefined') {
80 | localStorage.removeItem(storageKey);
81 | }
82 | };
83 |
84 | /**
85 | * Get base url
86 | * @returns string
87 | */
88 | export const getBaseUrl = (baseUrl: string | any) => {
89 | return baseUrl ? `https://${baseUrl}` : 'http://localhost:3000';
90 | };
91 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2023 Vercel, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { withAuth } from 'next-auth/middleware';
2 | import { NextResponse } from 'next/server';
3 | const checkAuthPages = (pathName: string) => {
4 | return (
5 | pathName.endsWith('/login') ||
6 | pathName.endsWith('/forget-password') ||
7 | pathName.endsWith('/register')
8 | );
9 | };
10 |
11 | export const config = {
12 | matcher: ['/customer/:path*']
13 | };
14 |
15 | export default withAuth(
16 | async function middleware(req) {
17 | const pathName = req.nextUrl.pathname;
18 | const url = req.nextUrl.clone();
19 | const token = req.nextauth.token;
20 | if (token && checkAuthPages(pathName)) {
21 | url.pathname = '/';
22 | return NextResponse.redirect(url);
23 | }
24 | return NextResponse.next();
25 | },
26 | {
27 | callbacks: {
28 | authorized: async () => {
29 | return true;
30 | }
31 | }
32 | }
33 | );
34 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: 'https',
7 | hostname: 'nextjsv2.bagisto.com'
8 | }
9 | ]
10 | },
11 | env: {
12 | NEXTAUTH_SECRET: '/lLj/OWKqymAisCWbatVdCaovgIOvQeFNaQEtZTSR1Q='
13 | }
14 | };
15 |
16 | module.exports = nextConfig;
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "packageManager": "pnpm@9.0.5",
4 | "version": "2.1.0",
5 | "engines": {
6 | "node": ">=18",
7 | "pnpm": ">=8"
8 | },
9 | "scripts": {
10 | "dev": "next dev",
11 | "build": "next build",
12 | "start": "next start",
13 | "lint": "next lint",
14 | "lint-staged": "lint-staged",
15 | "prettier": "prettier --write --ignore-unknown .",
16 | "prettier:check": "prettier --check --ignore-unknown .",
17 | "test": "pnpm lint && pnpm prettier:check"
18 | },
19 | "git": {
20 | "pre-commit": "lint-staged"
21 | },
22 | "lint-staged": {
23 | "*": "prettier --write --ignore-unknown"
24 | },
25 | "dependencies": {
26 | "@headlessui/react": "^1.7.17",
27 | "@heroicons/react": "^2.0.18",
28 | "@heroui/react": "^2.7.2",
29 | "clsx": "^2.0.0",
30 | "framer-motion": "^11.0.14",
31 | "geist": "^1.0.0",
32 | "next": "14.0.0",
33 | "next-auth": "^4.24.7",
34 | "react": "18.2.0",
35 | "react-dom": "18.2.0",
36 | "zod": "^3.22.4"
37 | },
38 | "devDependencies": {
39 | "@tailwindcss/container-queries": "^0.1.1",
40 | "@tailwindcss/typography": "^0.5.10",
41 | "@types/node": "20.8.9",
42 | "@types/react": "18.2.33",
43 | "@types/react-dom": "18.2.14",
44 | "@vercel/git-hooks": "^1.0.0",
45 | "autoprefixer": "^10.4.16",
46 | "eslint": "^8.52.0",
47 | "eslint-config-next": "^14.0.0",
48 | "eslint-config-prettier": "^9.0.0",
49 | "eslint-plugin-unicorn": "^48.0.1",
50 | "lint-staged": "^15.0.2",
51 | "postcss": "^8.4.31",
52 | "prettier": "3.0.3",
53 | "prettier-plugin-tailwindcss": "^0.5.6",
54 | "tailwindcss": "^3.3.5",
55 | "typescript": "5.2.2"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {}
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('prettier').Config} */
2 | module.exports = {
3 | singleQuote: true,
4 | arrowParens: 'always',
5 | trailingComma: 'none',
6 | printWidth: 100,
7 | tabWidth: 2,
8 | plugins: ['prettier-plugin-tailwindcss']
9 | };
10 |
--------------------------------------------------------------------------------
/public/image/placeholder.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bagisto/nextjs-commerce/d3b59621fc5027898416cf2add9b4d65a67eecc0/public/image/placeholder.webp
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const plugin = require('tailwindcss/plugin');
2 | const { heroui } = require('@heroui/react');
3 |
4 | /** @type {import('tailwindcss').Config} */
5 | module.exports = {
6 | content: [
7 | './app/**/*.{js,ts,jsx,tsx}',
8 | './components/**/*.{js,ts,jsx,tsx}',
9 | './node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'
10 | ],
11 | theme: {
12 | container: {
13 | center: true
14 | },
15 | extend: {
16 | fontFamily: {
17 | sans: ['var(--font-geist-sans)']
18 | },
19 | keyframes: {
20 | fadeIn: {
21 | from: { opacity: 0 },
22 | to: { opacity: 1 }
23 | },
24 | marquee: {
25 | '0%': { transform: 'translateX(0%)' },
26 | '100%': { transform: 'translateX(-100%)' }
27 | },
28 | blink: {
29 | '0%': { opacity: 0.2 },
30 | '20%': { opacity: 1 },
31 | '100% ': { opacity: 0.2 }
32 | }
33 | },
34 | animation: {
35 | fadeIn: 'fadeIn .3s ease-in-out',
36 | carousel: 'marquee 60s linear infinite',
37 | blink: 'blink 1.4s both infinite'
38 | }
39 | }
40 | },
41 | future: {
42 | hoverOnlyWhenSupported: true
43 | },
44 | // darkMode: "dark",
45 | plugins: [
46 | require('@tailwindcss/container-queries'),
47 | require('@tailwindcss/typography'),
48 | plugin(({ matchUtilities, theme }) => {
49 | matchUtilities(
50 | {
51 | 'animation-delay': (value) => {
52 | return {
53 | 'animation-delay': value
54 | };
55 | }
56 | },
57 | {
58 | values: theme('transitionDelay')
59 | }
60 | );
61 | }),
62 | heroui()
63 | ]
64 | };
65 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": ".",
18 | "noUncheckedIndexedAccess": true,
19 | "plugins": [
20 | {
21 | "name": "next"
22 | }
23 | ]
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/types/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | import 'next-auth';
2 |
3 | declare module 'next-auth' {
4 | interface User {
5 | id: string;
6 | address: string;
7 | name?: string;
8 | accessToken: string;
9 | email: string;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------