diff --git a/.changeset/gold-ducks-tie.md b/.changeset/gold-ducks-tie.md new file mode 100644 index 0000000000..cb5d2df7c1 --- /dev/null +++ b/.changeset/gold-ducks-tie.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Add currency selector to header diff --git a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts index 5d44357b83..48b99fbd33 100644 --- a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts +++ b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts @@ -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 } }, }); diff --git a/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts b/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts index 71245afd36..0a62838504 100644 --- a/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts +++ b/core/app/[locale]/(default)/cart/_actions/redirect-to-checkout.ts @@ -3,7 +3,6 @@ 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'; @@ -11,6 +10,7 @@ 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 => { 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')] }); diff --git a/core/app/[locale]/(default)/cart/_actions/remove-item.ts b/core/app/[locale]/(default)/cart/_actions/remove-item.ts index 4332f971b9..f02a24dd66 100644 --- a/core/app/[locale]/(default)/cart/_actions/remove-item.ts +++ b/core/app/[locale]/(default)/cart/_actions/remove-item.ts @@ -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); diff --git a/core/app/[locale]/(default)/cart/_actions/update-quantity.ts b/core/app/[locale]/(default)/cart/_actions/update-quantity.ts index 0b4900438f..f72d60ac63 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-quantity.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-quantity.ts @@ -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')); diff --git a/core/app/[locale]/(default)/cart/page-data.ts b/core/app/[locale]/(default)/cart/page-data.ts index 67bc135cde..a0bb163433 100644 --- a/core/app/[locale]/(default)/cart/page-data.ts +++ b/core/app/[locale]/(default)/cart/page-data.ts @@ -134,6 +134,7 @@ const CartPageQuery = graphql( site { cart(entityId: $cartId) { entityId + version currencyCode lineItems { physicalItems { diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index 6dc75107d1..b89ecd5a86 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -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 { 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={{ diff --git a/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts b/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts index 0dff0eece4..f30e02dd6a 100644 --- a/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts +++ b/core/app/[locale]/(default)/compare/_actions/add-to-cart.ts @@ -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); diff --git a/core/app/[locale]/(default)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index a1f8b2efd2..03fef8ee2f 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -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 } }, diff --git a/core/app/[locale]/(default)/page.tsx b/core/app/[locale]/(default)/page.tsx index 5da8c39394..5059358225 100644 --- a/core/app/[locale]/(default)/page.tsx +++ b/core/app/[locale]/(default)/page.tsx @@ -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 } }, }); diff --git a/core/app/[locale]/(default)/product/[slug]/_actions/add-to-cart.tsx b/core/app/[locale]/(default)/product/[slug]/_actions/add-to-cart.tsx index a2fb1fa9be..d1d76246f9 100644 --- a/core/app/[locale]/(default)/product/[slug]/_actions/add-to-cart.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_actions/add-to-cart.tsx @@ -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>; @@ -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); diff --git a/core/app/[locale]/(default)/product/[slug]/_components/product-schema/fragment.ts b/core/app/[locale]/(default)/product/[slug]/_components/product-schema/fragment.ts index e230a7893c..97d4e3963f 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/product-schema/fragment.ts +++ b/core/app/[locale]/(default)/product/[slug]/_components/product-schema/fragment.ts @@ -19,7 +19,7 @@ export const ProductSchemaFragment = graphql(` defaultImage { url: urlTemplate(lossy: true) } - prices { + prices(currencyCode: $currencyCode) { price { value currencyCode diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index ccf33ad0ba..14b3645b0f 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -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; 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 } }, }); diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index e5a2a0a077..e39c07ab5d 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -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 { 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 ( diff --git a/core/app/[locale]/not-found.tsx b/core/app/[locale]/not-found.tsx index 121a9657db..5bfdd3170e 100644 --- a/core/app/[locale]/not-found.tsx +++ b/core/app/[locale]/not-found.tsx @@ -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 { const format = await getFormatter(); + const currencyCode = await getPreferredCurrencyCode(); const { data } = await client.fetch({ document: NotFoundQuery, + variables: { currencyCode }, fetchOptions: { next: { revalidate } }, }); diff --git a/core/auth/index.ts b/core/auth/index.ts index 9a6ab42da4..b5010f4b8b 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -1,5 +1,4 @@ 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'; @@ -7,6 +6,7 @@ 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 { const parsed = Credentials.parse(credentials); - const cookieStore = await cookies(); - const cartEntityId = cookieStore.get('cartId')?.value; + const cartEntityId = await getCartId(); switch (parsed.type) { case 'password': { diff --git a/core/client/fragments/pricing.ts b/core/client/fragments/pricing.ts index d5a0b45548..b9eb389807 100644 --- a/core/client/fragments/pricing.ts +++ b/core/client/fragments/pricing.ts @@ -2,7 +2,7 @@ import { graphql } from '../graphql'; export const PricingFragment = graphql(` fragment PricingFragment on Product { - prices { + prices(currencyCode: $currencyCode) { price { value currencyCode diff --git a/core/components/header/_actions/switch-currency.ts b/core/components/header/_actions/switch-currency.ts new file mode 100644 index 0000000000..b667d806df --- /dev/null +++ b/core/components/header/_actions/switch-currency.ts @@ -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 }); +}; diff --git a/core/components/header/fragment.ts b/core/components/header/fragment.ts index df9fe9ec0e..fc9726f62f 100644 --- a/core/components/header/fragment.ts +++ b/core/components/header/fragment.ts @@ -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>['currencies']['edges'] +>[number]['node']; +export type CurrencyCode = Currency['code']; diff --git a/core/components/header/index.tsx b/core/components/header/index.tsx index dd0d065e49..4ddee75571 100644 --- a/core/components/header/index.tsx +++ b/core/components/header/index.tsx @@ -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 ( { activeLocaleId: locale, locales, localeAction: switchLocale, + currencies, + activeCurrencyId, + currencyAction: switchCurrency, }} /> ); diff --git a/core/components/header/schema.ts b/core/components/header/schema.ts new file mode 100644 index 0000000000..acce6099ca --- /dev/null +++ b/core/components/header/schema.ts @@ -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', + }); diff --git a/core/lib/cart.ts b/core/lib/cart.ts new file mode 100644 index 0000000000..2777fa1055 --- /dev/null +++ b/core/lib/cart.ts @@ -0,0 +1,27 @@ +'use server'; + +import { cookies } from 'next/headers'; + +export async function getCartId(): Promise { + const cookieStore = await cookies(); + const cartId = cookieStore.get('cartId')?.value; + + return cartId; +} + +export async function setCartId(cartId: string): Promise { + const cookieStore = await cookies(); + + cookieStore.set('cartId', cartId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + }); +} + +export async function clearCartId(): Promise { + const cookieStore = await cookies(); + + cookieStore.delete('cartId'); +} diff --git a/core/lib/currency.ts b/core/lib/currency.ts new file mode 100644 index 0000000000..9a7ff6d593 --- /dev/null +++ b/core/lib/currency.ts @@ -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 { + 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 { + const cookieStore = await cookies(); + + cookieStore.set('currencyCode', currencyCode, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + }); +} diff --git a/core/messages/en.json b/core/messages/en.json index 2f23ae03dc..1eb2390180 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -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" }, diff --git a/core/vibes/soul/primitives/navigation/index.tsx b/core/vibes/soul/primitives/navigation/index.tsx index 2f0615f918..3e64e035fd 100644 --- a/core/vibes/soul/primitives/navigation/index.tsx +++ b/core/vibes/soul/primitives/navigation/index.tsx @@ -47,6 +47,11 @@ interface Locale { label: string; } +interface Currency { + id: string; + label: string; +} + type Action = ( state: Awaited, payload: Awaited, @@ -71,6 +76,7 @@ export type SearchResult = }; type LocaleAction = Action; +type CurrencyAction = Action; type SearchAction = Action< { searchResults: S[] | null; @@ -92,6 +98,9 @@ interface Props { locales?: Locale[]; activeLocaleId?: string; localeAction?: LocaleAction; + currencies?: Currency[]; + activeCurrencyId?: string; + currencyAction?: CurrencyAction; logo?: Streamable; logoWidth?: number; logoHeight?: number; @@ -264,6 +273,9 @@ export const Navigation = forwardRef(function Navigation activeLocaleId, localeAction, locales, + currencies, + activeCurrencyId, + currencyAction, searchHref, searchParamName = 'query', searchAction, @@ -560,6 +572,15 @@ export const Navigation = forwardRef(function Navigation locales={locales as [Locale, Locale, ...Locale[]]} /> ) : null} + + {/* Currency Dropdown */} + {currencies && currencies.length > 1 && currencyAction ? ( + + ) : null} @@ -861,3 +882,61 @@ function LocaleForm({ ); } + +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 ( + + + {activeCurrency?.label ?? currencies[0].label} + + + + + {currencies.map((currency) => ( + { + // eslint-disable-next-line @typescript-eslint/require-await + startTransition(async () => { + const formData = new FormData(); + formData.append('id', currency.id); + formAction(formData); + }); + }} + > + {currency.label} + + ))} + + + + ); +}