From 4a5c4d227cf0ec8d1254b45b11735a1867d6b0ad Mon Sep 17 00:00:00 2001 From: bookernath Date: Wed, 15 Jan 2025 22:13:22 -0600 Subject: [PATCH] Add currency selector --- .../(faceted)/fetch-faceted-search.ts | 5 +- core/app/[locale]/(default)/compare/page.tsx | 5 +- core/app/[locale]/(default)/page.tsx | 6 +- .../_components/product-schema/fragment.ts | 2 +- .../(default)/product/[slug]/page-data.ts | 5 +- .../(default)/product/[slug]/page.tsx | 3 + core/app/[locale]/not-found.tsx | 5 +- core/client/fragments/pricing.ts | 2 +- .../header/_actions/switch-currency.ts | 31 ++++++++ core/components/header/fragment.ts | 14 +++- core/components/header/index.tsx | 33 ++++++-- core/components/header/schema.ts | 11 +++ core/lib/currency.ts | 30 ++++++++ core/messages/en.json | 3 + .../soul/primitives/navigation/index.tsx | 77 +++++++++++++++++++ 15 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 core/components/header/_actions/switch-currency.ts create mode 100644 core/components/header/schema.ts create mode 100644 core/lib/currency.ts 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)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index 46dbbcddd7..82ef38f18e 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -12,6 +12,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'; @@ -38,7 +39,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 { @@ -98,6 +99,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)); @@ -107,6 +109,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]/_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/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..15693849e4 --- /dev/null +++ b/core/components/header/_actions/switch-currency.ts @@ -0,0 +1,31 @@ +'use server'; + +import { SubmissionResult } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { revalidatePath } from 'next/cache'; +import { getTranslations } from 'next-intl/server'; +import { z } from 'zod'; + +import { setPreferredCurrencyCode } from '~/lib/currency'; + +import { currencyCodeSchema } from '../schema'; + +const currencySwitchSchema = z.object({ + code: currencyCodeSchema, +}); + +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.code); + + revalidatePath('/'); + + return submission.reply({ resetForm: true }); +}; diff --git a/core/components/header/fragment.ts b/core/components/header/fragment.ts index df9fe9ec0e..8bf5db44ca 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,17 @@ export const HeaderFragment = graphql(` } } } + currencies(first: 10) { + edges { + node { + code + } + } + } } `); + +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..1d81e1cfb9 100644 --- a/core/components/header/index.tsx +++ b/core/components/header/index.tsx @@ -9,11 +9,12 @@ import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, readFragment } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; -import { TAGS } from '~/client/tags'; import { logoTransformer } from '~/data-transformers/logo-transformer'; import { routing } from '~/i18n/routing'; +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 +36,7 @@ const getLayoutData = cache(async () => { const { data: response } = await client.fetch({ document: LayoutQuery, + customerAccessToken, fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, }); @@ -80,12 +82,7 @@ const getCartCount = async () => { document: GetCartCountQuery, variables: { cartId }, customerAccessToken, - fetchOptions: { - cache: 'no-store', - next: { - tags: [TAGS.cart], - }, - }, + fetchOptions: { cache: 'no-store' }, }); if (!response.data.site.cart) { @@ -95,15 +92,34 @@ 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.map(({ node }) => ({ + code: node.code, + })); + + 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(); + // todo handle default currency properly once added to API + const activeCurrencyCode = currencyCode ?? currencies[0]?.code; + return ( { activeLocaleId: locale, locales, localeAction: switchLocale, + currencies, + activeCurrencyCode, + currencyAction: switchCurrency, }} /> ); diff --git a/core/components/header/schema.ts b/core/components/header/schema.ts new file mode 100644 index 0000000000..efc77057e8 --- /dev/null +++ b/core/components/header/schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +import type { CurrencyCode } from './fragment'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const currencyCodeSchema = z + .string() + .length(3) + .toUpperCase() + .refine((val) => /^[A-Z]{3}$/.test(val), { + message: 'Must be a valid currency code', + }) as z.ZodType; diff --git a/core/lib/currency.ts b/core/lib/currency.ts new file mode 100644 index 0000000000..06fc8bbe36 --- /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..f7963b74dd 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -507,6 +507,9 @@ "login": "Login", "logout": "Log out" }, + "Currency": { + "invalidCurrency": "Invalid currency" + }, "Locale": { "invalidLocale": "Invalid locale" }, diff --git a/core/vibes/soul/primitives/navigation/index.tsx b/core/vibes/soul/primitives/navigation/index.tsx index 2f0615f918..0108087b14 100644 --- a/core/vibes/soul/primitives/navigation/index.tsx +++ b/core/vibes/soul/primitives/navigation/index.tsx @@ -47,6 +47,10 @@ interface Locale { label: string; } +interface Currency { + code: string; +} + type Action = ( state: Awaited, payload: Awaited, @@ -92,6 +96,9 @@ interface Props { locales?: Locale[]; activeLocaleId?: string; localeAction?: LocaleAction; + currencies?: Currency[]; + activeCurrencyCode?: string; + currencyAction?: LocaleAction; logo?: Streamable; logoWidth?: number; logoHeight?: number; @@ -264,6 +271,9 @@ export const Navigation = forwardRef(function Navigation activeLocaleId, localeAction, locales, + currencies, + activeCurrencyCode, + currencyAction, searchHref, searchParamName = 'query', searchAction, @@ -560,6 +570,15 @@ export const Navigation = forwardRef(function Navigation locales={locales as [Locale, Locale, ...Locale[]]} /> ) : null} + + {/* Currency Dropdown */} + {currencies && currencies.length > 1 && currencyAction ? ( + + ) : null} @@ -861,3 +880,61 @@ function LocaleForm({ ); } + +function CurrencyForm({ + action, + currencies, + activeCurrencyCode, +}: { + activeCurrencyCode?: string; + action: LocaleAction; + currencies: [Currency, ...Currency[]]; +}) { + const [lastResult, formAction] = useActionState(action, null); + const activeCurrency = currencies.find((currency) => currency.code === activeCurrencyCode); + + useEffect(() => { + if (lastResult?.error) console.log(lastResult.error); + }, [lastResult?.error]); + + return ( + + + {activeCurrency?.code ?? currencies[0].code} + + + + + {currencies.map((currency) => ( + { + // eslint-disable-next-line @typescript-eslint/require-await + startTransition(async () => { + const formData = new FormData(); + formData.append('code', currency.code); + formAction(formData); + }); + }} + > + {currency.code} + + ))} + + + + ); +}