diff --git a/README.md b/README.md index 029d02b723..e1507051ed 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +### ℹ️ This is the new integration between catalyst and buyer-portal, if you are looking for the old version check: [integrations/b2b-buyer-portal-before-vibes](https://github.com/bigcommerce/catalyst/tree/integrations/b2b-buyer-portal-before-vibes) + Catalyst for Composable Commerce Image Banner diff --git a/core/app/[locale]/(default)/(auth)/layout.tsx b/core/app/[locale]/(default)/(auth)/layout.tsx index cdccae9970..25a9dde85f 100644 --- a/core/app/[locale]/(default)/(auth)/layout.tsx +++ b/core/app/[locale]/(default)/(auth)/layout.tsx @@ -12,7 +12,7 @@ export default async function Layout({ children, params }: Props) { const { locale } = await params; if (loggedIn) { - redirect({ href: '/account/orders', locale }); + redirect({ href: '/?section=orders', locale }); } return children; diff --git a/core/app/[locale]/(default)/(auth)/login/page.tsx b/core/app/[locale]/(default)/(auth)/login/page.tsx index 9c350aa177..c5931e7f7f 100644 --- a/core/app/[locale]/(default)/(auth)/login/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/page.tsx @@ -28,7 +28,7 @@ export async function generateMetadata({ params }: Props): Promise { export default async function Login({ params, searchParams }: Props) { const { locale } = await params; - const { redirectTo = '/account/orders' } = await searchParams; + const { redirectTo = '/?section=orders' } = await searchParams; setRequestLocale(locale); diff --git a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts deleted file mode 100644 index 003c3b0098..0000000000 --- a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts +++ /dev/null @@ -1,414 +0,0 @@ -'use server'; - -import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; -import { SubmissionResult } from '@conform-to/react'; -import { parseWithZod } from '@conform-to/zod'; -import { getLocale, getTranslations } from 'next-intl/server'; -import { z } from 'zod'; - -import { Field, FieldGroup, schema } from '@/vibes/soul/form/dynamic-form/schema'; -import { signIn } from '~/auth'; -import { client } from '~/client'; -import { graphql, VariablesOf } from '~/client/graphql'; -import { FieldNameToFieldId } from '~/data-transformers/form-field-transformer/utils'; -import { redirect } from '~/i18n/routing'; -import { getCartId } from '~/lib/cart'; - -import { ADDRESS_FIELDS_NAME_PREFIX, CUSTOMER_FIELDS_NAME_PREFIX } from './prefixes'; - -const RegisterCustomerMutation = graphql(` - mutation RegisterCustomerMutation( - $input: RegisterCustomerInput! - $reCaptchaV2: ReCaptchaV2Input - ) { - customer { - registerCustomer(input: $input, reCaptchaV2: $reCaptchaV2) { - customer { - firstName - lastName - } - errors { - ... on EmailAlreadyInUseError { - message - } - ... on AccountCreationDisabledError { - message - } - ... on CustomerRegistrationError { - message - } - ... on ValidationError { - message - } - } - } - } - } -`); - -const stringToNumber = z.string().pipe(z.coerce.number()); - -const inputSchema = z.object({ - firstName: z.string(), - lastName: z.string(), - email: z.string(), - password: z.string(), - phone: z.string().optional(), - company: z.string().optional(), - address: z - .object({ - firstName: z.string(), - lastName: z.string(), - address1: z.string(), - address2: z.string().optional(), - city: z.string(), - company: z.string().optional(), - countryCode: z.string(), - stateOrProvince: z.string().optional(), - phone: z.string().optional(), - postalCode: z.string().optional(), - formFields: z.object({ - checkboxes: z.array( - z.object({ - fieldEntityId: stringToNumber, - fieldValueEntityIds: z.array(stringToNumber), - }), - ), - multipleChoices: z.array( - z.object({ - fieldEntityId: stringToNumber, - fieldValueEntityId: stringToNumber, - }), - ), - numbers: z.array( - z.object({ - fieldEntityId: stringToNumber, - number: stringToNumber, - }), - ), - dates: z.array( - z.object({ - fieldEntityId: stringToNumber, - date: z.string(), - }), - ), - passwords: z.array( - z.object({ - fieldEntityId: stringToNumber, - password: z.string(), - }), - ), - multilineTexts: z.array( - z.object({ - fieldEntityId: stringToNumber, - multilineText: z.string(), - }), - ), - texts: z.array( - z.object({ - fieldEntityId: stringToNumber, - text: z.string(), - }), - ), - }), - }) - .optional(), - formFields: z.object({ - checkboxes: z.array( - z.object({ - fieldEntityId: stringToNumber, - fieldValueEntityIds: z.array(stringToNumber), - }), - ), - multipleChoices: z.array( - z.object({ - fieldEntityId: stringToNumber, - fieldValueEntityId: stringToNumber, - }), - ), - numbers: z.array( - z.object({ - fieldEntityId: stringToNumber, - number: stringToNumber, - }), - ), - dates: z.array( - z.object({ - fieldEntityId: stringToNumber, - date: z.string(), - }), - ), - passwords: z.array( - z.object({ - fieldEntityId: stringToNumber, - password: z.string(), - }), - ), - multilineTexts: z.array( - z.object({ - fieldEntityId: stringToNumber, - multilineText: z.string(), - }), - ), - texts: z.array( - z.object({ - fieldEntityId: stringToNumber, - text: z.string(), - }), - ), - }), -}); - -function parseRegisterCustomerInput( - value: Record, - fields: Array>, -): VariablesOf['input'] { - const customFields = fields - .flatMap((f) => (Array.isArray(f) ? f : [f])) - .filter( - (field) => - ![ - String(FieldNameToFieldId.email), - String(FieldNameToFieldId.password), - String(FieldNameToFieldId.confirmPassword), - String(FieldNameToFieldId.firstName), - String(FieldNameToFieldId.lastName), - String(FieldNameToFieldId.address1), - String(FieldNameToFieldId.address2), - String(FieldNameToFieldId.city), - String(FieldNameToFieldId.company), - String(FieldNameToFieldId.countryCode), - String(FieldNameToFieldId.stateOrProvince), - String(FieldNameToFieldId.phone), - String(FieldNameToFieldId.postalCode), - ].includes(field.name), - ); - - const customAddressFields = customFields.filter((field) => - field.name.startsWith(ADDRESS_FIELDS_NAME_PREFIX), - ); - const customCustomerFields = customFields.filter((field) => - field.name.startsWith(CUSTOMER_FIELDS_NAME_PREFIX), - ); - - const mappedInput = { - firstName: value.firstName, - lastName: value.lastName, - email: value.email, - password: value.password, - phone: value.phone, - company: value.company, - address: { - firstName: value.firstName, - lastName: value.lastName, - address1: value.address1, - address2: value.address2, - city: value.city, - company: value.company, - countryCode: value.countryCode, - stateOrProvince: value.stateOrProvince, - phone: value.phone, - postalCode: value.postalCode, - formFields: { - checkboxes: customAddressFields - .filter((field) => ['checkbox-group'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => { - return { - fieldEntityId: field.id, - fieldValueEntityIds: Array.isArray(value[field.name]) - ? value[field.name] - : [value[field.name]], - }; - }), - multipleChoices: customAddressFields - .filter((field) => ['radio-group', 'button-radio-group'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => { - return { - fieldEntityId: field.id, - fieldValueEntityId: value[field.name], - }; - }), - numbers: customAddressFields - .filter((field) => ['number'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => { - return { - fieldEntityId: field.id, - number: value[field.name], - }; - }), - dates: customAddressFields - .filter((field) => ['date'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => { - return { - fieldEntityId: field.id, - date: new Date(String(value[field.name])).toISOString(), - }; - }), - passwords: customAddressFields - .filter((field) => ['password'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => ({ - fieldEntityId: field.id, - password: value[field.name], - })), - multilineTexts: customAddressFields - .filter((field) => ['textarea'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => ({ - fieldEntityId: field.id, - multilineText: value[field.name], - })), - texts: customAddressFields - .filter((field) => ['text'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => ({ - fieldEntityId: field.id, - text: value[field.name], - })), - }, - }, - formFields: { - checkboxes: customCustomerFields - .filter((field) => ['checkbox-group'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => { - return { - fieldEntityId: field.id, - fieldValueEntityIds: Array.isArray(value[field.name]) - ? value[field.name] - : [value[field.name]], - }; - }), - multipleChoices: customCustomerFields - .filter((field) => ['radio-group', 'button-radio-group'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => { - return { - fieldEntityId: field.id, - fieldValueEntityId: value[field.name], - }; - }), - numbers: customCustomerFields - .filter((field) => ['number'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => { - return { - fieldEntityId: field.id, - number: value[field.name], - }; - }), - dates: customCustomerFields - .filter((field) => ['date'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => { - return { - fieldEntityId: field.id, - date: new Date(String(value[field.name])).toISOString(), - }; - }), - passwords: customCustomerFields - .filter((field) => ['password'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => ({ - fieldEntityId: field.id, - password: value[field.name], - })), - multilineTexts: customCustomerFields - .filter((field) => ['textarea'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => ({ - fieldEntityId: field.id, - multilineText: value[field.name], - })), - texts: customCustomerFields - .filter((field) => ['text'].includes(field.type)) - .filter((field) => Boolean(value[field.name])) - .map((field) => ({ - fieldEntityId: field.id, - text: value[field.name], - })), - }, - }; - - return inputSchema.parse(mappedInput); -} - -export async function registerCustomer( - prevState: { lastResult: SubmissionResult | null; fields: Array> }, - formData: FormData, -) { - const t = await getTranslations('Auth.Register'); - const locale = await getLocale(); - const cartId = await getCartId(); - - const submission = parseWithZod(formData, { schema: schema(prevState.fields) }); - - if (submission.status !== 'success') { - return { - lastResult: submission.reply(), - fields: prevState.fields, - }; - } - - try { - const input = parseRegisterCustomerInput(submission.value, prevState.fields); - const response = await client.fetch({ - document: RegisterCustomerMutation, - variables: { - input, - // ...(recaptchaToken && { reCaptchaV2: { token: recaptchaToken } }), - }, - fetchOptions: { cache: 'no-store' }, - }); - - const result = response.data.customer.registerCustomer; - - if (result.errors.length > 0) { - return { - lastResult: submission.reply({ - formErrors: response.data.customer.registerCustomer.errors.map((error) => error.message), - }), - fields: prevState.fields, - }; - } - - await signIn('password', { - email: input.email, - password: input.password, - cartId, - // We want to use next/navigation for the redirect as it - // follows basePath and trailing slash configurations. - redirect: false, - }); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - - if (error instanceof BigCommerceGQLError) { - return { - lastResult: submission.reply({ - formErrors: error.errors.map(({ message }) => message), - }), - fields: prevState.fields, - }; - } - - if (error instanceof Error) { - return { - lastResult: submission.reply({ formErrors: [error.message] }), - fields: prevState.fields, - }; - } - - return { - lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }), - fields: prevState.fields, - }; - } - - return redirect({ href: '/account/orders', locale }); -} diff --git a/core/app/[locale]/(default)/(auth)/register/page-data.ts b/core/app/[locale]/(default)/(auth)/register/page-data.ts deleted file mode 100644 index 08d8e85952..0000000000 --- a/core/app/[locale]/(default)/(auth)/register/page-data.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { cache } from 'react'; - -import { getSessionCustomerAccessToken } from '~/auth'; -import { client } from '~/client'; -import { graphql, VariablesOf } from '~/client/graphql'; -import { FormFieldsFragment } from '~/data-transformers/form-field-transformer/fragment'; -import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; - -const RegisterCustomerQuery = graphql( - ` - query RegisterCustomerQuery( - $customerFilters: FormFieldFiltersInput - $customerSortBy: FormFieldSortInput - $addressFilters: FormFieldFiltersInput - $addressSortBy: FormFieldSortInput - ) { - site { - settings { - formFields { - customer(filters: $customerFilters, sortBy: $customerSortBy) { - ...FormFieldsFragment - } - shippingAddress(filters: $addressFilters, sortBy: $addressSortBy) { - ...FormFieldsFragment - } - } - } - settings { - reCaptcha { - isEnabledOnStorefront - siteKey - } - } - } - geography { - countries { - code - name - } - } - } - `, - [FormFieldsFragment], -); - -type Variables = VariablesOf; - -interface Props { - address?: { - filters?: Variables['addressFilters']; - sortBy?: Variables['addressSortBy']; - }; - - customer?: { - filters?: Variables['customerFilters']; - sortBy?: Variables['customerSortBy']; - }; -} - -export const getRegisterCustomerQuery = cache(async ({ address, customer }: Props) => { - const customerAccessToken = await getSessionCustomerAccessToken(); - - const response = await client.fetch({ - document: RegisterCustomerQuery, - variables: { - addressFilters: address?.filters, - addressSortBy: address?.sortBy, - customerFilters: customer?.filters, - customerSortBy: customer?.sortBy, - }, - fetchOptions: { cache: 'no-store' }, - customerAccessToken, - }); - - const addressFields = response.data.site.settings?.formFields.shippingAddress; - const customerFields = response.data.site.settings?.formFields.customer; - const countries = response.data.geography.countries; - - const reCaptchaSettings = await bypassReCaptcha(response.data.site.settings?.reCaptcha); - - if (!addressFields || !customerFields) { - return null; - } - - return { - addressFields, - customerFields, - reCaptchaSettings, - countries, - }; -}); diff --git a/core/app/[locale]/(default)/(auth)/register/page.tsx b/core/app/[locale]/(default)/(auth)/register/page.tsx deleted file mode 100644 index 9bc4b03d4a..0000000000 --- a/core/app/[locale]/(default)/(auth)/register/page.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { Metadata } from 'next'; -import { notFound } from 'next/navigation'; -import { getTranslations, setRequestLocale } from 'next-intl/server'; - -// TODO: Add recaptcha token -// import { bypassReCaptcha } from '~/lib/bypass-recaptcha'; - -import { DynamicFormSection } from '@/vibes/soul/sections/dynamic-form-section'; -import { - formFieldTransformer, - injectCountryCodeOptions, -} from '~/data-transformers/form-field-transformer'; -import { - CUSTOMER_FIELDS_TO_EXCLUDE, - REGISTER_CUSTOMER_FORM_LAYOUT, - transformFieldsToLayout, -} from '~/data-transformers/form-field-transformer/utils'; -import { exists } from '~/lib/utils'; - -import { ADDRESS_FIELDS_NAME_PREFIX, CUSTOMER_FIELDS_NAME_PREFIX } from './_actions/prefixes'; -import { registerCustomer } from './_actions/register-customer'; -import { getRegisterCustomerQuery } from './page-data'; - -interface Props { - params: Promise<{ locale: string }>; -} - -export async function generateMetadata({ params }: Props): Promise { - const { locale } = await params; - - const t = await getTranslations({ locale, namespace: 'Auth.Register' }); - - return { - title: t('title'), - }; -} - -export default async function Register({ params }: Props) { - const { locale } = await params; - - setRequestLocale(locale); - - const t = await getTranslations('Auth.Register'); - - const registerCustomerData = await getRegisterCustomerQuery({ - address: { sortBy: 'SORT_ORDER' }, - customer: { sortBy: 'SORT_ORDER' }, - }); - - if (!registerCustomerData) { - notFound(); - } - - const { addressFields, customerFields, countries } = registerCustomerData; - // const reCaptcha = await bypassReCaptcha(reCaptchaSettings); - - const fields = transformFieldsToLayout( - [ - ...addressFields.map((field) => { - if (!field.isBuiltIn) { - return { - ...field, - name: `${ADDRESS_FIELDS_NAME_PREFIX}${field.label}`, - }; - } - - return field; - }), - ...customerFields.map((field) => { - if (!field.isBuiltIn) { - return { - ...field, - name: `${CUSTOMER_FIELDS_NAME_PREFIX}${field.label}`, - }; - } - - return field; - }), - ].filter((field) => !CUSTOMER_FIELDS_TO_EXCLUDE.includes(field.entityId)), - REGISTER_CUSTOMER_FORM_LAYOUT, - ) - .map((field) => { - if (Array.isArray(field)) { - return field.map(formFieldTransformer).filter(exists); - } - - return formFieldTransformer(field); - }) - .filter(exists) - .map((field) => { - if (Array.isArray(field)) { - return field.map((f) => injectCountryCodeOptions(f, countries ?? [])); - } - - return injectCountryCodeOptions(field, countries ?? []); - }) - .filter(exists); - - return ( - - ); -} diff --git a/core/app/[locale]/(default)/(auth)/register/route.ts b/core/app/[locale]/(default)/(auth)/register/route.ts new file mode 100644 index 0000000000..88febe199d --- /dev/null +++ b/core/app/[locale]/(default)/(auth)/register/route.ts @@ -0,0 +1,9 @@ +import { getLocale } from 'next-intl/server'; + +import { redirect } from '~/i18n/routing'; + +export const GET = async () => { + const locale = await getLocale(); + + redirect({ href: '/?section=register', locale }); +}; diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index 0dd0d4044c..bf7e3e8d61 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -157,6 +157,7 @@ export default async function Cart({ params }: Props) { getAnalyticsData(cartId))}> + diff --git a/core/auth/index.ts b/core/auth/index.ts index bf70c6ff88..6d0df89aee 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -6,6 +6,7 @@ import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; import { anonymousSignIn, clearAnonymousSession } from '~/auth/anonymous-session'; +import { loginWithB2B } from '~/b2b/client'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { clearCartId, setCartId } from '~/lib/cart'; @@ -16,6 +17,7 @@ const LoginMutation = graphql(` login(email: $email, password: $password, guestCartEntityId: $cartEntityId) { customerAccessToken { value + expiresAt } customer { entityId @@ -35,6 +37,7 @@ const LoginWithTokenMutation = graphql(` loginWithCustomerLoginJwt(jwt: $jwt, guestCartEntityId: $cartEntityId) { customerAccessToken { value + expiresAt } customer { entityId @@ -75,6 +78,10 @@ const PasswordCredentials = z.object({ cartId: cartIdSchema, }); +const AnonymousCredentials = z.object({ + cartId: z.string().optional(), +}); + const JwtCredentials = z.object({ jwt: z.string(), cartId: cartIdSchema, @@ -124,6 +131,12 @@ async function loginWithPassword(credentials: unknown): Promise { } await handleLoginCart(cartId, result.cart?.entityId); + + const b2bToken = await loginWithB2B({ + customerId: result.customer.entityId, + customerAccessToken: result.customerAccessToken, + }); + await clearAnonymousSession(); return { @@ -131,6 +144,7 @@ async function loginWithPassword(credentials: unknown): Promise { email: result.customer.email, customerAccessToken: result.customerAccessToken.value, cartId: result.cart?.entityId, + b2bToken, }; } @@ -160,6 +174,12 @@ async function loginWithJwt(credentials: unknown): Promise { } await handleLoginCart(cartId, result.cart?.entityId); + + const b2bToken = await loginWithB2B({ + customerId: result.customer.entityId, + customerAccessToken: result.customerAccessToken, + }); + await clearAnonymousSession(); return { @@ -168,6 +188,15 @@ async function loginWithJwt(credentials: unknown): Promise { customerAccessToken: result.customerAccessToken.value, impersonatorId, cartId: result.cart?.entityId, + b2bToken, + }; +} + +function loginWithAnonymous(credentials: unknown): User | null { + const { cartId } = AnonymousCredentials.parse(credentials); + + return { + cartId: cartId ?? null, }; } @@ -197,6 +226,12 @@ const config = { }; } + // user can actually be undefined + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (user?.b2bToken) { + token.b2bToken = user.b2bToken; + } + // user can actually be undefined // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (user?.cartId) { @@ -228,6 +263,10 @@ const config = { session.user.cartId = token.user.cartId; } + if (token.b2bToken) { + session.b2bToken = token.b2bToken; + } + return session; }, }, @@ -279,6 +318,13 @@ const config = { }, authorize: loginWithPassword, }), + CredentialsProvider({ + id: 'anonymous', + credentials: { + cartId: { type: 'text' }, + }, + authorize: loginWithAnonymous, + }), CredentialsProvider({ id: 'jwt', credentials: { diff --git a/core/auth/types.ts b/core/auth/types.ts index 5cdf427e0c..a5e8508f89 100644 --- a/core/auth/types.ts +++ b/core/auth/types.ts @@ -3,6 +3,7 @@ import { User } from 'next-auth'; declare module 'next-auth' { interface Session { user?: User; + b2bToken?: string; } interface User { @@ -11,6 +12,7 @@ declare module 'next-auth' { cartId?: string | null; customerAccessToken?: string; impersonatorId?: string | null; + b2bToken?: string; } interface AnonymousUser { @@ -22,5 +24,6 @@ declare module 'next-auth/jwt' { interface JWT { id?: string; user?: User; + b2bToken?: string; } } diff --git a/core/b2b/client.ts b/core/b2b/client.ts new file mode 100644 index 0000000000..2edf1d4369 --- /dev/null +++ b/core/b2b/client.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +interface LoginWithB2BParams { + customerId: number; + customerAccessToken: { + value: string; + expiresAt: string; + }; +} + +const ENV = z + .object({ + env: z.object({ + B2B_API_TOKEN: z.string(), + BIGCOMMERCE_CHANNEL_ID: z.string(), + B2B_API_HOST: z.string().default('https://api-b2b.bigcommerce.com'), + }), + }) + .transform(({ env }) => env); + +const ErrorResponse = z.object({ + detail: z.string().default('Unknown error'), +}); + +const B2BTokenResponseSchema = z.object({ + data: z.object({ + token: z.array(z.string()).nonempty({ message: 'No token returned from B2B API' }), + }), +}); + +export async function loginWithB2B({ customerId, customerAccessToken }: LoginWithB2BParams) { + const { B2B_API_HOST, B2B_API_TOKEN, BIGCOMMERCE_CHANNEL_ID } = ENV.parse(process); + + const response = await fetch(`${B2B_API_HOST}/api/io/auth/customers/storefront`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authToken: B2B_API_TOKEN, + }, + body: JSON.stringify({ + channelId: BIGCOMMERCE_CHANNEL_ID, + customerId, + customerAccessToken, + }), + }); + + if (!response.ok) { + const errorMessage = ErrorResponse.parse(await response.json()).detail; + + throw new Error( + `Failed to login with ${B2B_API_HOST}. Status: ${response.status}, Message: ${errorMessage}`, + ); + } + + return B2BTokenResponseSchema.parse(await response.json()).data.token[0]; +} diff --git a/core/b2b/loader.tsx b/core/b2b/loader.tsx new file mode 100644 index 0000000000..58db961435 --- /dev/null +++ b/core/b2b/loader.tsx @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +import { auth } from '~/auth'; + +import { ScriptDev } from './script-dev'; +import { ScriptProduction } from './script-production'; + +const EnvironmentSchema = z.object({ + BIGCOMMERCE_STORE_HASH: z.string({ message: 'BIGCOMMERCE_STORE_HASH is required' }), + BIGCOMMERCE_CHANNEL_ID: z.string({ message: 'BIGCOMMERCE_CHANNEL_ID is required' }), + LOCAL_BUYER_PORTAL_HOST: z.string().url().optional(), + STAGING_B2B_CDN_ORIGIN: z.string().optional(), +}); + +export async function B2BLoader() { + const { + BIGCOMMERCE_STORE_HASH, + BIGCOMMERCE_CHANNEL_ID, + LOCAL_BUYER_PORTAL_HOST, + STAGING_B2B_CDN_ORIGIN, + } = EnvironmentSchema.parse(process.env); + + const session = await auth(); + + if (LOCAL_BUYER_PORTAL_HOST) { + return ( + + ); + } + + const environment = STAGING_B2B_CDN_ORIGIN === 'true' ? 'staging' : 'production'; + + return ( + + ); +} diff --git a/core/b2b/map-to-b2b-product-options.tsx b/core/b2b/map-to-b2b-product-options.tsx new file mode 100644 index 0000000000..1bd223ceae --- /dev/null +++ b/core/b2b/map-to-b2b-product-options.tsx @@ -0,0 +1,63 @@ +import { B2BProductOption } from '~/b2b/types'; + +import { Field } from '../vibes/soul/sections/product-detail/schema'; + +interface ProductOption { + field: Field; + value?: string | number; +} + +export function mapToB2BProductOptions({ field, value }: ProductOption): B2BProductOption { + const fieldId = Number(field.name); + + const baseOption: B2BProductOption = { + optionEntityId: fieldId, + optionValueEntityId: 0, // Will be set based on type + entityId: fieldId, + valueEntityId: 0, // Will be set based on type + text: '', + number: 0, + date: { utc: '' }, + }; + + switch (field.type) { + case 'text': + case 'textarea': + return { + ...baseOption, + text: String(value || ''), + }; + + case 'number': + return { + ...baseOption, + number: Number(value || 0), + }; + + case 'date': + return { + ...baseOption, + date: { + utc: value ? new Date(value).toISOString() : '', + }, + }; + + case 'button-radio-group': + case 'swatch-radio-group': + case 'radio-group': + case 'card-radio-group': + case 'select': { + const selectedOption = field.options.find((opt) => opt.value === String(value)); + + return { + ...baseOption, + optionValueEntityId: Number(selectedOption?.value ?? 0), + valueEntityId: Number(selectedOption?.value ?? 0), + text: selectedOption?.label || '', + }; + } + + default: + return baseOption; + } +} diff --git a/core/b2b/script-dev.tsx b/core/b2b/script-dev.tsx new file mode 100644 index 0000000000..e01f9822c8 --- /dev/null +++ b/core/b2b/script-dev.tsx @@ -0,0 +1,56 @@ +/* eslint-disable @next/next/no-before-interactive-script-outside-document */ +'use client'; + +import Script from 'next/script'; + +import { useB2BAuth } from './use-b2b-auth'; +import { useB2BCart } from './use-b2b-cart'; + +interface DevProps { + storeHash: string; + channelId: string; + hostname: string; + token?: string; + cartId?: string; +} + +export function ScriptDev({ cartId, hostname, storeHash, channelId, token }: DevProps) { + useB2BAuth(token); + useB2BCart(cartId); + + const src = `${hostname}/src/main.ts`; + + return ( + <> + + + +