diff --git a/core/app/[locale]/(default)/cart/_components/cart-item.tsx b/core/app/[locale]/(default)/cart/_components/cart-item.tsx index 5cf04a6389..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, RemoveGiftCertificate } from './remove-item'; +import { RemoveGiftCertificate, RemoveItem } from './remove-item'; const PhysicalItemFragment = graphql(` fragment PhysicalItemFragment on CartPhysicalItem { @@ -261,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', @@ -301,21 +301,28 @@ export const CartGiftCertificate = ({ currencyCode, giftCertificate }: GiftCerti return (

  • -
    +

    {giftCertificate.theme}

    -

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

    - +

    + {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})

    +

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

    +

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

    diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index c6d667e23a..1a6b12074b 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -87,7 +87,11 @@ export default async function Cart() { ))} {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 index cca872150e..0c8ceb5466 100644 --- a/core/app/[locale]/(default)/gift-certificates/_actions/add-to-cart.tsx +++ b/core/app/[locale]/(default)/gift-certificates/_actions/add-to-cart.tsx @@ -3,78 +3,131 @@ 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 { 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"; +import { createCartWithGiftCertificate } from '../_mutations/create-cart-with-gift-certificate'; -export const addGiftCertificateToCart = async (data: FormData) => { +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'); - 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); + 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 - ], + giftCertificates: [giftCertificate], }); if (!cart?.entityId) { - return { status: 'error', error: t('error') }; + return CartResponseSchema.parse({ + status: 'error', + error: t('error'), + }); } revalidateTag(TAGS.cart); - return { status: 'success', data: cart }; + return CartResponseSchema.parse({ + status: 'success', + data: cart, + }); } cart = await createCartWithGiftCertificate([giftCertificate]); if (!cart?.entityId) { - return { status: 'error', error: t('error') }; + return CartResponseSchema.parse({ + status: 'error', + error: t('error'), + }); } cookies().set({ @@ -88,12 +141,33 @@ export const addGiftCertificateToCart = async (data: FormData) => { revalidateTag(TAGS.cart); - return { status: 'success', data: 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 { status: 'error', error: error.message }; + return CartResponseSchema.parse({ + status: 'error', + error: error.message, + }); } - return { status: 'error', error: t('error') }; + 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 index e4421d5f2b..0995361f38 100644 --- a/core/app/[locale]/(default)/gift-certificates/_actions/lookup-balance.tsx +++ b/core/app/[locale]/(default)/gift-certificates/_actions/lookup-balance.tsx @@ -1,55 +1,75 @@ -'use server' +'use server'; import { getTranslations } from 'next-intl/server'; +import { z } from 'zod'; -export async function lookupGiftCertificateBalance(code: string) { +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') } + return { error: t('noCode') }; } - const apiUrl = `https://api.bigcommerce.com/stores/${process.env.BIGCOMMERCE_STORE_HASH}/v2/gift_certificates` + 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' - } + Accept: 'application/json', + }; try { const response = await fetch(`${apiUrl}?limit=1&code=${encodeURIComponent(code)}`, { method: 'GET', - headers: headers - }) + headers, + }); if (response.status === 404 || response.status === 204) { - return { error: t('notFound') } + return { error: t('notFound') }; } if (!response.ok) { - console.error(`v2 Gift Certificate API responded with status ${response.status}: ${response.statusText}`) - return { error: t('error') } + return { error: t('error') }; + } + + const parseResult = z.array(giftCertificateSchema).safeParse(await response.json()); + + if (!parseResult.success) { + 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') } + 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) { - console.error('Error checking gift certificate balance:', error) - return { error: t('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 index 0d82e1108f..d6737e6b5e 100644 --- a/core/app/[locale]/(default)/gift-certificates/_components/balance-checker.tsx +++ b/core/app/[locale]/(default)/gift-certificates/_components/balance-checker.tsx @@ -1,46 +1,55 @@ -'use client' +'use client'; -import { useState } from 'react' 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, }: { - checkBalanceAction: (code: string) => Promise<{ balance?: number; currencyCode?: string; error?: string }> + 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 [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) - } + 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')}

    +

    {t('heading')}

    -
    @@ -49,21 +58,23 @@ export default function GiftCertificateBalanceClient({ {result && (
    {result.balance !== undefined ? ( - - {t('balanceResult', { - balance: format.number(result.balance, { - style: 'currency', - currency: result.currencyCode, - }) - })} + + + {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 index c2d86239bf..50243bb747 100644 --- a/core/app/[locale]/(default)/gift-certificates/_components/gift-certificate-tabs.tsx +++ b/core/app/[locale]/(default)/gift-certificates/_components/gift-certificate-tabs.tsx @@ -1,14 +1,17 @@ -'use client' +'use client'; -import { useState } from 'react' 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 RedeemGiftCertificateDetails from './redeem-details'; import GiftCertificatePurchaseForm from './purchase-form'; -import { lookupGiftCertificateBalance } from '../_actions/lookup-balance'; +import RedeemGiftCertificateDetails from './redeem-details'; -const defaultTab = "Purchase Gift Certificate" +const defaultTab = 'Purchase Gift Certificate'; export default function GiftCertificateTabs() { const [activeTab, setActiveTab] = useState(defaultTab); @@ -17,27 +20,28 @@ export default function GiftCertificateTabs() { const tabs = [ { value: t('purchase'), - content: + content: , }, { value: t('check'), - content: + content: , }, { value: t('redeem'), - content: - } + 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 index 0e4d22207c..07ab945c31 100644 --- a/core/app/[locale]/(default)/gift-certificates/_components/purchase-form.tsx +++ b/core/app/[locale]/(default)/gift-certificates/_components/purchase-form.tsx @@ -1,4 +1,4 @@ -'use client' +'use client'; import { useTranslations } from 'next-intl'; import { useRef, useState } from 'react'; @@ -13,33 +13,40 @@ import { Form, FormSubmit, Input, - TextArea, 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 GIFT_CERTIFICATE_THEMES = [ + 'GENERAL', + 'BIRTHDAY', + 'BOY', + 'CELEBRATION', + 'CHRISTMAS', + 'GIRL', + 'NONE', +]; const defaultValues = { - theme: "GENERAL", - amount: 25.00, + 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 🚀', -} + 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; -} +type FieldValidation = Record; const Submit = () => { const { pending } = useFormStatus(); @@ -47,7 +54,7 @@ const Submit = () => { return ( - @@ -79,7 +86,7 @@ export default function GiftCertificatePurchaseForm() { const { name, validity } = e.target; const isValid = !validity.valueMissing && !validity.typeMismatch; - setFieldValidation(prev => ({ + setFieldValidation((prev) => ({ ...prev, [name]: isValid, })); @@ -88,7 +95,7 @@ export default function GiftCertificatePurchaseForm() { return ( <>
    -

    {t('heading')}

    +

    {t('heading')}

    {formStatus && ( @@ -101,32 +108,37 @@ export default function GiftCertificatePurchaseForm() { ref={form} > - {t('themeLabel')} + + {t('themeLabel')} + - {t('senderEmailLabel')} + + {t('senderEmailLabel')} + - {t('senderNameLabel')} + + {t('senderNameLabel')} + - {t('recipientEmailLabel')} + + {t('recipientEmailLabel')} + - {t('recipientNameLabel')} + + {t('recipientNameLabel')} + - + {t('messageLabel')} -