Skip to content

Commit

Permalink
feat(core): Add currency selector to header (#1912)
Browse files Browse the repository at this point in the history
* Add currency selector

Co-Authored-By: Chancellor Clark <[email protected]>

* Abstract cart cookie handling to a common lib

* Change currency of cart

* Only show transactional currencies in switcher (for now)

* Use default currency when there is no preference specified in cookie

* Add cart ID + version to key to invalidate cart on currency change

---------

Co-authored-by: Chancellor Clark <[email protected]>
bookernath and chanceaclark authored Jan 22, 2025

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent ee42e45 commit da2a462
Showing 25 changed files with 341 additions and 49 deletions.
5 changes: 5 additions & 0 deletions .changeset/gold-ducks-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": minor
---

Add currency selector to header
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import { PaginationFragment } from '~/client/fragments/pagination';
import { graphql, VariablesOf } from '~/client/graphql';
import { revalidate } from '~/client/revalidate-target';
import { ProductCardFragment } from '~/components/product-card/fragment';
import { getPreferredCurrencyCode } from '~/lib/currency';

const GetProductSearchResultsQuery = graphql(
`
@@ -18,6 +19,7 @@ const GetProductSearchResultsQuery = graphql(
$before: String
$filters: SearchProductsFiltersInput!
$sort: SearchProductsSortInput
$currencyCode: currencyCode
) {
site {
search {
@@ -168,12 +170,13 @@ interface ProductSearch {
const getProductSearchResults = cache(
async ({ limit = 9, after, before, sort, filters }: ProductSearch) => {
const customerAccessToken = await getSessionCustomerAccessToken();
const currencyCode = await getPreferredCurrencyCode();
const filterArgs = { filters, sort };
const paginationArgs = before ? { last: limit, before } : { first: limit, after };

const response = await client.fetch({
document: GetProductSearchResultsQuery,
variables: { ...filterArgs, ...paginationArgs },
variables: { ...filterArgs, ...paginationArgs, currencyCode },
customerAccessToken,
fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 300 } },
});
Original file line number Diff line number Diff line change
@@ -3,14 +3,14 @@
import { BigCommerceGQLError } from '@bigcommerce/catalyst-client';
import { SubmissionResult } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { cookies } from 'next/headers';
import { getLocale, getTranslations } from 'next-intl/server';
import { z } from 'zod';

import { getSessionCustomerAccessToken } from '~/auth';
import { client } from '~/client';
import { graphql } from '~/client/graphql';
import { redirect } from '~/i18n/routing';
import { getCartId } from '~/lib/cart';

const CheckoutRedirectMutation = graphql(`
mutation CheckoutRedirectMutation($cartId: String!) {
@@ -30,13 +30,12 @@ export const redirectToCheckout = async (
): Promise<SubmissionResult | null> => {
const locale = await getLocale();
const t = await getTranslations('Cart.Errors');
const cookieStore = await cookies();

const customerAccessToken = await getSessionCustomerAccessToken();

const submission = parseWithZod(formData, { schema: z.object({}) });

const cartId = cookieStore.get('cartId')?.value;
const cartId = await getCartId();

if (!cartId) {
return submission.reply({ formErrors: [t('cartNotFound')] });
7 changes: 3 additions & 4 deletions core/app/[locale]/(default)/cart/_actions/remove-item.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
'use server';

import { unstable_expireTag } from 'next/cache';
import { cookies } from 'next/headers';
import { getTranslations } from 'next-intl/server';

import { getSessionCustomerAccessToken } from '~/auth';
import { client } from '~/client';
import { graphql, VariablesOf } from '~/client/graphql';
import { TAGS } from '~/client/tags';
import { clearCartId, getCartId } from '~/lib/cart';

const DeleteCartLineItemMutation = graphql(`
mutation DeleteCartLineItemMutation($input: DeleteCartLineItemInput!) {
@@ -31,8 +31,7 @@ export async function removeItem({

const customerAccessToken = await getSessionCustomerAccessToken();

const cookieStore = await cookies();
const cartId = cookieStore.get('cartId')?.value;
const cartId = await getCartId();

if (!cartId) {
throw new Error(t('cartNotFound'));
@@ -60,7 +59,7 @@ export async function removeItem({
// so we need to remove the cartId cookie
// TODO: We need to figure out if it actually failed.
if (!cart) {
cookieStore.delete('cartId');
await clearCartId();
}

unstable_expireTag(TAGS.cart);
5 changes: 2 additions & 3 deletions core/app/[locale]/(default)/cart/_actions/update-quantity.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
'use server';

import { unstable_expirePath } from 'next/cache';
import { cookies } from 'next/headers';
import { getTranslations } from 'next-intl/server';

import { getSessionCustomerAccessToken } from '~/auth';
import { client } from '~/client';
import { graphql, VariablesOf } from '~/client/graphql';
import { getCartId } from '~/lib/cart';

import { removeItem } from './remove-item';

@@ -44,8 +44,7 @@ export const updateQuantity = async ({

const customerAccessToken = await getSessionCustomerAccessToken();

const cookieStore = await cookies();
const cartId = cookieStore.get('cartId')?.value;
const cartId = await getCartId();

if (!cartId) {
throw new Error(t('cartNotFound'));
1 change: 1 addition & 0 deletions core/app/[locale]/(default)/cart/page-data.ts
Original file line number Diff line number Diff line change
@@ -134,6 +134,7 @@ const CartPageQuery = graphql(
site {
cart(entityId: $cartId) {
entityId
version
currencyCode
lineItems {
physicalItems {
5 changes: 3 additions & 2 deletions core/app/[locale]/(default)/cart/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Metadata } from 'next';
import { cookies } from 'next/headers';
import { getFormatter, getTranslations } from 'next-intl/server';

import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/cart';
import { getCartId } from '~/lib/cart';

import { redirectToCheckout } from './_actions/redirect-to-checkout';
import { updateLineItem } from './_actions/update-line-item';
@@ -20,7 +20,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function Cart() {
const t = await getTranslations('Cart');
const format = await getFormatter();
const cartId = (await cookies()).get('cartId')?.value;
const cartId = await getCartId();

if (!cartId) {
return (
@@ -98,6 +98,7 @@ export default async function Cart() {
cta: { label: t('Empty.cta'), href: '/shop-all' },
}}
incrementLineItemLabel={t('increment')}
key={`${cart.entityId}-${cart.version}`}
lineItemAction={updateLineItem}
lineItems={formattedLineItems}
summary={{
14 changes: 3 additions & 11 deletions core/app/[locale]/(default)/compare/_actions/add-to-cart.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use server';

import { unstable_expireTag } from 'next/cache';
import { cookies } from 'next/headers';

import {
addCartLineItem,
@@ -10,12 +9,12 @@ import {
import { assertCreateCartErrors, createCart } from '~/client/mutations/create-cart';
import { getCart } from '~/client/queries/get-cart';
import { TAGS } from '~/client/tags';
import { getCartId, setCartId } from '~/lib/cart';

export const addToCart = async (data: FormData) => {
const productEntityId = Number(data.get('product_id'));

const cookieStore = await cookies();
const cartId = cookieStore.get('cartId')?.value;
const cartId = await getCartId();

let cart;

@@ -55,14 +54,7 @@ export const addToCart = async (data: FormData) => {
return { status: 'error', error: 'Failed to add product to cart.' };
}

cookieStore.set({
name: 'cartId',
value: cart.entityId,
httpOnly: true,
sameSite: 'lax',
secure: true,
path: '/',
});
await setCartId(cart.entityId);

unstable_expireTag(TAGS.cart);

5 changes: 4 additions & 1 deletion core/app/[locale]/(default)/compare/page.tsx
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ import { Link } from '~/components/link';
import { SearchForm } from '~/components/search-form';
import { Button } from '~/components/ui/button';
import { Rating } from '~/components/ui/rating';
import { getPreferredCurrencyCode } from '~/lib/currency';
import { cn } from '~/lib/utils';

import { AddToCart } from './_components/add-to-cart';
@@ -39,7 +40,7 @@ const CompareParamsSchema = z.object({

const ComparePageQuery = graphql(
`
query ComparePageQuery($entityIds: [Int!], $first: Int) {
query ComparePageQuery($entityIds: [Int!], $first: Int, $currencyCode: currencyCode) {
site {
products(entityIds: $entityIds, first: $first) {
edges {
@@ -99,6 +100,7 @@ export default async function Compare(props: Props) {
const t = await getTranslations('Compare');
const format = await getFormatter();
const customerAccessToken = await getSessionCustomerAccessToken();
const currencyCode = await getPreferredCurrencyCode();

const parsed = CompareParamsSchema.parse(searchParams);
const productIds = parsed.ids?.filter((id) => !Number.isNaN(id));
@@ -108,6 +110,7 @@ export default async function Compare(props: Props) {
variables: {
entityIds: productIds ?? [],
first: productIds?.length ? MAX_COMPARE_LIMIT : 0,
currencyCode,
},
customerAccessToken,
fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } },
6 changes: 4 additions & 2 deletions core/app/[locale]/(default)/page.tsx
Original file line number Diff line number Diff line change
@@ -12,12 +12,13 @@ import { FeaturedProductsCarouselFragment } from '~/components/featured-products
import { FeaturedProductsListFragment } from '~/components/featured-products-list/fragment';
import { Subscribe } from '~/components/subscribe';
import { productCardTransformer } from '~/data-transformers/product-card-transformer';
import { getPreferredCurrencyCode } from '~/lib/currency';

import { Slideshow } from './_components/slideshow';

const HomePageQuery = graphql(
`
query HomePageQuery {
query HomePageQuery($currencyCode: currencyCode) {
site {
featuredProducts(first: 12) {
edges {
@@ -41,10 +42,11 @@ const HomePageQuery = graphql(

const getPageData = cache(async () => {
const customerAccessToken = await getSessionCustomerAccessToken();

const currencyCode = await getPreferredCurrencyCode();
const { data } = await client.fetch({
document: HomePageQuery,
customerAccessToken,
variables: { currencyCode },
fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } },
});

Original file line number Diff line number Diff line change
@@ -4,7 +4,6 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client';
import { SubmissionResult } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { unstable_expireTag } from 'next/cache';
import { cookies } from 'next/headers';
import { getTranslations } from 'next-intl/server';
import { ReactNode } from 'react';

@@ -15,6 +14,7 @@ import { createCart } from '~/client/mutations/create-cart';
import { getCart } from '~/client/queries/get-cart';
import { TAGS } from '~/client/tags';
import { Link } from '~/components/link';
import { getCartId, setCartId } from '~/lib/cart';

type CartSelectedOptionsInput = ReturnType<typeof graphql.scalar<'CartSelectedOptionsInput'>>;

@@ -43,8 +43,7 @@ export const addToCart = async (
const productEntityId = Number(submission.value.id);
const quantity = Number(submission.value.quantity);

const cookieStore = await cookies();
const cartId = cookieStore.get('cartId')?.value;
const cartId = await getCartId();

let cart;

@@ -217,14 +216,7 @@ export const addToCart = async (
};
}

cookieStore.set({
name: 'cartId',
value: cart.entityId,
httpOnly: true,
sameSite: 'lax',
secure: true,
path: '/',
});
await setCartId(cart.entityId);

unstable_expireTag(TAGS.cart);

Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ export const ProductSchemaFragment = graphql(`
defaultImage {
url: urlTemplate(lossy: true)
}
prices {
prices(currencyCode: $currencyCode) {
price {
value
currencyCode
5 changes: 4 additions & 1 deletion core/app/[locale]/(default)/product/[slug]/page-data.ts
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@ import { PricingFragment } from '~/client/fragments/pricing';
import { graphql, VariablesOf } from '~/client/graphql';
import { revalidate } from '~/client/revalidate-target';
import { FeaturedProductsCarouselFragment } from '~/components/featured-products-carousel/fragment';
import { getPreferredCurrencyCode } from '~/lib/currency';

import { ProductSchemaFragment } from './_components/product-schema/fragment';
import { ProductViewedFragment } from './_components/product-viewed/fragment';
@@ -211,6 +212,7 @@ const ProductPageQuery = graphql(
$entityId: Int!
$optionValueIds: [OptionValueId!]
$useDefaultOptionSelections: Boolean
$currencyCode: currencyCode
) {
site {
product(
@@ -254,10 +256,11 @@ type Variables = VariablesOf<typeof ProductPageQuery>;

export const getProductData = cache(async (variables: Variables) => {
const customerAccessToken = await getSessionCustomerAccessToken();
const currencyCode = await getPreferredCurrencyCode();

const { data } = await client.fetch({
document: ProductPageQuery,
variables,
variables: { ...variables, currencyCode },
customerAccessToken,
fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } },
});
3 changes: 3 additions & 0 deletions core/app/[locale]/(default)/product/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import { ProductDetail } from '@/vibes/soul/sections/product-detail';
import { pricesTransformer } from '~/data-transformers/prices-transformer';
import { productCardTransformer } from '~/data-transformers/product-card-transformer';
import { productOptionsTransformer } from '~/data-transformers/product-options-transformer';
import { getPreferredCurrencyCode } from '~/lib/currency';

import { addToCart } from './_actions/add-to-cart';
import { ProductSchema } from './_components/product-schema';
@@ -204,6 +205,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
export default async function Product(props: Props) {
const searchParams = await props.searchParams;
const params = await props.params;
const currencyCode = await getPreferredCurrencyCode();

const { locale, slug } = params;

@@ -219,6 +221,7 @@ export default async function Product(props: Props) {
entityId: productId,
optionValueIds,
useDefaultOptionSelections: true,
currencyCode,
});

return (
5 changes: 4 additions & 1 deletion core/app/[locale]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -11,10 +11,11 @@ import { Footer } from '~/components/footer/footer';
import { Header } from '~/components/header';
import { ProductCardFragment } from '~/components/product-card/fragment';
import { productCardTransformer } from '~/data-transformers/product-card-transformer';
import { getPreferredCurrencyCode } from '~/lib/currency';

const NotFoundQuery = graphql(
`
query NotFoundQuery {
query NotFoundQuery($currencyCode: currencyCode) {
site {
featuredProducts(first: 10) {
edges {
@@ -31,8 +32,10 @@ const NotFoundQuery = graphql(

async function getFeaturedProducts(): Promise<CarouselProduct[]> {
const format = await getFormatter();
const currencyCode = await getPreferredCurrencyCode();
const { data } = await client.fetch({
document: NotFoundQuery,
variables: { currencyCode },
fetchOptions: { next: { revalidate } },
});

5 changes: 2 additions & 3 deletions core/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { decodeJwt } from 'jose';
import { cookies } from 'next/headers';
import NextAuth, { type DefaultSession, type NextAuthConfig, User } from 'next-auth';
import 'next-auth/jwt';
import CredentialsProvider from 'next-auth/providers/credentials';
import { z } from 'zod';

import { client } from '~/client';
import { graphql } from '~/client/graphql';
import { getCartId } from '~/lib/cart';

const LoginMutation = graphql(`
mutation Login($email: String!, $password: String!, $cartEntityId: String) {
@@ -124,8 +124,7 @@ async function loginWithJwt(jwt: string, cartEntityId?: string): Promise<User |

async function authorize(credentials: unknown): Promise<User | null> {
const parsed = Credentials.parse(credentials);
const cookieStore = await cookies();
const cartEntityId = cookieStore.get('cartId')?.value;
const cartEntityId = await getCartId();

switch (parsed.type) {
case 'password': {
2 changes: 1 addition & 1 deletion core/client/fragments/pricing.ts
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ import { graphql } from '../graphql';

export const PricingFragment = graphql(`
fragment PricingFragment on Product {
prices {
prices(currencyCode: $currencyCode) {
price {
value
currencyCode
90 changes: 90 additions & 0 deletions core/components/header/_actions/switch-currency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use server';

import { BigCommerceGQLError } from '@bigcommerce/catalyst-client';
import { SubmissionResult } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { revalidatePath, revalidateTag } from 'next/cache';
import { getTranslations } from 'next-intl/server';
import { z } from 'zod';

import { client } from '~/client';
import { graphql } from '~/client/graphql';
import { TAGS } from '~/client/tags';
import { getCartId, setCartId } from '~/lib/cart';
import { setPreferredCurrencyCode } from '~/lib/currency';

import { CurrencyCode } from '../fragment';
import { CurrencyCodeSchema } from '../schema';

const currencySwitchSchema = z.object({
id: CurrencyCodeSchema,
});

// Note: this results in a new cart being created in the new currency, so the cart ID will change
const UpdateCartCurrencyMutation = graphql(`
mutation UpdateCartCurrency($input: UpdateCartCurrencyInput!) {
cart {
updateCartCurrency(input: $input) {
cart {
currencyCode
entityId
}
}
}
}
`);

export const updateCartCurrency = async (cartId: string, currencyCode: CurrencyCode) => {
const result = await client.fetch({
document: UpdateCartCurrencyMutation,
variables: { input: { data: { currencyCode }, cartEntityId: cartId } },
});
const newCartId = result.data.cart.updateCartCurrency?.cart?.entityId;

if (newCartId) {
await setCartId(newCartId);
} else {
throw new Error('Failed to update cart currency', { cause: result });
}
};

export const switchCurrency = async (_prevState: SubmissionResult | null, payload: FormData) => {
const t = await getTranslations('Components.Header.Currency');

const submission = parseWithZod(payload, { schema: currencySwitchSchema });

if (submission.status !== 'success') {
return submission.reply({ formErrors: [t('invalidCurrency')] });
}

await setPreferredCurrencyCode(submission.value.id);

const cartId = await getCartId();

if (cartId) {
await updateCartCurrency(cartId, submission.value.id)
.then(() => {
revalidateTag(TAGS.cart);
})
.catch((error: unknown) => {
// eslint-disable-next-line no-console
console.error('Error updating cart currency', error);

if (error instanceof BigCommerceGQLError) {
return submission.reply({
formErrors: error.errors.map(({ message }) => message),
});
}

if (error instanceof Error) {
return submission.reply({ formErrors: [error.message] });
}

return submission.reply({ formErrors: [t('errorUpdatingCurrency')] });
});
}

revalidatePath('/');

return submission.reply({ resetForm: true });
};
16 changes: 15 additions & 1 deletion core/components/header/fragment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { graphql } from '~/client/graphql';
import { FragmentOf, graphql } from '~/client/graphql';

export const HeaderFragment = graphql(`
fragment HeaderFragment on Site {
@@ -29,5 +29,19 @@ export const HeaderFragment = graphql(`
}
}
}
currencies(first: 25) {
edges {
node {
code
isTransactional
isDefault
}
}
}
}
`);

export type Currency = NonNullable<
NonNullable<FragmentOf<typeof HeaderFragment>>['currencies']['edges']
>[number]['node'];
export type CurrencyCode = Currency['code'];
39 changes: 36 additions & 3 deletions core/components/header/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { cookies } from 'next/headers';
import { getLocale, getTranslations } from 'next-intl/server';
import PLazy from 'p-lazy';
import { cache } from 'react';
@@ -12,8 +11,11 @@ import { revalidate } from '~/client/revalidate-target';
import { TAGS } from '~/client/tags';
import { logoTransformer } from '~/data-transformers/logo-transformer';
import { routing } from '~/i18n/routing';
import { getCartId } from '~/lib/cart';
import { getPreferredCurrencyCode } from '~/lib/currency';

import { search } from './_actions/search';
import { switchCurrency } from './_actions/switch-currency';
import { switchLocale } from './_actions/switch-locale';
import { HeaderFragment } from './fragment';

@@ -35,6 +37,7 @@ const getLayoutData = cache(async () => {

const { data: response } = await client.fetch({
document: LayoutQuery,
customerAccessToken,
fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } },
});

@@ -71,8 +74,11 @@ const getLogo = async () => {
};

const getCartCount = async () => {
const cookieStore = await cookies();
const cartId = cookieStore.get('cartId')?.value;
const cartId = await getCartId();

if (!cartId) {
return null;
}

const customerAccessToken = await getSessionCustomerAccessToken();

@@ -95,15 +101,39 @@ const getCartCount = async () => {
return response.data.site.cart.lineItems.totalQuantity;
};

const getCurrencies = async () => {
const data = await getLayoutData();

if (!data.currencies.edges) {
return [];
}

const currencies = data.currencies.edges
// only show transactional currencies for now until cart prices can be rendered in display currencies
.filter(({ node }) => node.isTransactional)
.map(({ node }) => ({
id: node.code,
label: node.code,
isDefault: node.isDefault,
}));

return currencies;
};

export const Header = async () => {
const t = await getTranslations('Components.Header');
const locale = await getLocale();
const currencyCode = await getPreferredCurrencyCode();

const locales = routing.locales.map((enabledLocales) => ({
id: enabledLocales,
label: enabledLocales.toLocaleUpperCase(),
}));

const currencies = await getCurrencies();
const defaultCurrency = currencies.find(({ isDefault }) => isDefault);
const activeCurrencyId = currencyCode ?? defaultCurrency?.id;

return (
<HeaderSection
navigation={{
@@ -124,6 +154,9 @@ export const Header = async () => {
activeLocaleId: locale,
locales,
localeAction: switchLocale,
currencies,
activeCurrencyId,
currencyAction: switchCurrency,
}}
/>
);
11 changes: 11 additions & 0 deletions core/components/header/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from 'zod';

import type { CurrencyCode } from './fragment';

export const CurrencyCodeSchema = z
.string()
.length(3)
.toUpperCase()
.refine((val): val is CurrencyCode => /^[A-Z]{3}$/.test(val), {
message: 'Must be a valid currency code',
});
27 changes: 27 additions & 0 deletions core/lib/cart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use server';

import { cookies } from 'next/headers';

export async function getCartId(): Promise<string | undefined> {
const cookieStore = await cookies();
const cartId = cookieStore.get('cartId')?.value;

return cartId;
}

export async function setCartId(cartId: string): Promise<void> {
const cookieStore = await cookies();

cookieStore.set('cartId', cartId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
}

export async function clearCartId(): Promise<void> {
const cookieStore = await cookies();

cookieStore.delete('cartId');
}
30 changes: 30 additions & 0 deletions core/lib/currency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use server';

import { cookies } from 'next/headers';

import type { CurrencyCode } from '~/components/header/fragment';
import { CurrencyCodeSchema } from '~/components/header/schema';

export async function getPreferredCurrencyCode(): Promise<CurrencyCode | undefined> {
const cookieStore = await cookies();
const currencyCode = cookieStore.get('currencyCode')?.value;

if (!currencyCode) {
return undefined;
}

const result = CurrencyCodeSchema.safeParse(currencyCode);

return result.success ? result.data : undefined;
}

export async function setPreferredCurrencyCode(currencyCode: CurrencyCode): Promise<void> {
const cookieStore = await cookies();

cookieStore.set('currencyCode', currencyCode, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
}
4 changes: 4 additions & 0 deletions core/messages/en.json
Original file line number Diff line number Diff line change
@@ -507,6 +507,10 @@
"login": "Login",
"logout": "Log out"
},
"Currency": {
"invalidCurrency": "Invalid currency",
"errorUpdatingCurrency": "Error updating currency for your cart. Please try again."
},
"Locale": {
"invalidLocale": "Invalid locale"
},
79 changes: 79 additions & 0 deletions core/vibes/soul/primitives/navigation/index.tsx
Original file line number Diff line number Diff line change
@@ -47,6 +47,11 @@ interface Locale {
label: string;
}

interface Currency {
id: string;
label: string;
}

type Action<State, Payload> = (
state: Awaited<State>,
payload: Awaited<Payload>,
@@ -71,6 +76,7 @@ export type SearchResult =
};

type LocaleAction = Action<SubmissionResult | null, FormData>;
type CurrencyAction = Action<SubmissionResult | null, FormData>;
type SearchAction<S extends SearchResult> = Action<
{
searchResults: S[] | null;
@@ -92,6 +98,9 @@ interface Props<S extends SearchResult> {
locales?: Locale[];
activeLocaleId?: string;
localeAction?: LocaleAction;
currencies?: Currency[];
activeCurrencyId?: string;
currencyAction?: CurrencyAction;
logo?: Streamable<string | { src: string; alt: string } | null>;
logoWidth?: number;
logoHeight?: number;
@@ -264,6 +273,9 @@ export const Navigation = forwardRef(function Navigation<S extends SearchResult>
activeLocaleId,
localeAction,
locales,
currencies,
activeCurrencyId,
currencyAction,
searchHref,
searchParamName = 'query',
searchAction,
@@ -560,6 +572,15 @@ export const Navigation = forwardRef(function Navigation<S extends SearchResult>
locales={locales as [Locale, Locale, ...Locale[]]}
/>
) : null}

{/* Currency Dropdown */}
{currencies && currencies.length > 1 && currencyAction ? (
<CurrencyForm
action={currencyAction}
activeCurrencyId={activeCurrencyId}
currencies={currencies as [Currency, ...Currency[]]}
/>
) : null}
</div>
</div>

@@ -861,3 +882,61 @@ function LocaleForm({
</DropdownMenu.Root>
);
}

function CurrencyForm({
action,
currencies,
activeCurrencyId,
}: {
activeCurrencyId?: string;
action: CurrencyAction;
currencies: [Currency, ...Currency[]];
}) {
const [lastResult, formAction] = useActionState(action, null);
const activeCurrency = currencies.find((currency) => currency.id === activeCurrencyId);

useEffect(() => {
if (lastResult?.error) console.log(lastResult.error);
}, [lastResult?.error]);

return (
<DropdownMenu.Root>
<DropdownMenu.Trigger
className={clsx('flex items-center gap-1 text-xs uppercase', navButtonClassName)}
>
{activeCurrency?.label ?? currencies[0].label}
<ChevronDown size={16} strokeWidth={1.5} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
className="z-50 max-h-80 overflow-y-scroll rounded-xl bg-[var(--nav-locale-background,hsl(var(--background)))] p-2 shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 @4xl:w-32 @4xl:rounded-2xl @4xl:p-2"
sideOffset={16}
>
{currencies.map((currency) => (
<DropdownMenu.Item
className={clsx(
'cursor-default rounded-lg bg-[var(--nav-locale-link-background,transparent)] px-2.5 py-2 font-[family-name:var(--nav-locale-link-font-family,var(--font-family-body))] text-sm font-medium text-[var(--nav-locale-link-text,hsl(var(--contrast-400)))] outline-none ring-[var(--nav-focus,hsl(var(--primary)))] transition-colors hover:bg-[var(--nav-locale-link-background-hover,hsl(var(--contrast-100)))] hover:text-[var(--nav-locale-link-text-hover,hsl(var(--foreground)))]',
{
'text-[var(--nav-locale-link-text-selected,hsl(var(--foreground)))]':
currency.id === activeCurrencyId,
},
)}
key={currency.id}
onSelect={() => {
// eslint-disable-next-line @typescript-eslint/require-await
startTransition(async () => {
const formData = new FormData();
formData.append('id', currency.id);
formAction(formData);
});
}}
>
{currency.label}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

0 comments on commit da2a462

Please sign in to comment.