From 62168e0b101b11b90c3cbfc7e54732d9e7d964f9 Mon Sep 17 00:00:00 2001 From: Nate Stewart Date: Mon, 21 Oct 2024 06:05:15 +0000 Subject: [PATCH 1/2] Add gift certificates page with purchase, check, and redeem tabs --- .../(default)/cart/_components/cart-item.tsx | 85 +++++- .../cart/_components/remove-item.tsx | 52 ++++ core/app/[locale]/(default)/cart/page.tsx | 6 +- .../_actions/add-to-cart.tsx | 99 +++++++ .../_actions/lookup-balance.tsx | 55 ++++ .../_components/balance-checker.tsx | 69 +++++ .../_components/gift-certificate-tabs.tsx | 43 +++ .../_components/purchase-form.tsx | 252 ++++++++++++++++++ .../_components/redeem-details.tsx | 20 ++ .../create-cart-with-gift-certificate.tsx | 37 +++ .../(default)/gift-certificates/page.tsx | 24 ++ core/components/ui/tabs/tabs.tsx | 2 +- core/messages/en.json | 52 ++++ 13 files changed, 791 insertions(+), 5 deletions(-) create mode 100644 core/app/[locale]/(default)/gift-certificates/_actions/add-to-cart.tsx create mode 100644 core/app/[locale]/(default)/gift-certificates/_actions/lookup-balance.tsx create mode 100644 core/app/[locale]/(default)/gift-certificates/_components/balance-checker.tsx create mode 100644 core/app/[locale]/(default)/gift-certificates/_components/gift-certificate-tabs.tsx create mode 100644 core/app/[locale]/(default)/gift-certificates/_components/purchase-form.tsx create mode 100644 core/app/[locale]/(default)/gift-certificates/_components/redeem-details.tsx create mode 100644 core/app/[locale]/(default)/gift-certificates/_mutations/create-cart-with-gift-certificate.tsx create mode 100644 core/app/[locale]/(default)/gift-certificates/page.tsx diff --git a/core/app/[locale]/(default)/cart/_components/cart-item.tsx b/core/app/[locale]/(default)/cart/_components/cart-item.tsx index e853a233ee..5cf04a6389 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 { RemoveItem, RemoveGiftCertificate } 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; @@ -235,7 +261,7 @@ export const CartItem = ({ currencyCode, product }: Props) => {
{product.originalPrice.value && - product.originalPrice.value !== product.listPrice.value ? ( + product.originalPrice.value !== product.listPrice.value ? (

{format.number(product.originalPrice.value * product.quantity, { style: 'currency', @@ -263,3 +289,56 @@ 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..c6d667e23a 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,9 @@ 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..cca872150e --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/_actions/add-to-cart.tsx @@ -0,0 +1,99 @@ +'use server'; + +import { revalidateTag } from 'next/cache'; +import { cookies } from 'next/headers'; +import { getFormatter, getTranslations } from 'next-intl/server'; + +import { addCartLineItem } from '~/client/mutations/add-cart-line-item'; +import { createCartWithGiftCertificate } from '../_mutations/create-cart-with-gift-certificate'; +import { getCart } from '~/client/queries/get-cart'; +import { TAGS } from '~/client/tags'; + +const GIFT_CERTIFICATE_THEMES = ['GENERAL', 'BIRTHDAY', 'BOY', 'CELEBRATION', 'CHRISTMAS', 'GIRL', 'NONE']; +type giftCertificateTheme = "GENERAL" | "BIRTHDAY" | "BOY" | "CELEBRATION" | "CHRISTMAS" | "GIRL" | "NONE"; + +export const addGiftCertificateToCart = async (data: FormData) => { + const format = await getFormatter(); + const t = await getTranslations('GiftCertificate.Actions.AddToCart'); + + let theme = String(data.get('theme')) as giftCertificateTheme; + const amount = Number(data.get('amount')); + const senderEmail = String(data.get('senderEmail')); + const senderName = String(data.get('senderName')); + const recipientEmail = String(data.get('recipientEmail')); + const recipientName = String(data.get('recipientName')); + const message = data.get('message') ? String(data.get('message')) : null; + + if (!GIFT_CERTIFICATE_THEMES.includes(theme)) { + theme = 'GENERAL' + } + + const giftCertificate = { + name: t('certificateName', { + amount: format.number(amount, { + style: 'currency', + currency: 'USD', // TODO: Determine this from the selected currency + }) + }), + theme, + amount, + "quantity": 1, + "sender": { + "email": senderEmail, + "name": senderName, + }, + "recipient": { + "email": recipientEmail, + "name": recipientName, + }, + message, + } + + const cartId = cookies().get('cartId')?.value; + let cart; + + try { + cart = await getCart(cartId); + + if (cart) { + cart = await addCartLineItem(cart.entityId, { + giftCertificates: [ + giftCertificate + ], + }); + + if (!cart?.entityId) { + return { status: 'error', error: t('error') }; + } + + revalidateTag(TAGS.cart); + + return { status: 'success', data: cart }; + } + + cart = await createCartWithGiftCertificate([giftCertificate]); + + if (!cart?.entityId) { + return { status: 'error', error: t('error') }; + } + + cookies().set({ + name: 'cartId', + value: cart.entityId, + httpOnly: true, + sameSite: 'lax', + secure: true, + path: '/', + }); + + revalidateTag(TAGS.cart); + + return { status: 'success', data: cart }; + } catch (error: unknown) { + if (error instanceof Error) { + return { status: 'error', error: error.message }; + } + + return { 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..e4421d5f2b --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/_actions/lookup-balance.tsx @@ -0,0 +1,55 @@ +'use server' + +import { getTranslations } from 'next-intl/server'; + +export async function lookupGiftCertificateBalance(code: string) { + 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: headers + }) + + if (response.status === 404 || response.status === 204) { + return { error: t('notFound') } + } + + if (!response.ok) { + console.error(`v2 Gift Certificate API responded with status ${response.status}: ${response.statusText}`) + return { error: t('error') } + } + + const data = await response.json() + + if (Array.isArray(data) && data.length > 0 && typeof data[0].balance !== 'undefined') { + // There isn't a way to query the exact code in the v2 Gift Certificate API, + // so we'll loop through the results to make sure it's not a partial match + for (const certificate of data) { + if (certificate.code === code) { + return { balance: parseFloat(data[0].balance), currencyCode: data[0].currency_code } + } + } + + // No exact match, so consider it not found + return { error: t('notFound') } + } else { + console.error('Unexpected v2 Gift Certificate API response structure') + return { error: t('error') } + } + } catch (error) { + console.error('Error checking gift certificate balance:', error) + return { error: t('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..0d82e1108f --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/_components/balance-checker.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useState } from 'react' +import { useFormatter, useTranslations } from 'next-intl'; +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') as string + 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..c2d86239bf --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/_components/gift-certificate-tabs.tsx @@ -0,0 +1,43 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl'; +import { Tabs } from '~/components/ui/tabs'; +import GiftCertificateBalanceClient from './balance-checker'; +import RedeemGiftCertificateDetails from './redeem-details'; +import GiftCertificatePurchaseForm from './purchase-form'; +import { lookupGiftCertificateBalance } from '../_actions/lookup-balance'; + +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..0e4d22207c --- /dev/null +++ b/core/app/[locale]/(default)/gift-certificates/_components/purchase-form.tsx @@ -0,0 +1,252 @@ +'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, + TextArea, + Select, +} 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.00, + 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; +} + +interface FieldValidation { + [key: string]: boolean; +} + +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')} + +