Skip to content

Commit

Permalink
Add gift certificates page with purchase, check, and redeem tabs
Browse files Browse the repository at this point in the history
  • Loading branch information
becomevocal committed Oct 21, 2024
1 parent 031d44b commit f5cfa2c
Show file tree
Hide file tree
Showing 13 changed files with 791 additions and 5 deletions.
85 changes: 82 additions & 3 deletions core/app/[locale]/(default)/cart/_components/cart-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -131,14 +153,18 @@ export const CartItemFragment = graphql(
digitalItems {
...DigitalItemFragment
}
giftCertificates {
...GiftCertificateItemFragment
}
}
`,
[PhysicalItemFragment, DigitalItemFragment],
[PhysicalItemFragment, DigitalItemFragment, GiftCertificateItemFragment],
);

type FragmentResult = FragmentOf<typeof CartItemFragment>;
type PhysicalItem = FragmentResult['physicalItems'][number];
type DigitalItem = FragmentResult['digitalItems'][number];
type GiftCertificateItem = FragmentResult['giftCertificates'][number];

export type Product = PhysicalItem | DigitalItem;

Expand Down Expand Up @@ -235,7 +261,7 @@ export const CartItem = ({ currencyCode, product }: Props) => {
<div className="flex flex-col gap-2 md:items-end">
<div>
{product.originalPrice.value &&
product.originalPrice.value !== product.listPrice.value ? (
product.originalPrice.value !== product.listPrice.value ? (
<p className="text-lg font-bold line-through">
{format.number(product.originalPrice.value * product.quantity, {
style: 'currency',
Expand Down Expand Up @@ -263,3 +289,56 @@ export const CartItem = ({ currencyCode, product }: Props) => {
</li>
);
};

interface GiftCertificateProps {
giftCertificate: GiftCertificateItem;
currencyCode: string;
}

export const CartGiftCertificate = ({ currencyCode, giftCertificate }: GiftCertificateProps) => {
const format = useFormatter();

return (
<li>
<div className="flex gap-4 border-t border-t-gray-200 py-4 md:flex-row">
<div className="flex justify-center items-center w-24 md:w-[144px]">
<h2 className="text-lg font-bold">{giftCertificate.theme}</h2>
</div>

<div className="flex-1">
<div className="flex flex-col gap-2 md:flex-row">
<div className="flex flex-1 flex-col gap-2">
<p className="text-xl font-bold md:text-2xl">{format.number(giftCertificate.amount.value, {
style: 'currency',
currency: currencyCode,
})} Gift Certificate</p>

<p className="text-md text-gray-500">{giftCertificate.message}</p>
<p className="text-sm text-gray-500">To: {giftCertificate.recipient.name} ({giftCertificate.recipient.email})</p>
<p className="text-sm text-gray-500">From: {giftCertificate.sender.name} ({giftCertificate.sender.email})</p>

<div className="hidden md:block">
<RemoveGiftCertificate currency={currencyCode} giftCertificate={giftCertificate} />
</div>
</div>

<div className="flex flex-col gap-2 md:items-end">
<div>
<p className="text-lg font-bold">
{format.number(giftCertificate.amount.value, {
style: 'currency',
currency: currencyCode,
})}
</p>
</div>
</div>
</div>

<div className="mt-4 md:hidden">
<RemoveGiftCertificate currency={currencyCode} giftCertificate={giftCertificate} />
</div>
</div>
</div>
</li>
);
};
52 changes: 52 additions & 0 deletions core/app/[locale]/(default)/cart/_components/remove-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { RemoveFromCartButton } from './remove-from-cart-button';
type FragmentResult = FragmentOf<typeof CartItemFragment>;
type PhysicalItem = FragmentResult['physicalItems'][number];
type DigitalItem = FragmentResult['digitalItems'][number];
type GiftCertificate = FragmentResult['giftCertificates'][number];

export type Product = PhysicalItem | DigitalItem;

Expand Down Expand Up @@ -68,3 +69,54 @@ export const RemoveItem = ({ currency, product }: Props) => {
</form>
);
};

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: <AlertCircle className="text-error-secondary" />,
});

return;
}

bodl.cart.productRemoved({
currency,
product_value: giftCertificate.amount.value,
line_items: [giftCertificateTransform(giftCertificate)],
});
};

return (
<form action={onSubmitRemoveItem}>
<RemoveFromCartButton />
</form>
);
};
6 changes: 5 additions & 1 deletion core/app/[locale]/(default)/cart/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -76,6 +76,7 @@ export default async function Cart() {
}

const lineItems = [...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems];
const giftCertificates = [...cart.lineItems.giftCertificates];

return (
<div>
Expand All @@ -85,6 +86,9 @@ export default async function Cart() {
{lineItems.map((product) => (
<CartItem currencyCode={cart.currencyCode} key={product.entityId} product={product} />
))}
{giftCertificates.map((giftCertificate) => (
<CartGiftCertificate currencyCode={cart.currencyCode} key={giftCertificate.name} giftCertificate={giftCertificate} />
))}
</ul>

<div className="col-span-1 col-start-2 lg:col-start-3">
Expand Down
Original file line number Diff line number Diff line change
@@ -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') };
}
};
Original file line number Diff line number Diff line change
@@ -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') }
}
}
Loading

0 comments on commit f5cfa2c

Please sign in to comment.