@@ -68,7 +66,9 @@ export default async function NotFound() {
/>
-
+
+
+
>
);
}
diff --git a/core/app/[locale]/store-selector/page.tsx b/core/app/[locale]/store-selector/page.tsx
index c7aed281c8..ee24778e56 100644
--- a/core/app/[locale]/store-selector/page.tsx
+++ b/core/app/[locale]/store-selector/page.tsx
@@ -1,4 +1,4 @@
-import { getTranslations, unstable_setRequestLocale } from 'next-intl/server';
+import { getTranslations, setRequestLocale } from 'next-intl/server';
import { client } from '~/client';
import { graphql } from '~/client/graphql';
@@ -35,7 +35,7 @@ interface Props {
}
export default async function StoreSelector({ params: { locale: selectedLocale } }: Props) {
- unstable_setRequestLocale(selectedLocale);
+ setRequestLocale(selectedLocale);
const t = await getTranslations('StoreSelector');
diff --git a/core/app/admin/route.ts b/core/app/admin/route.ts
index 457ae77a4d..ce1cd21f74 100644
--- a/core/app/admin/route.ts
+++ b/core/app/admin/route.ts
@@ -1,4 +1,4 @@
-import { redirect } from '~/i18n/routing';
+import { defaultLocale, redirect } from '~/i18n/routing';
const canonicalDomain: string = process.env.BIGCOMMERCE_GRAPHQL_API_DOMAIN ?? 'mybigcommerce.com';
const BIGCOMMERCE_STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
@@ -7,14 +7,15 @@ const ENABLE_ADMIN_ROUTE = process.env.ENABLE_ADMIN_ROUTE;
export const GET = () => {
// This route should not work unless explicitly enabled
if (ENABLE_ADMIN_ROUTE !== 'true') {
- return redirect('/');
+ return redirect({ href: '/', locale: defaultLocale });
}
- return redirect(
- BIGCOMMERCE_STORE_HASH
+ return redirect({
+ href: BIGCOMMERCE_STORE_HASH
? `https://store-${BIGCOMMERCE_STORE_HASH}.${canonicalDomain}/admin`
: 'https://login.bigcommerce.com',
- );
+ locale: defaultLocale,
+ });
};
export const runtime = 'edge';
diff --git a/core/app/providers.tsx b/core/app/providers.tsx
index 94e21c9597..e8490a82b5 100644
--- a/core/app/providers.tsx
+++ b/core/app/providers.tsx
@@ -2,14 +2,17 @@
import { PropsWithChildren } from 'react';
+import { CartProvider } from '~/components/header/cart-provider';
import { CompareDrawerProvider } from '~/components/ui/compare-drawer';
import { AccountStatusProvider } from './[locale]/(default)/account/(tabs)/_components/account-status-provider';
export function Providers({ children }: PropsWithChildren) {
return (
-
- {children}
-
+
+
+ {children}
+
+
);
}
diff --git a/core/app/robots.ts b/core/app/robots.ts
deleted file mode 100644
index f845232529..0000000000
--- a/core/app/robots.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { MetadataRoute } from 'next';
-
-function parseUrl(url?: string): URL {
- let incomingUrl = '';
- const defaultUrl = new URL('http://localhost:3000/');
-
- if (url && !url.startsWith('http')) {
- incomingUrl = `https://${url}`;
- }
-
- return new URL(incomingUrl || defaultUrl);
-}
-
-// Disallow routes that have no SEO value or should not be indexed
-const disallow = ['/cart', '/account'];
-
-// Robots.txt config https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots#generate-a-robots-file
-export default function robots(): MetadataRoute.Robots {
- const baseUrl = parseUrl(
- process.env.NEXTAUTH_URL || process.env.VERCEL_PROJECT_PRODUCTION_URL || '',
- );
-
- return {
- // Infer base URL from environment variables
- sitemap: `${baseUrl.origin}/sitemap.xml`,
- rules: [
- {
- userAgent: '*',
- allow: ['/'],
- disallow,
- },
- ],
- };
-}
diff --git a/core/app/robots.txt/route.ts b/core/app/robots.txt/route.ts
new file mode 100644
index 0000000000..d32dc5eb34
--- /dev/null
+++ b/core/app/robots.txt/route.ts
@@ -0,0 +1,59 @@
+/* eslint-disable check-file/folder-naming-convention */
+/*
+ * Robots.txt route
+ *
+ * This route pulls robots.txt content from the channel settings.
+ *
+ * If you would like to configure this in code instead, delete this file and follow this guide:
+ *
+ * https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots
+ *
+ */
+
+import { getChannelIdFromLocale } from '~/channels.config';
+import { client } from '~/client';
+import { graphql } from '~/client/graphql';
+import { defaultLocale } from '~/i18n/routing';
+
+const RobotsTxtQuery = graphql(`
+ query RobotsTxt {
+ site {
+ settings {
+ robotsTxt
+ }
+ }
+ }
+`);
+
+function parseUrl(url?: string): URL {
+ let incomingUrl = '';
+ const defaultUrl = new URL('http://localhost:3000/');
+
+ if (url && !url.startsWith('http')) {
+ incomingUrl = `https://${url}`;
+ }
+
+ return new URL(incomingUrl || defaultUrl);
+}
+
+const baseUrl = parseUrl(
+ process.env.NEXTAUTH_URL || process.env.VERCEL_PROJECT_PRODUCTION_URL || '',
+);
+
+export const GET = async () => {
+ const { data } = await client.fetch({
+ document: RobotsTxtQuery,
+ channelId: getChannelIdFromLocale(defaultLocale),
+ fetchOptions: { cache: 'no-store' }, // disable caching to get the latest robots.txt at build time
+ });
+
+ const robotsTxt = `${data.site.settings?.robotsTxt ?? ''}\nSitemap: ${baseUrl.origin}/sitemap.xml\n`;
+
+ return new Response(robotsTxt, {
+ headers: {
+ 'Content-Type': 'text/plain; charset=UTF-8',
+ },
+ });
+};
+
+export const dynamic = 'force-static';
diff --git a/core/app/sitemap.xml/route.ts b/core/app/sitemap.xml/route.ts
index 585430ba3d..f5cb52809d 100644
--- a/core/app/sitemap.xml/route.ts
+++ b/core/app/sitemap.xml/route.ts
@@ -3,10 +3,12 @@
* Proxy to the existing BigCommerce sitemap index on the canonical URL
*/
+import { getChannelIdFromLocale } from '~/channels.config';
import { client } from '~/client';
+import { defaultLocale } from '~/i18n/routing';
export const GET = async () => {
- const sitemapIndex = await client.fetchSitemapIndex();
+ const sitemapIndex = await client.fetchSitemapIndex(getChannelIdFromLocale(defaultLocale));
return new Response(sitemapIndex, {
headers: {
diff --git a/core/app/xmlsitemap.php/route.ts b/core/app/xmlsitemap.php/route.ts
index 42036d2b09..6d848b4afa 100644
--- a/core/app/xmlsitemap.php/route.ts
+++ b/core/app/xmlsitemap.php/route.ts
@@ -1,5 +1,5 @@
/* eslint-disable check-file/folder-naming-convention */
-import { permanentRedirect } from '~/i18n/routing';
+import { defaultLocale, permanentRedirect } from '~/i18n/routing';
/*
* This route is used to redirect the legacy Stencil sitemap that lives on /xmlsitemap.php
@@ -8,6 +8,8 @@ import { permanentRedirect } from '~/i18n/routing';
* on /xmlsitemap.php
*/
-export const GET = () => permanentRedirect('/sitemap.xml');
+export const GET = () => {
+ permanentRedirect({ href: '/sitemap.xml', locale: defaultLocale });
+};
export const runtime = 'edge';
diff --git a/core/auth.ts b/core/auth.ts
index 42e53fdd0c..09b4d5abaa 100644
--- a/core/auth.ts
+++ b/core/auth.ts
@@ -90,7 +90,9 @@ const config = {
},
},
customerId: user.id,
- fetchOptions: { cache: 'no-store' },
+ fetchOptions: {
+ cache: 'no-store',
+ },
});
} catch (error) {
// eslint-disable-next-line no-console
@@ -113,7 +115,9 @@ const config = {
},
},
customerId,
- fetchOptions: { cache: 'no-store' },
+ fetchOptions: {
+ cache: 'no-store',
+ },
});
} catch (error) {
// eslint-disable-next-line no-console
@@ -134,6 +138,9 @@ const config = {
const response = await client.fetch({
document: LoginMutation,
variables: { email, password },
+ fetchOptions: {
+ cache: 'no-store',
+ },
});
const result = response.data.login;
diff --git a/core/client/index.ts b/core/client/index.ts
index a1b8d1bb77..21e6d28aa5 100644
--- a/core/client/index.ts
+++ b/core/client/index.ts
@@ -1,4 +1,5 @@
import { createClient } from '@bigcommerce/catalyst-client';
+import { headers } from 'next/headers';
import { getLocale } from 'next-intl/server';
import { getChannelIdFromLocale } from '~/channels.config';
@@ -22,7 +23,7 @@ export const client = createClient({
* - Requests in middlewares
* - Requests in `generateStaticParams`
* - Request in api routes
- * - Requests in static sites without `unstable_setRequestLocale`
+ * - Requests in static sites without `setRequestLocale`
*
* We use the default channelId as a fallback, but it is not ideal in some scenarios.
* */
@@ -37,4 +38,18 @@ export const client = createClient({
return defaultChannelId;
}
},
+ beforeRequest: (fetchOptions) => {
+ if (fetchOptions?.cache && ['no-store', 'no-cache'].includes(fetchOptions.cache)) {
+ const ipAddress = headers().get('X-Forwarded-For');
+
+ if (ipAddress) {
+ return {
+ headers: {
+ 'X-Forwarded-For': ipAddress,
+ 'True-Client-IP': ipAddress,
+ },
+ };
+ }
+ }
+ },
});
diff --git a/core/components/blog-post-card/fragment.ts b/core/components/blog-post-card/fragment.ts
index 7e6a498432..ffd7c2de5f 100644
--- a/core/components/blog-post-card/fragment.ts
+++ b/core/components/blog-post-card/fragment.ts
@@ -10,7 +10,7 @@ export const BlogPostCardFragment = graphql(`
utc
}
thumbnailImage {
- url: urlTemplate
+ url: urlTemplate(lossy: true)
altText
}
}
diff --git a/core/components/breadcrumbs/index.tsx b/core/components/breadcrumbs/index.tsx
index ba6e57e673..6fa5339259 100644
--- a/core/components/breadcrumbs/index.tsx
+++ b/core/components/breadcrumbs/index.tsx
@@ -16,5 +16,9 @@ export const Breadcrumbs = ({ category }: Props) => {
href: path ?? '#',
}));
+ if (breadcrumbs.length < 2) {
+ return null;
+ }
+
return
;
};
diff --git a/core/components/footer/footer.tsx b/core/components/footer/footer.tsx
index 97558f63a8..baf9cf215b 100644
--- a/core/components/footer/footer.tsx
+++ b/core/components/footer/footer.tsx
@@ -9,7 +9,11 @@ import {
} from '@icons-pack/react-simple-icons';
import { JSX } from 'react';
-import { FragmentOf } from '~/client/graphql';
+import { LayoutQuery } from '~/app/[locale]/(default)/query';
+import { getSessionCustomerId } from '~/auth';
+import { client } from '~/client';
+import { readFragment } from '~/client/graphql';
+import { revalidate } from '~/client/revalidate-target';
import { Footer as ComponentsFooter } from '~/components/ui/footer';
import { logoTransformer } from '~/data-transformers/logo-transformer';
@@ -31,11 +35,16 @@ const socialIcons: Record
= {
YouTube: { icon: },
};
-interface Props {
- data: FragmentOf;
-}
+export const Footer = async () => {
+ const customerId = await getSessionCustomerId();
+
+ const { data: response } = await client.fetch({
+ document: LayoutQuery,
+ fetchOptions: customerId ? { cache: 'no-store' } : { next: { revalidate } },
+ });
+
+ const data = readFragment(FooterFragment, response).site;
-export const Footer = ({ data }: Props) => {
const sections = [
{
title: 'Categories',
diff --git a/core/components/footer/fragment.ts b/core/components/footer/fragment.ts
index 9dfc4db725..ef2e17c803 100644
--- a/core/components/footer/fragment.ts
+++ b/core/components/footer/fragment.ts
@@ -19,7 +19,7 @@ export const FooterFragment = graphql(`
}
... on StoreImageLogo {
image {
- url: urlTemplate
+ url: urlTemplate(lossy: true)
altText
}
}
diff --git a/core/components/form-fields/shared/field-wrapper.tsx b/core/components/form-fields/shared/field-wrapper.tsx
index cb85c47959..93e5b06ad3 100644
--- a/core/components/form-fields/shared/field-wrapper.tsx
+++ b/core/components/form-fields/shared/field-wrapper.tsx
@@ -2,14 +2,11 @@ import { PropsWithChildren } from 'react';
import { FieldNameToFieldId } from '../utils';
-const LAYOUT_SINGLE_LINE_FIELDS = [
- FieldNameToFieldId.email,
- FieldNameToFieldId.company,
- FieldNameToFieldId.phone,
-];
+const LAYOUT_HALF_OF_SINGLE_LINE_FIELDS = [FieldNameToFieldId.company, FieldNameToFieldId.phone];
+const LAYOUT_SINGLE_LINE_FIELDS = [FieldNameToFieldId.email];
export const FieldWrapper = ({ children, fieldId }: { fieldId: number } & PropsWithChildren) => {
- if (LAYOUT_SINGLE_LINE_FIELDS.includes(fieldId)) {
+ if (LAYOUT_HALF_OF_SINGLE_LINE_FIELDS.includes(fieldId)) {
return (
{children}
@@ -17,5 +14,9 @@ export const FieldWrapper = ({ children, fieldId }: { fieldId: number } & PropsW
);
}
+ if (LAYOUT_SINGLE_LINE_FIELDS.includes(fieldId)) {
+ return
{children}
;
+ }
+
return children;
};
diff --git a/core/components/form-fields/shared/parse-fields.ts b/core/components/form-fields/shared/parse-fields.ts
index 5e8c84a0a7..bd3434ddba 100644
--- a/core/components/form-fields/shared/parse-fields.ts
+++ b/core/components/form-fields/shared/parse-fields.ts
@@ -19,7 +19,6 @@ type FormFieldsType = VariablesOf
['input']['formFields'];
interface ReturnedFormData {
[k: string]: unknown;
- address: Record;
formFields: Record;
}
@@ -225,24 +224,7 @@ export const parseRegisterCustomerFormData = (registerFormData: FormData): unkno
});
}
- if (sections.includes('address')) {
- parsedData.address[key] = value;
- }
-
- if (sections.some((section) => section.startsWith('custom_address'))) {
- const fields = updateFormFields({
- formFields: isFormFieldsType(parsedData.address.formFields)
- ? parsedData.address.formFields
- : null,
- fieldType: sections[1] ?? '',
- fieldEntityId: Number(key),
- fieldValue: value,
- });
-
- parsedData.address = { ...parsedData.address, formFields: { ...fields } };
- }
-
return parsedData;
},
- { formFields: {}, address: {} },
+ { formFields: {} },
);
diff --git a/core/components/form-fields/utils.ts b/core/components/form-fields/utils.ts
index e86468e636..ecca3d400c 100644
--- a/core/components/form-fields/utils.ts
+++ b/core/components/form-fields/utils.ts
@@ -47,6 +47,8 @@ export const BOTH_CUSTOMER_ADDRESS_FIELDS = [
FieldNameToFieldId.phone,
];
+export const FULL_NAME_FIELDS = [FieldNameToFieldId.firstName, FieldNameToFieldId.lastName];
+
export const createFieldName = (
field: FragmentOf,
fieldOrigin: 'customer' | 'address',
diff --git a/core/components/header/_actions/logout.ts b/core/components/header/_actions/logout.ts
index 5e5bae528f..4100ce5e0a 100644
--- a/core/components/header/_actions/logout.ts
+++ b/core/components/header/_actions/logout.ts
@@ -1,10 +1,14 @@
'use server';
+import { getLocale } from 'next-intl/server';
+
import { signOut } from '~/auth';
import { redirect } from '~/i18n/routing';
export const logout = async () => {
+ const locale = await getLocale();
+
await signOut({ redirect: false });
- redirect('/login');
+ redirect({ href: '/login', locale });
};
diff --git a/core/components/header/cart-icon.tsx b/core/components/header/cart-icon.tsx
index 135ae5c520..de1fca09d1 100644
--- a/core/components/header/cart-icon.tsx
+++ b/core/components/header/cart-icon.tsx
@@ -2,11 +2,13 @@
import { ShoppingCart } from 'lucide-react';
import { useLocale } from 'next-intl';
-import { useEffect, useState } from 'react';
+import { useEffect } from 'react';
import { z } from 'zod';
import { Badge } from '~/components/ui/badge';
+import { useCart } from './cart-provider';
+
const CartQuantityResponseSchema = z.object({
count: z.number(),
});
@@ -15,9 +17,8 @@ interface CartIconProps {
count?: number;
}
-export const CartIcon = ({ count }: CartIconProps) => {
- const [fetchedCount, setFetchedCount] = useState();
- const computedCount = count ?? fetchedCount;
+export const CartIcon = ({ count: serverCount }: CartIconProps) => {
+ const { count, setCount } = useCart();
const locale = useLocale();
useEffect(() => {
@@ -25,18 +26,20 @@ export const CartIcon = ({ count }: CartIconProps) => {
const response = await fetch(`/api/cart-quantity/?locale=${locale}`);
const parsedData = CartQuantityResponseSchema.parse(await response.json());
- setFetchedCount(parsedData.count);
+ setCount(parsedData.count);
}
- // When a page is rendered statically via the 'force-static' route config option, cookies().get() always returns undefined,
- // which ultimately means that the `count` prop here will always be undefined on initial render, even if there actually is
- // a populated cart. Thus, we perform a client-side check in this case.
- if (count === undefined) {
+ if (serverCount !== undefined) {
+ setCount(serverCount);
+ } else {
+ // When a page is rendered statically via the 'force-static' route config option, cookies().get() always returns undefined,
+ // which ultimately means that the `serverCount` here will always be undefined on initial render, even if there actually is
+ // a populated cart. Thus, we perform a client-side check in this case.
void fetchCartQuantity();
}
- }, [count, locale]);
+ }, [serverCount, locale, setCount]);
- if (!computedCount) {
+ if (!count) {
return ;
}
@@ -44,7 +47,7 @@ export const CartIcon = ({ count }: CartIconProps) => {
<>
Cart Items
- {computedCount}
+ {count}
>
);
};
diff --git a/core/components/header/cart-provider.tsx b/core/components/header/cart-provider.tsx
new file mode 100644
index 0000000000..a9f9b049c5
--- /dev/null
+++ b/core/components/header/cart-provider.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
+
+interface CartContext {
+ count: number;
+ increment: (step?: number) => void;
+ decrement: (step?: number) => void;
+ setCount: (newCount: number) => void;
+}
+
+const CartContext = createContext(undefined);
+
+export const CartProvider = ({ children }: { children: ReactNode }) => {
+ const [count, setCount] = useState(0);
+ const increment = useCallback((step = 1) => setCount((prev) => prev + step), []);
+ const decrement = useCallback((step = 1) => setCount((prev) => prev - step), []);
+
+ const value = useMemo(
+ () => ({ count, increment, decrement, setCount }),
+ [count, increment, decrement],
+ );
+
+ return {children};
+};
+
+export const useCart = () => {
+ const context = useContext(CartContext);
+
+ if (context === undefined) {
+ throw new Error('useCart must be used within a CartProvider');
+ }
+
+ return context;
+};
diff --git a/core/components/header/fragment.ts b/core/components/header/fragment.ts
index b1d0a00784..df9fe9ec0e 100644
--- a/core/components/header/fragment.ts
+++ b/core/components/header/fragment.ts
@@ -11,7 +11,7 @@ export const HeaderFragment = graphql(`
}
... on StoreImageLogo {
image {
- url: urlTemplate
+ url: urlTemplate(lossy: true)
altText
}
}
diff --git a/core/components/header/index.tsx b/core/components/header/index.tsx
index 237f07dcfe..8f24088e41 100644
--- a/core/components/header/index.tsx
+++ b/core/components/header/index.tsx
@@ -2,8 +2,11 @@ import { ShoppingCart, User } from 'lucide-react';
import { getLocale, getTranslations } from 'next-intl/server';
import { ReactNode, Suspense } from 'react';
+import { LayoutQuery } from '~/app/[locale]/(default)/query';
import { getSessionCustomerId } from '~/auth';
-import { FragmentOf } from '~/client/graphql';
+import { client } from '~/client';
+import { readFragment } from '~/client/graphql';
+import { revalidate } from '~/client/revalidate-target';
import { logoTransformer } from '~/data-transformers/logo-transformer';
import { localeLanguageRegionMap } from '~/i18n/routing';
@@ -19,15 +22,20 @@ import { QuickSearch } from './quick-search';
interface Props {
cart: ReactNode;
- data: FragmentOf;
}
-export const Header = async ({ cart, data }: Props) => {
+export const Header = async ({ cart }: Props) => {
const locale = await getLocale();
const t = await getTranslations('Components.Header');
-
const customerId = await getSessionCustomerId();
+ const { data: response } = await client.fetch({
+ document: LayoutQuery,
+ fetchOptions: customerId ? { cache: 'no-store' } : { next: { revalidate } },
+ });
+
+ const data = readFragment(HeaderFragment, response).site;
+
/** To prevent the navigation menu from overflowing, we limit the number of categories to 6.
To show a full list of categories, modify the `slice` method to remove the limit.
Will require modification of navigation menu styles to accommodate the additional categories.
@@ -97,3 +105,27 @@ export const Header = async ({ cart, data }: Props) => {
/>
);
};
+
+export const HeaderSkeleton = () => (
+
+);
diff --git a/core/components/product-card/add-to-cart/form/index.tsx b/core/components/product-card/add-to-cart/form/index.tsx
index aa6fc38ba6..7433031a39 100644
--- a/core/components/product-card/add-to-cart/form/index.tsx
+++ b/core/components/product-card/add-to-cart/form/index.tsx
@@ -3,10 +3,11 @@
import { FragmentOf } from 'gql.tada';
import { AlertCircle, Check } from 'lucide-react';
import { useTranslations } from 'next-intl';
-import { useFormStatus } from 'react-dom';
+import { useTransition } from 'react';
import { toast } from 'react-hot-toast';
import { AddToCartButton } from '~/components/add-to-cart-button';
+import { useCart } from '~/components/header/cart-provider';
import { Link } from '~/components/link';
import { AddToCartFragment } from '../fragment';
@@ -17,56 +18,60 @@ interface Props {
data: FragmentOf;
}
-const SubmitButton = ({ data: product }: Props) => {
- const { pending } = useFormStatus();
-
- return ;
-};
-
export const Form = ({ data: product }: Props) => {
const t = useTranslations('Components.ProductCard.AddToCart');
+ const cart = useCart();
+ const [isPending, startTransition] = useTransition();
- return (
-