diff --git a/core/app/[locale]/(default)/cart/_components/cart-item.tsx b/core/app/[locale]/(default)/cart/_components/cart-item.tsx index e853a233ee..bd7062cb64 100644 --- a/core/app/[locale]/(default)/cart/_components/cart-item.tsx +++ b/core/app/[locale]/(default)/cart/_components/cart-item.tsx @@ -4,7 +4,7 @@ import { FragmentOf, graphql } from '~/client/graphql'; import { BcImage } from '~/components/bc-image'; import { ItemQuantity } from './item-quantity'; -import { RemoveItem } from './remove-item'; +import { RemoveGiftCertificate, RemoveItem } from './remove-item'; const PhysicalItemFragment = graphql(` fragment PhysicalItemFragment on CartPhysicalItem { @@ -122,6 +122,28 @@ const DigitalItemFragment = graphql(` } `); +const GiftCertificateItemFragment = graphql(` + fragment GiftCertificateItemFragment on CartGiftCertificate { + entityId + name + theme + amount { + currencyCode + value + } + isTaxable + sender { + email + name + } + recipient { + email + name + } + message + } +`); + export const CartItemFragment = graphql( ` fragment CartItemFragment on CartLineItems { @@ -131,14 +153,18 @@ export const CartItemFragment = graphql( digitalItems { ...DigitalItemFragment } + giftCertificates { + ...GiftCertificateItemFragment + } } `, - [PhysicalItemFragment, DigitalItemFragment], + [PhysicalItemFragment, DigitalItemFragment, GiftCertificateItemFragment], ); type FragmentResult = FragmentOf; type PhysicalItem = FragmentResult['physicalItems'][number]; type DigitalItem = FragmentResult['digitalItems'][number]; +type GiftCertificateItem = FragmentResult['giftCertificates'][number]; export type Product = PhysicalItem | DigitalItem; @@ -263,3 +289,63 @@ export const CartItem = ({ currencyCode, product }: Props) => { ); }; + +interface GiftCertificateProps { + giftCertificate: GiftCertificateItem; + currencyCode: string; +} + +export const CartGiftCertificate = ({ currencyCode, giftCertificate }: GiftCertificateProps) => { + const format = useFormatter(); + + return ( +
  • +
    +
    +

    {giftCertificate.theme}

    +
    + +
    +
    +
    +

    + {format.number(giftCertificate.amount.value, { + style: 'currency', + currency: currencyCode, + })}{' '} + Gift Certificate +

    + +

    {giftCertificate.message}

    +

    + To: {giftCertificate.recipient.name} ({giftCertificate.recipient.email}) +

    +

    + From: {giftCertificate.sender.name} ({giftCertificate.sender.email}) +

    + +
    + +
    +
    + +
    +
    +

    + {format.number(giftCertificate.amount.value, { + style: 'currency', + currency: currencyCode, + })} +

    +
    +
    +
    + +
    + +
    +
    +
    +
  • + ); +}; diff --git a/core/app/[locale]/(default)/cart/_components/remove-item.tsx b/core/app/[locale]/(default)/cart/_components/remove-item.tsx index d0fc01baf1..0239351086 100644 --- a/core/app/[locale]/(default)/cart/_components/remove-item.tsx +++ b/core/app/[locale]/(default)/cart/_components/remove-item.tsx @@ -15,6 +15,7 @@ import { RemoveFromCartButton } from './remove-from-cart-button'; type FragmentResult = FragmentOf; type PhysicalItem = FragmentResult['physicalItems'][number]; type DigitalItem = FragmentResult['digitalItems'][number]; +type GiftCertificate = FragmentResult['giftCertificates'][number]; export type Product = PhysicalItem | DigitalItem; @@ -68,3 +69,54 @@ export const RemoveItem = ({ currency, product }: Props) => { ); }; + +interface GiftCertificateProps { + currency: string; + giftCertificate: GiftCertificate; +} + +const giftCertificateTransform = (item: GiftCertificate) => { + return { + product_id: item.entityId.toString(), + product_name: `${item.theme} Gift Certificate`, + brand_name: undefined, + sku: undefined, + sale_price: undefined, + purchase_price: item.amount.value, + base_price: undefined, + retail_price: undefined, + currency: item.amount.currencyCode, + variant_id: undefined, + quantity: 1, + }; +}; + +export const RemoveGiftCertificate = ({ currency, giftCertificate }: GiftCertificateProps) => { + const t = useTranslations('Cart.SubmitRemoveItem'); + + const onSubmitRemoveItem = async () => { + const { status } = await removeItem({ + lineItemEntityId: giftCertificate.entityId, + }); + + if (status === 'error') { + toast.error(t('errorMessage'), { + icon: , + }); + + return; + } + + bodl.cart.productRemoved({ + currency, + product_value: giftCertificate.amount.value, + line_items: [giftCertificateTransform(giftCertificate)], + }); + }; + + return ( +
    + + + ); +}; diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index 8a25d5a004..1a6b12074b 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -6,7 +6,7 @@ import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { TAGS } from '~/client/tags'; -import { CartItem, CartItemFragment } from './_components/cart-item'; +import { CartGiftCertificate, CartItem, CartItemFragment } from './_components/cart-item'; import { CartViewed } from './_components/cart-viewed'; import { CheckoutButton } from './_components/checkout-button'; import { CheckoutSummary, CheckoutSummaryFragment } from './_components/checkout-summary'; @@ -76,6 +76,7 @@ export default async function Cart() { } const lineItems = [...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems]; + const giftCertificates = [...cart.lineItems.giftCertificates]; return (
    @@ -85,6 +86,13 @@ export default async function Cart() { {lineItems.map((product) => ( ))} + {giftCertificates.map((giftCertificate) => ( + + ))}
    diff --git a/core/app/[locale]/(default)/gift-certificates/_actions/add-to-cart.tsx b/core/app/[locale]/(default)/gift-certificates/_actions/add-to-cart.tsx new file mode 100644 index 0000000000..0c8ceb5466 --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/_actions/add-to-cart.tsx @@ -0,0 +1,173 @@ +'use server'; + +import { revalidateTag } from 'next/cache'; +import { cookies } from 'next/headers'; +import { getFormatter, getTranslations } from 'next-intl/server'; +import { z } from 'zod'; + +import { addCartLineItem } from '~/client/mutations/add-cart-line-item'; +import { getCart } from '~/client/queries/get-cart'; +import { TAGS } from '~/client/tags'; + +import { createCartWithGiftCertificate } from '../_mutations/create-cart-with-gift-certificate'; + +const giftCertificateThemes = [ + 'GENERAL', + 'BIRTHDAY', + 'BOY', + 'CELEBRATION', + 'CHRISTMAS', + 'GIRL', + 'NONE', +] as const; + +const GiftCertificateThemeSchema = z.enum(giftCertificateThemes); + +const ValidatedFormDataSchema = z.object({ + theme: GiftCertificateThemeSchema, + amount: z.number().positive(), + senderEmail: z.string().email(), + senderName: z.string().min(1), + recipientEmail: z.string().email(), + recipientName: z.string().min(1), + message: z.string().nullable(), +}); + +type ValidatedFormData = z.infer; + +const CartResponseSchema = z.object({ + status: z.enum(['success', 'error']), + data: z.unknown().optional(), + error: z.string().optional(), +}); + +type CartResponse = z.infer; + +function parseFormData(data: FormData): ValidatedFormData { + const theme = data.get('theme'); + const amount = data.get('amount'); + const senderEmail = data.get('senderEmail'); + const senderName = data.get('senderName'); + const recipientEmail = data.get('recipientEmail'); + const recipientName = data.get('recipientName'); + const message = data.get('message'); + + // Parse and validate the form data + const validatedData = ValidatedFormDataSchema.parse({ + theme, + amount: amount ? Number(amount) : undefined, + senderEmail, + senderName, + recipientEmail, + recipientName, + message: message ? String(message) : null, + }); + + return validatedData; +} + +export async function addGiftCertificateToCart(data: FormData): Promise { + const format = await getFormatter(); + const t = await getTranslations('GiftCertificate.Actions.AddToCart'); + + try { + const validatedData = parseFormData(data); + + const giftCertificate = { + name: t('certificateName', { + amount: format.number(validatedData.amount, { + style: 'currency', + currency: 'USD', + }), + }), + theme: validatedData.theme, + amount: validatedData.amount, + quantity: 1, + sender: { + email: validatedData.senderEmail, + name: validatedData.senderName, + }, + recipient: { + email: validatedData.recipientEmail, + name: validatedData.recipientName, + }, + message: validatedData.message, + }; + + const cartId = cookies().get('cartId')?.value; + let cart; + + if (cartId) { + cart = await getCart(cartId); + } + + if (cart) { + cart = await addCartLineItem(cart.entityId, { + giftCertificates: [giftCertificate], + }); + + if (!cart?.entityId) { + return CartResponseSchema.parse({ + status: 'error', + error: t('error'), + }); + } + + revalidateTag(TAGS.cart); + + return CartResponseSchema.parse({ + status: 'success', + data: cart, + }); + } + + cart = await createCartWithGiftCertificate([giftCertificate]); + + if (!cart?.entityId) { + return CartResponseSchema.parse({ + status: 'error', + error: t('error'), + }); + } + + cookies().set({ + name: 'cartId', + value: cart.entityId, + httpOnly: true, + sameSite: 'lax', + secure: true, + path: '/', + }); + + revalidateTag(TAGS.cart); + + return CartResponseSchema.parse({ + status: 'success', + data: cart, + }); + } catch (error: unknown) { + if (error instanceof z.ZodError) { + // Handle validation errors + const errorMessage = error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', '); + + return CartResponseSchema.parse({ + status: 'error', + error: errorMessage, + }); + } + + if (error instanceof Error) { + return CartResponseSchema.parse({ + status: 'error', + error: error.message, + }); + } + + return CartResponseSchema.parse({ + status: 'error', + error: t('error'), + }); + } +} diff --git a/core/app/[locale]/(default)/gift-certificates/_actions/lookup-balance.tsx b/core/app/[locale]/(default)/gift-certificates/_actions/lookup-balance.tsx new file mode 100644 index 0000000000..0995361f38 --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/_actions/lookup-balance.tsx @@ -0,0 +1,75 @@ +'use server'; + +import { getTranslations } from 'next-intl/server'; +import { z } from 'zod'; + +const giftCertificateSchema = z.object({ + code: z.string(), + balance: z.string(), + currency_code: z.string(), +}); + +interface SuccessResponse { + balance: number; + currencyCode: string; +} + +interface ErrorResponse { + error: string; + details?: unknown; +} + +type LookupResponse = SuccessResponse | ErrorResponse; + +export async function lookupGiftCertificateBalance(code: string): Promise { + const t = await getTranslations('GiftCertificate.Actions.Lookup'); + + if (!code) { + return { error: t('noCode') }; + } + + const apiUrl = `https://api.bigcommerce.com/stores/${process.env.BIGCOMMERCE_STORE_HASH}/v2/gift_certificates`; + const headers = { + 'Content-Type': 'application/json', + 'X-Auth-Token': process.env.GIFT_CERTIFICATE_V3_API_TOKEN ?? '', + Accept: 'application/json', + }; + + try { + const response = await fetch(`${apiUrl}?limit=1&code=${encodeURIComponent(code)}`, { + method: 'GET', + headers, + }); + + if (response.status === 404 || response.status === 204) { + return { error: t('notFound') }; + } + + if (!response.ok) { + return { error: t('error') }; + } + + const parseResult = z.array(giftCertificateSchema).safeParse(await response.json()); + + if (!parseResult.success) { + return { error: t('error') }; + } + + const data = parseResult.data; + const certificate = data.find((cert) => cert.code === code); + + if (!certificate) { + return { error: t('notFound') }; + } + + return { + balance: parseFloat(certificate.balance), + currencyCode: certificate.currency_code, + }; + } catch (error) { + return { + error: t('error'), + details: error, + }; + } +} diff --git a/core/app/[locale]/(default)/gift-certificates/_components/balance-checker.tsx b/core/app/[locale]/(default)/gift-certificates/_components/balance-checker.tsx new file mode 100644 index 0000000000..d6737e6b5e --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/_components/balance-checker.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useFormatter, useTranslations } from 'next-intl'; +import { useState } from 'react'; + +import { Button } from '~/components/ui/button'; +import { Input } from '~/components/ui/form'; +import { Message } from '~/components/ui/message'; + +export default function GiftCertificateBalanceClient({ + checkBalanceAction, +}: { + checkBalanceAction: ( + code: string, + ) => Promise<{ balance?: number; currencyCode?: string; error?: string }>; +}) { + const [result, setResult] = useState<{ + balance?: number; + currencyCode?: string; + error?: string; + } | null>(null); + const [isLoading, setIsLoading] = useState(false); + + const t = useTranslations('GiftCertificate.Check'); + const format = useFormatter(); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setIsLoading(true); + + const formData = new FormData(event.currentTarget); + const code = formData.get('code')?.toString() || ''; + const response = await checkBalanceAction(code); + + setResult(response); + setIsLoading(false); + }; + + return ( +
    +

    {t('heading')}

    + +
    +
    + + +
    +
    + + {result && ( +
    + {result.balance !== undefined ? ( + + + {t('balanceResult', { + balance: format.number(result.balance, { + style: 'currency', + currency: result.currencyCode, + }), + })} + + + ) : ( + + {result.error} + + )} +
    + )} +
    + ); +} diff --git a/core/app/[locale]/(default)/gift-certificates/_components/gift-certificate-tabs.tsx b/core/app/[locale]/(default)/gift-certificates/_components/gift-certificate-tabs.tsx new file mode 100644 index 0000000000..50243bb747 --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/_components/gift-certificate-tabs.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; + +import { Tabs } from '~/components/ui/tabs'; + +import { lookupGiftCertificateBalance } from '../_actions/lookup-balance'; + +import GiftCertificateBalanceClient from './balance-checker'; +import GiftCertificatePurchaseForm from './purchase-form'; +import RedeemGiftCertificateDetails from './redeem-details'; + +const defaultTab = 'Purchase Gift Certificate'; + +export default function GiftCertificateTabs() { + const [activeTab, setActiveTab] = useState(defaultTab); + const t = useTranslations('GiftCertificate.Tabs'); + + const tabs = [ + { + value: t('purchase'), + content: , + }, + { + value: t('check'), + content: , + }, + { + value: t('redeem'), + content: , + }, + ]; + + return ( +
    + +
    + ); +} diff --git a/core/app/[locale]/(default)/gift-certificates/_components/purchase-form.tsx b/core/app/[locale]/(default)/gift-certificates/_components/purchase-form.tsx new file mode 100644 index 0000000000..07ab945c31 --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/_components/purchase-form.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useRef, useState } from 'react'; +import { useFormStatus } from 'react-dom'; + +import { Button } from '~/components/ui/button'; +import { + Field, + FieldControl, + FieldLabel, + FieldMessage, + Form, + FormSubmit, + Input, + Select, + TextArea, +} from '~/components/ui/form'; +import { Message } from '~/components/ui/message'; + +import { addGiftCertificateToCart } from '../_actions/add-to-cart'; + +const GIFT_CERTIFICATE_THEMES = [ + 'GENERAL', + 'BIRTHDAY', + 'BOY', + 'CELEBRATION', + 'CHRISTMAS', + 'GIRL', + 'NONE', +]; + +const defaultValues = { + theme: 'GENERAL', + amount: 25.0, + senderName: 'Nate Stewart', + senderEmail: 'nate.stewart@bigcommerce.com', + recipientName: 'Nathan Booker', + recipientEmail: 'nathan.booker@bigcommerce.com', + message: + "Hey, sorry I missed your birthday (again). No one is perfect, although I fully expect you to hold it against me. Anyway, let's get to work 🚀", +}; + +interface FormStatus { + status: 'success' | 'error'; + message: string; +} + +type FieldValidation = Record; + +const Submit = () => { + const { pending } = useFormStatus(); + const t = useTranslations('GiftCertificate.Purchase'); + + return ( + + + + ); +}; + +export default function GiftCertificatePurchaseForm() { + const form = useRef(null); + const [formStatus, setFormStatus] = useState(null); + const [fieldValidation, setFieldValidation] = useState({}); + + const t = useTranslations('GiftCertificate.Purchase'); + + const onSubmit = async (formData: FormData) => { + const response = await addGiftCertificateToCart(formData); + + if (response.status === 'success') { + form.current?.reset(); + setFormStatus({ + status: 'success', + message: t('success'), + }); + } else { + setFormStatus({ status: 'error', message: response.error ?? t('error') }); + } + }; + + const handleInputValidation = (e: React.ChangeEvent) => { + const { name, validity } = e.target; + const isValid = !validity.valueMissing && !validity.typeMismatch; + + setFieldValidation((prev) => ({ + ...prev, + [name]: isValid, + })); + }; + + return ( + <> +
    +

    {t('heading')}

    +
    + {formStatus && ( + +

    {formStatus.message}

    +
    + )} +
    + + + {t('themeLabel')} + + + + + + {t('amountValidationMessage')} + + + + + {t('senderEmailLabel')} + + + + + + {t('emailValidationMessage')} + + + {t('emailValidationMessage')} + + + + + {t('senderNameLabel')} + + + + + + {t('nameValidationMessage')} + + + + + {t('recipientEmailLabel')} + + + + + + {t('emailValidationMessage')} + + + {t('emailValidationMessage')} + + + + + {t('recipientNameLabel')} + + + + + + {t('nameValidationMessage')} + + + + {t('messageLabel')} + +