-
{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')}
- )
+ );
}
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('amountLabel')}
+
+ {t('amountLabel')}
+
- {t('senderEmailLabel')}
+
+ {t('senderEmailLabel')}
+
- {t('senderNameLabel')}
+
+ {t('senderNameLabel')}
+
- {t('recipientEmailLabel')}
+
+ {t('recipientEmailLabel')}
+
- {t('recipientNameLabel')}
+
+ {t('recipientNameLabel')}
+
-
+
{t('messageLabel')}
-
+
diff --git a/core/app/[locale]/(default)/gift-certificates/_components/redeem-details.tsx b/core/app/[locale]/(default)/gift-certificates/_components/redeem-details.tsx
index e61619551e..661510c7ab 100644
--- a/core/app/[locale]/(default)/gift-certificates/_components/redeem-details.tsx
+++ b/core/app/[locale]/(default)/gift-certificates/_components/redeem-details.tsx
@@ -1,4 +1,4 @@
-'use client'
+'use client';
import { useTranslations } from 'next-intl';
@@ -6,15 +6,15 @@ export default function RedeemGiftCertificateDetails() {
const t = useTranslations('GiftCertificate.Redeem');
return (
-
-
Redeem Gift Certificate
-
{t('instructionIntro')}
-
+
+
Redeem Gift Certificate
+
{t('instructionIntro')}
+
{t('instructionOne') ? - {t('instructionOne')}
: ''}
{t('instructionTwo') ? - {t('instructionTwo')}
: ''}
{t('instructionThree') ? - {t('instructionThree')}
: ''}
{t('instructionFour') ? - {t('instructionFour')}
: ''}
- )
+ );
}
diff --git a/core/app/[locale]/(default)/gift-certificates/_mutations/create-cart-with-gift-certificate.tsx b/core/app/[locale]/(default)/gift-certificates/_mutations/create-cart-with-gift-certificate.tsx
index 4659048f74..7168638cc3 100644
--- a/core/app/[locale]/(default)/gift-certificates/_mutations/create-cart-with-gift-certificate.tsx
+++ b/core/app/[locale]/(default)/gift-certificates/_mutations/create-cart-with-gift-certificate.tsx
@@ -1,5 +1,4 @@
import { getSessionCustomerId } from '~/auth';
-
import { client } from '~/client';
import { graphql, VariablesOf } from '~/client/graphql';
@@ -26,7 +25,7 @@ export const createCartWithGiftCertificate = async (giftCertificates: GiftCertif
document: CreateCartMutation,
variables: {
createCartInput: {
- giftCertificates: giftCertificates
+ giftCertificates,
},
},
customerId,
diff --git a/core/app/[locale]/(default)/gift-certificates/page.tsx b/core/app/[locale]/(default)/gift-certificates/page.tsx
index 1805f4a159..3287eb68f5 100644
--- a/core/app/[locale]/(default)/gift-certificates/page.tsx
+++ b/core/app/[locale]/(default)/gift-certificates/page.tsx
@@ -1,5 +1,6 @@
import { getTranslations } from 'next-intl/server';
-import GiftCertificateTabs from './_components/gift-certificate-tabs'
+
+import GiftCertificateTabs from './_components/gift-certificate-tabs';
export async function generateMetadata() {
const t = await getTranslations('GiftCertificate');
@@ -9,9 +10,7 @@ export async function generateMetadata() {
};
}
-export default async function GiftCertificateBalancePage() {
- const t = await getTranslations('GiftCertificate');
-
+export default function GiftCertificateBalancePage() {
return (