From 24f66b6b82ca3b5ecb94025aa12b18025e85f390 Mon Sep 17 00:00:00 2001 From: Chris Nanninga Date: Fri, 2 Feb 2024 13:55:20 -0600 Subject: [PATCH] Catalyst ae71f24 --- .../(default)/(faceted)/brand/[slug]/page.tsx | 15 ++ .../(faceted)/category/[slug]/page.tsx | 15 ++ .../app/(default)/(faceted)/search/page.tsx | 4 + .../app/(default)/(webpages)/[page]/page.tsx | 14 +- .../core/app/(default)/blog/[blogId]/page.tsx | 17 ++- apps/core/app/(default)/blog/page.tsx | 11 ++ apps/core/app/(default)/cart/page.tsx | 4 + apps/core/app/(default)/compare/page.tsx | 4 + apps/core/app/(default)/login/page.tsx | 4 + apps/core/app/error.tsx | 4 + apps/core/app/layout.tsx | 27 ++-- apps/core/app/maintenance/page.tsx | 9 +- apps/core/app/not-found.tsx | 4 + apps/core/components/BlogPostCard/index.tsx | 12 +- apps/core/components/Forms/ContactUs.tsx | 130 ++++++++++++++---- .../Forms/_actions/submitContactForm.ts | 48 ++++--- apps/core/components/Header/index.tsx | 22 ++- apps/core/components/ProductCard/index.tsx | 2 +- apps/core/components/ProductForm/index.tsx | 2 +- apps/core/components/SharingLinks/index.tsx | 6 + apps/core/package.json | 2 + .../reactant/src/components/Select/Select.tsx | 4 +- .../reactant/src/components/Sheet/Sheet.tsx | 4 +- pnpm-lock.yaml | 36 +++++ 24 files changed, 316 insertions(+), 84 deletions(-) diff --git a/apps/core/app/(default)/(faceted)/brand/[slug]/page.tsx b/apps/core/app/(default)/(faceted)/brand/[slug]/page.tsx index 075057f7..6a398eb6 100644 --- a/apps/core/app/(default)/(faceted)/brand/[slug]/page.tsx +++ b/apps/core/app/(default)/(faceted)/brand/[slug]/page.tsx @@ -1,4 +1,5 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'; +import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { getBrand } from '~/client/queries/getBrand'; @@ -17,6 +18,20 @@ interface Props { searchParams: { [key: string]: string | string[] | undefined }; } +export async function generateMetadata({ params }: Props): Promise { + const brandId = Number(params.slug); + + const brand = await getBrand({ + brandId, + }); + + const title = brand?.name; + + return { + title, + }; +} + export default async function Brand({ params, searchParams }: Props) { const brandId = Number(params.slug); diff --git a/apps/core/app/(default)/(faceted)/category/[slug]/page.tsx b/apps/core/app/(default)/(faceted)/category/[slug]/page.tsx index 2d6497c7..89d08657 100644 --- a/apps/core/app/(default)/(faceted)/category/[slug]/page.tsx +++ b/apps/core/app/(default)/(faceted)/category/[slug]/page.tsx @@ -1,4 +1,5 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'; +import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { getCategory } from '~/client/queries/getCategory'; @@ -19,6 +20,20 @@ interface Props { searchParams: { [key: string]: string | string[] | undefined }; } +export async function generateMetadata({ params }: Props): Promise { + const categoryId = Number(params.slug); + + const category = await getCategory({ + categoryId, + }); + + const title = category?.name; + + return { + title, + }; +} + export default async function Category({ params, searchParams }: Props) { const categoryId = Number(params.slug); const search = await fetchFacetedSearch({ ...searchParams, category: [params.slug] }); diff --git a/apps/core/app/(default)/(faceted)/search/page.tsx b/apps/core/app/(default)/(faceted)/search/page.tsx index 7d181fc6..dc2f6014 100644 --- a/apps/core/app/(default)/(faceted)/search/page.tsx +++ b/apps/core/app/(default)/(faceted)/search/page.tsx @@ -13,6 +13,10 @@ interface Props { searchParams: { [key: string]: string | string[] | undefined }; } +export const metadata = { + title: 'Search Results', +}; + export default async function Search({ searchParams }: Props) { const searchTerm = typeof searchParams.term === 'string' ? searchParams.term : undefined; diff --git a/apps/core/app/(default)/(webpages)/[page]/page.tsx b/apps/core/app/(default)/(webpages)/[page]/page.tsx index 450d8bd0..187520dd 100644 --- a/apps/core/app/(default)/(webpages)/[page]/page.tsx +++ b/apps/core/app/(default)/(webpages)/[page]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; +import { getReCaptchaSettings } from '~/client/queries/getReCaptchaSettings'; import { getWebPage } from '~/client/queries/getWebPage'; import { ContactUs } from '~/components/Forms'; @@ -35,16 +36,23 @@ export default async function WebPage({ params }: Props) { notFound(); } - const { name, htmlBody, __typename: pageType } = webpage; + const { name, htmlBody, __typename: pageType, entityId } = webpage; switch (pageType) { - case 'ContactPage': + case 'ContactPage': { + const reCaptchaSettings = await getReCaptchaSettings(); + return ( <> - + ); + } case 'NormalPage': default: diff --git a/apps/core/app/(default)/blog/[blogId]/page.tsx b/apps/core/app/(default)/blog/[blogId]/page.tsx index 98b2b207..0e265dcc 100644 --- a/apps/core/app/(default)/blog/[blogId]/page.tsx +++ b/apps/core/app/(default)/blog/[blogId]/page.tsx @@ -6,6 +6,7 @@ import { BlogPostTitle, } from '@bigcommerce/reactant/BlogPostCard'; import { Tag, TagContent } from '@bigcommerce/reactant/Tag'; +import type { Metadata } from 'next'; import Image from 'next/image'; import { notFound } from 'next/navigation'; @@ -19,6 +20,16 @@ interface Props { }; } +export async function generateMetadata({ params: { blogId } }: Props): Promise { + const blogPost = await getBlogPost(+blogId); + + const title = blogPost?.seo.pageTitle ?? 'Blog'; + + return { + title, + }; +} + export default async function BlogPostPage({ params: { blogId } }: Props) { const blogPost = await getBlogPost(+blogId); @@ -63,11 +74,7 @@ export default async function BlogPostPage({ params: { blogId } }: Props) {
{blogPost.tags.map((tag) => ( - + {tag} diff --git a/apps/core/app/(default)/blog/page.tsx b/apps/core/app/(default)/blog/page.tsx index 9d19390e..e2a1c6d7 100644 --- a/apps/core/app/(default)/blog/page.tsx +++ b/apps/core/app/(default)/blog/page.tsx @@ -1,4 +1,5 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'; +import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { getBlogPosts } from '~/client/queries/getBlogPosts'; @@ -9,6 +10,16 @@ interface Props { searchParams: { [key: string]: string | string[] | undefined }; } +export async function generateMetadata({ searchParams }: Props): Promise { + const blogPosts = await getBlogPosts(searchParams); + + const title = blogPosts?.name ?? 'Blog'; + + return { + title, + }; +} + export default async function BlogPostPage({ searchParams }: Props) { const blogPosts = await getBlogPosts(searchParams); diff --git a/apps/core/app/(default)/cart/page.tsx b/apps/core/app/(default)/cart/page.tsx index f1074a2b..e4b6984e 100644 --- a/apps/core/app/(default)/cart/page.tsx +++ b/apps/core/app/(default)/cart/page.tsx @@ -10,6 +10,10 @@ import { getCart } from '~/client/queries/getCart'; import { removeProduct } from './_actions/removeProduct'; import { CartItemCounter } from './CartItemCounter'; +export const metadata = { + title: 'Cart', +}; + const EmptyCart = () => (

Your cart

diff --git a/apps/core/app/(default)/compare/page.tsx b/apps/core/app/(default)/compare/page.tsx index 89a40ee2..f56407ba 100644 --- a/apps/core/app/(default)/compare/page.tsx +++ b/apps/core/app/(default)/compare/page.tsx @@ -13,6 +13,10 @@ import { AddToCartForm } from './AddToCartForm'; const MAX_COMPARE_LIMIT = 10; +export const metadata = { + title: 'Compare', +}; + const CompareParamsSchema = z.object({ ids: z .union([z.string(), z.array(z.string()), z.undefined()]) diff --git a/apps/core/app/(default)/login/page.tsx b/apps/core/app/(default)/login/page.tsx index df39ef41..5a61b9a8 100644 --- a/apps/core/app/(default)/login/page.tsx +++ b/apps/core/app/(default)/login/page.tsx @@ -4,6 +4,10 @@ import { Link } from '~/components/Link'; import { LoginForm } from './LoginForm'; +export const metadata = { + title: 'Login', +}; + export default function Login() { return (
diff --git a/apps/core/app/error.tsx b/apps/core/app/error.tsx index 469cf487..01fa7efd 100644 --- a/apps/core/app/error.tsx +++ b/apps/core/app/error.tsx @@ -1,5 +1,9 @@ 'use client'; +export const metadata = { + title: 'Error', +}; + export default function Error() { return (
diff --git a/apps/core/app/layout.tsx b/apps/core/app/layout.tsx index 39d3d406..05609f2d 100644 --- a/apps/core/app/layout.tsx +++ b/apps/core/app/layout.tsx @@ -1,10 +1,13 @@ import { Analytics } from '@vercel/analytics/react'; import { SpeedInsights } from '@vercel/speed-insights/next'; +import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import { PropsWithChildren } from 'react'; import './globals.css'; +import { getStoreSettings } from '~/client/queries/getStoreSettings'; + import { Notifications } from './notifications'; import { Providers } from './providers'; @@ -14,14 +17,22 @@ const inter = Inter({ variable: '--font-inter', }); -export const metadata = { - title: 'Catalyst Store', - description: 'Example store built with Catalyst', - other: { - platform: 'bigcommerce.catalyst', - build_sha: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, - }, -}; +export async function generateMetadata(): Promise { + const storeSettings = await getStoreSettings(); + const title = storeSettings?.storeName ?? 'Catalyst Store'; + + return { + title: { + template: `${title} - %s`, + default: `${title}`, + }, + description: 'Example store built with Catalyst', + other: { + platform: 'bigcommerce.catalyst', + build_sha: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA ?? '', + }, + }; +} export const fetchCache = 'default-cache'; diff --git a/apps/core/app/maintenance/page.tsx b/apps/core/app/maintenance/page.tsx index 6d797d7f..fc570ca2 100644 --- a/apps/core/app/maintenance/page.tsx +++ b/apps/core/app/maintenance/page.tsx @@ -8,6 +8,10 @@ const Container = ({ children }: { children: ReactNode }) => (
{children}
); +export const metadata = { + title: 'Maintenance', +}; + export default async function MaintenancePage() { const storeSettings = await getStoreSettings(); @@ -35,7 +39,10 @@ export default async function MaintenancePage() {

diff --git a/apps/core/app/not-found.tsx b/apps/core/app/not-found.tsx index 46e3d768..a7e03c43 100644 --- a/apps/core/app/not-found.tsx +++ b/apps/core/app/not-found.tsx @@ -8,6 +8,10 @@ import { Header } from '~/components/Header'; import { CartLink } from '~/components/Header/cart'; import { ProductCard } from '~/components/ProductCard'; +export const metadata = { + title: 'Not Found', +}; + export default async function NotFound() { const featuredProducts = await getFeaturedProducts({ imageHeight: 500, imageWidth: 500 }); diff --git a/apps/core/components/BlogPostCard/index.tsx b/apps/core/components/BlogPostCard/index.tsx index 3cac5c2e..bc28bb1e 100644 --- a/apps/core/components/BlogPostCard/index.tsx +++ b/apps/core/components/BlogPostCard/index.tsx @@ -21,10 +21,7 @@ export const BlogPostCard = ({ blogPost }: BlogPostCardProps) => ( {blogPost.thumbnailImage ? ( - + {blogPost.thumbnailImage.altText} ( )} - - {blogPost.name} - + {blogPost.name} {blogPost.plainTextSummary} diff --git a/apps/core/components/Forms/ContactUs.tsx b/apps/core/components/Forms/ContactUs.tsx index e87579b7..80c7a563 100644 --- a/apps/core/components/Forms/ContactUs.tsx +++ b/apps/core/components/Forms/ContactUs.tsx @@ -11,11 +11,29 @@ import { Input } from '@bigcommerce/reactant/Input'; import { Message } from '@bigcommerce/reactant/Message'; import { TextArea } from '@bigcommerce/reactant/TextArea'; import { Loader2 as Spinner } from 'lucide-react'; -import { ChangeEvent, useState } from 'react'; +import { ChangeEvent, useRef, useState } from 'react'; import { useFormStatus } from 'react-dom'; +// eslint-disable-next-line import/no-named-as-default +import ReCAPTCHA from 'react-google-recaptcha'; import { submitContactForm } from './_actions/submitContactForm'; +interface FormStatus { + status: 'success' | 'error'; + message: string; +} + +interface ContactUsProps { + fields: string[]; + pageEntityId: number; + reCaptchaSettings?: { + siteKey: string; + }; +} + +// TODO: replace mocked var when enabled field will be added to GraphQL api +const IS_RECAPTCHA_ENABLED = true; + const fieldNameMapping = { fullname: 'Full name', companyname: 'Company name', @@ -26,18 +44,75 @@ const fieldNameMapping = { type Field = keyof typeof fieldNameMapping; -export const ContactUs = ({ fields }: { fields: string[] }) => { +const Submit = () => { const { pending } = useFormStatus(); - const [isMessageVisible, showMessage] = useState(false); + + return ( + + + + ); +}; + +export const ContactUs = ({ fields, pageEntityId, reCaptchaSettings }: ContactUsProps) => { + const form = useRef(null); + const [formStatus, setFormStatus] = useState(null); const [isTextFieldValid, setTextFieldValidation] = useState(true); const [isInputValid, setInputValidation] = useState(true); + const reCaptchaRef = useRef(null); + const [reCaptchaToken, setReCaptchaToken] = useState(''); + const [isReCaptchaValid, setReCaptchaValid] = useState(true); + + const onReCatpchaChange = (token: string | null) => { + if (!token) { + return setReCaptchaValid(false); + } + + setReCaptchaToken(token); + setReCaptchaValid(true); + }; + const onSubmit = async (formData: FormData) => { - const { status } = await submitContactForm(formData); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (IS_RECAPTCHA_ENABLED && !reCaptchaToken) { + return setReCaptchaValid(false); + } + + setReCaptchaValid(true); + + const submit = await submitContactForm({ formData, pageEntityId, reCaptchaToken }); - if (status === 'success') { - showMessage(true); + if (submit.status === 'success') { + form.current?.reset(); + setFormStatus({ + status: 'success', + message: "Thanks for reaching out. We'll get back to you soon.", + }); } + + if (submit.status === 'failed') { + setFormStatus({ status: 'error', message: submit.error ?? '' }); + } + + reCaptchaRef.current?.reset(); }; + const handleTextFieldValidation = (e: ChangeEvent) => { setTextFieldValidation(!e.target.validity.valueMissing); }; @@ -50,16 +125,15 @@ export const ContactUs = ({ fields }: { fields: string[] }) => { return ( <> - {isMessageVisible && ( - -

- Thanks for reaching out. We'll get back to you soon. Keep shopping -

+ {formStatus && ( + +

{formStatus.message}

)}
<> {fields @@ -128,22 +202,24 @@ export const ContactUs = ({ fields }: { fields: string[] }) => { - - - + { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + IS_RECAPTCHA_ENABLED && ( + + + {!isReCaptchaValid && ( + + Pass ReCAPTCHA check + + )} + + ) + } + ); diff --git a/apps/core/components/Forms/_actions/submitContactForm.ts b/apps/core/components/Forms/_actions/submitContactForm.ts index 7ea18f46..a8024818 100644 --- a/apps/core/components/Forms/_actions/submitContactForm.ts +++ b/apps/core/components/Forms/_actions/submitContactForm.ts @@ -1,39 +1,53 @@ -/* eslint-disable @typescript-eslint/require-await */ - 'use server'; import { z } from 'zod'; -const ContactUsSchema = z.object({ - companyName: z.string().optional(), - fullName: z.string().optional(), - phone: z.string().optional(), - orderNumber: z.string().optional(), - rmaNumber: z.string().optional(), - email: z.string().email(), - comments: z.string().trim().nonempty(), -}); - -export const submitContactForm = async (formData: FormData) => { +import { + ContactUsSchema, + submitContactForm as submitContactFormClient, +} from '~/client/mutations/submitContactForm'; + +interface SubmitContactForm { + formData: FormData; + pageEntityId: number; + reCaptchaToken: string; +} + +export const submitContactForm = async ({ + formData, + pageEntityId, + reCaptchaToken, +}: SubmitContactForm) => { try { const parsedData = ContactUsSchema.parse({ email: formData.get('email'), comments: formData.get('comments'), companyName: formData.get('Company name'), fullName: formData.get('Full name'), - phone: formData.get('Phone'), + phoneNumber: formData.get('Phone'), orderNumber: formData.get('Order number'), rmaNumber: formData.get('RMA number'), }); - // TODO: Add graphql mutation on submit + const response = await submitContactFormClient({ + formFields: parsedData, + pageEntityId, + reCaptchaToken, + }); + + if (response.submitContactUs.errors.length === 0) { + return { status: 'success', data: parsedData }; + } - return { status: 'success', data: parsedData }; + return { + status: 'failed', + error: response.submitContactUs.errors.map((error) => error.message).join('\n'), + }; } catch (e: unknown) { if (e instanceof Error || e instanceof z.ZodError) { return { status: 'failed', error: e.message }; } - return { status: 'failed' }; + return { status: 'failed', error: 'Unknown error' }; } }; diff --git a/apps/core/components/Header/index.tsx b/apps/core/components/Header/index.tsx index 9a4e1eb8..7eaf7d10 100644 --- a/apps/core/components/Header/index.tsx +++ b/apps/core/components/Header/index.tsx @@ -9,8 +9,8 @@ import { NavigationMenuToggle, NavigationMenuTrigger, } from '@bigcommerce/reactant/NavigationMenu'; -import { ChevronDown, LogOut, User } from 'lucide-react'; -import { ReactNode } from 'react'; +import { ChevronDown, LogOut, ShoppingCart, User } from 'lucide-react'; +import { ReactNode, Suspense } from 'react'; import { getSessionCustomerId } from '~/auth'; import { getCategoryTree } from '~/client/queries/getCategoryTree'; @@ -21,6 +21,7 @@ import { QuickSearch } from '../QuickSearch'; import { StoreLogo } from '../StoreLogo'; import { logout } from './_actions/logout'; +import { CartLink } from './cart'; const HeaderNav = async ({ className, @@ -124,10 +125,7 @@ export const Header = async ({ cart }: { cart: ReactNode }) => { - + @@ -152,7 +150,17 @@ export const Header = async ({ cart }: { cart: ReactNode }) => { )} -

{cart}

+

+ + + + } + > + {cart} + +

diff --git a/apps/core/components/ProductCard/index.tsx b/apps/core/components/ProductCard/index.tsx index a7b8ba62..d90cb44d 100644 --- a/apps/core/components/ProductCard/index.tsx +++ b/apps/core/components/ProductCard/index.tsx @@ -116,7 +116,7 @@ export const ProductCard = ({ {product.path ? (