diff --git a/example/api/get_token.js b/example/api/get_token.js index 47cbb26a2..1435e02e9 100644 --- a/example/api/get_token.js +++ b/example/api/get_token.js @@ -47,6 +47,53 @@ async function fetchAccessToken() { return { accessToken: data.access_token, expiresIn: data.expires_in }; } +async function fetchClientCredentialsAccessToken() { + const { + VITE_CLIENT_ID, + VITE_CLIENT_SECRET, + VITE_REMOTE_GATEWAY, + VITE_REFRESH_TOKEN, + } = process.env; + + // for local development, we don't need a client secret + if ( + !VITE_CLIENT_ID || + (!VITE_CLIENT_SECRET && VITE_REMOTE_GATEWAY !== 'local') || + !VITE_REMOTE_GATEWAY || + !VITE_REFRESH_TOKEN + ) { + throw new Error( + 'Missing VITE_CLIENT_ID, VITE_CLIENT_SECRET, VITE_REMOTE_GATEWAY, or VITE_REFRESH_TOKEN', + ); + } + + const gatewayUrl = buildGatewayURL(); + + const encodedCredentials = Buffer.from( + `${VITE_CLIENT_ID}:${VITE_CLIENT_SECRET}`, + ).toString('base64'); + + const response = await fetch(`${gatewayUrl}/auth/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${encodedCredentials}`, + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const data = await response.json(); + return { accessToken: data.access_token, expiresIn: data.expires_in }; +} + + // Express route handler async function getToken(req, res) { const { NODE_ENV } = process.env; @@ -70,4 +117,4 @@ async function getToken(req, res) { } } -module.exports = { getToken, fetchAccessToken }; +module.exports = { getToken, fetchAccessToken, fetchClientCredentialsAccessToken }; diff --git a/example/api/proxy.js b/example/api/proxy.js index 30651aac3..f36aa7356 100644 --- a/example/api/proxy.js +++ b/example/api/proxy.js @@ -1,5 +1,5 @@ const axios = require('axios'); -const { fetchAccessToken } = require('./get_token.js'); +const { fetchClientCredentialsAccessToken, fetchAccessToken } = require('./get_token.js'); const { buildGatewayURL } = require('./utils.js'); /** @@ -38,9 +38,16 @@ async function createProxyRequest(path, method = 'GET', options = {}) { if (stream) { delete requestConfig.headers['Content-Type']; } + console.log("REQUIRES AUTH", requiresAuth) // Add authentication if required - if (requiresAuth) { + if (requiresAuth && (path.startsWith('/v1/countries') || path.startsWith('/v1/companies') || path.startsWith('/v1/company-currencies'))) { + const { accessToken } = await fetchClientCredentialsAccessToken(); + requestConfig.headers.Authorization = `Bearer ${accessToken}`; + + + } + else if (requiresAuth) { const { accessToken } = await fetchAccessToken(); requestConfig.headers.Authorization = `Bearer ${accessToken}`; } @@ -58,6 +65,7 @@ function createProxyMiddleware(requiresAuth = true) { const isMultipart = req.headers['content-type']?.includes( 'multipart/form-data', ); + console.log("PROXYING", req.originalUrl) try { const response = await createProxyRequest(req.originalUrl, req.method, { diff --git a/example/flake.nix b/example/flake.nix index e9b30a68b..d784e7146 100644 --- a/example/flake.nix +++ b/example/flake.nix @@ -15,7 +15,7 @@ in { devShells.default = pkgs.mkShell { buildInputs = [ - pkgs.nodejs_20 or pkgs.nodejs_latest + pkgs.nodejs_24 or pkgs.nodejs_latest pkgs.playwright-driver.browsers pkgs.playwright ]; diff --git a/example/package-lock.json b/example/package-lock.json index 5e0c89a57..c59c06d48 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -37,7 +37,7 @@ }, "..": { "name": "@remoteoss/remote-flows", - "version": "1.6.0", + "version": "1.11.0", "dependencies": { "@hookform/resolvers": "^4.1.3", "@radix-ui/react-accordion": "^1.2.12", @@ -54,7 +54,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", - "@remoteoss/remote-json-schema-form-kit": "github:remoteoss/remote-json-schema-form-kit#update-types-kit", + "@remoteoss/remote-json-schema-form-kit": "github:remoteoss/remote-json-schema-form-kit#v0.0.5", "@tailwindcss/cli": "^4.1.17", "@tailwindcss/postcss": "^4.1.17", "@tanstack/react-query": "^5.90.10", @@ -117,7 +117,7 @@ "vitest": "^4.0.13" }, "engines": { - "node": ">=24" + "node": ">=20" }, "peerDependencies": { "react": "^18.3.1", diff --git a/example/src/App.tsx b/example/src/App.tsx index 3c23aaf62..1f44d4a07 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -44,7 +44,9 @@ import CostCalculatorWithReplaceableComponentsCode from './CostCalculatorWithRep import TerminationCode from './Termination?raw'; import ContractAmendmentCode from './ContractAmendment?raw'; import { ContractorOnboardingForm } from './ContractorOnboarding'; +import { CreateCompanyForm } from './CreateCompany'; import ContractorOnboardingCode from './ContractorOnboarding?raw'; +import CreateCompanyCode from './CreateCompany?raw'; const costCalculatorDemos = [ { @@ -148,6 +150,14 @@ const additionalDemos = [ component: ContractorOnboardingForm, sourceCode: ContractorOnboardingCode, }, + { + id: 'create-company', + title: 'Create Company', + description: 'Create a company', + component: CreateCompanyForm, + sourceCode: CreateCompanyCode, + }, + ]; const demoStructure = [ diff --git a/example/src/CreateCompany.tsx b/example/src/CreateCompany.tsx new file mode 100644 index 000000000..e9486d1a7 --- /dev/null +++ b/example/src/CreateCompany.tsx @@ -0,0 +1,229 @@ +import { + SelectCountrySuccess, + SelectCountryFormPayload, + NormalizedFieldError, + CreateCompanyFlow, + CreateCompanyRenderProps, + JSFCustomComponentProps, + CompanyAddressDetailsFormPayload, + CompanyAddressDetailsSuccess, +} from '@remoteoss/remote-flows'; +import { + Card, + Tabs, + TabsTrigger, + TabsList, +} from '@remoteoss/remote-flows/internals'; +import React, { useState } from 'react'; +import { RemoteFlows } from './RemoteFlows'; +import { AlertError } from './AlertError'; +import './css/main.css'; +import './css/contractor-onboarding.css'; + +const Switcher = (props: JSFCustomComponentProps) => { + return ( + { + props.setValue(value); + }} + > + + {props.options?.map((option) => ( + + {option.label} + + ))} + + + ); +}; + +const STEPS = [ + 'Select Country', + 'Address Details', +]; + +type MultiStepFormProps = { + createCompanyBag: CreateCompanyRenderProps['createCompanyBag']; + components: CreateCompanyRenderProps['components']; +}; + +const MultiStepForm = ({ + components, + createCompanyBag, +}: MultiStepFormProps) => { + const { + SubmitButton, + SelectCountryStep, + AddressDetailsStep, + } = components; + const [errors, setErrors] = useState<{ + apiError: string; + fieldErrors: NormalizedFieldError[]; + }>({ + apiError: '', + fieldErrors: [], + }); + + switch (createCompanyBag.stepState.currentStep.name) { + case 'select_country': + return ( +
+ + console.log('payload', payload) + } + onSuccess={(response: SelectCountrySuccess) => + console.log('response', response) + } + onError={({ + error, + fieldErrors, + }: { + error: Error; + fieldErrors: NormalizedFieldError[]; + }) => setErrors({ apiError: error.message, fieldErrors })} + /> + +
+ + Continue + +
+
+ ); + case 'address_details': + return ( +
+ + console.log('address details payload', payload) + } + onSuccess={(response: CompanyAddressDetailsSuccess) => + console.log('address details response', response) + } + onError={({ + error, + fieldErrors, + }: { + error: Error; + fieldErrors: NormalizedFieldError[]; + }) => setErrors({ apiError: error.message, fieldErrors })} + /> + +
+ + + Complete + +
+
+ ); + default: + return null; + } +}; + +const CreateCompanyRender = ({ + createCompanyBag, + components, +}: MultiStepFormProps) => { + const currentStepIndex = createCompanyBag.stepState.currentStep.index; + + return ( + <> +
+ +
+ + {createCompanyBag.isLoading ? ( +
+

Loading...

+
+ ) : ( + + )} + + ); +}; + +const Header = () => { + return ( +
+

Create Company

+

Create a new company and complete the address details.

+
+ ); +}; + +type CreateCompanyFormData = { + countryCode?: string; +}; + +export const CreateCompanyWithProps = ({ +}: CreateCompanyFormData) => { + return ( +
+ +
+
+ + + +
+
+
+ ); +}; + +export const CreateCompanyForm = () => { + const [formData] = useState({ + }); + const [showOnboarding, setShowOnboarding] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setShowOnboarding(true); + }; + + if (showOnboarding) { + return ; + } + + return ( +
+ +
+ ); +}; diff --git a/src/auth/createClient.ts b/src/auth/createClient.ts index 58950e048..01788f972 100644 --- a/src/auth/createClient.ts +++ b/src/auth/createClient.ts @@ -25,7 +25,7 @@ function isValidUrl(url: string) { } export function createClient( - auth: () => Promise, + auth?: () => Promise, options?: Options, ) { const sessionRef = { @@ -50,6 +50,7 @@ export function createClient( return createHeyApiClient({ ...clientConfig, + credentials: 'include', headers: { ...clientConfig.headers, ...(isValidProxy ? options?.proxy?.headers : {}), @@ -58,6 +59,10 @@ export function createClient( }, baseUrl: isValidProxy ? options.proxy?.url : baseUrl, auth: async () => { + if (!auth) { + return undefined; + } + function hasTokenExpired(expiresAt: number | undefined) { return !expiresAt || Date.now() + 60000 > expiresAt; } @@ -77,6 +82,7 @@ export function createClient( return undefined; } } + return sessionRef.current?.accessToken; }, }); diff --git a/src/flows/CreateCompany/CreateCompany.tsx b/src/flows/CreateCompany/CreateCompany.tsx new file mode 100644 index 000000000..3198bb4b6 --- /dev/null +++ b/src/flows/CreateCompany/CreateCompany.tsx @@ -0,0 +1,33 @@ + +import { CreateCompanyFlowProps} from '@/src/flows/CreateCompany/types' +import { useCreateCompany} from '@/src/flows/CreateCompany/hooks' +import { CreateCompanyContext} from '@/src/flows/CreateCompany/context' +import { SelectCountryStep } from '@/src/flows/CreateCompany/components/SelectCountryStep'; +import { AddressDetailsStep } from '@/src/flows/CreateCompany/components/AddressDetailsStep'; +import { CreateCompanySubmit } from '@/src/flows/CreateCompany/components/CreateCompanySubmit'; +import { useId } from 'react'; +export const CreateCompanyFlow = ({ + render, + countryCode, + options, +}: CreateCompanyFlowProps) => { + const createCompanyBag = useCreateCompany({ + options, + countryCode, + }); + const formId = useId(); + return ( + + {render({ + createCompanyBag, + components: { + SelectCountryStep: SelectCountryStep, + AddressDetailsStep: AddressDetailsStep, + SubmitButton: CreateCompanySubmit + }, + })} + + ); +}; diff --git a/src/flows/CreateCompany/api.ts b/src/flows/CreateCompany/api.ts new file mode 100644 index 000000000..3a3bea616 --- /dev/null +++ b/src/flows/CreateCompany/api.ts @@ -0,0 +1,58 @@ +import { useMutation} from '@tanstack/react-query'; +import { useClient } from '@/src/context'; +import { Client } from '@/src/client/client'; + +import { + CreateCompanyParams, + postCreateCompany, + patchUpdateCompany2, + UpdateCompanyParams, +} from '@/src/client'; + + +export const useCreateCompanyRequest = () => { + const { client } = useClient(); + return useMutation({ + mutationFn: (payload: CreateCompanyParams) => { + console.log("FIRING REQUEST", payload) + return postCreateCompany({ + client: client as Client, + headers: { + Authorization: ``, + }, + body: payload, + }); + }, + }); +}; + +export const useUpdateCompanyRequest = () => { + const { client } = useClient(); + return useMutation({ + mutationFn: ({ + companyId, + payload, + jsonSchemaVersion, + }: { + companyId: string; + payload: UpdateCompanyParams; + jsonSchemaVersion?: number | 'latest'; + }) => { + return patchUpdateCompany2({ + client: client as Client, + headers: { + Authorization: ``, + }, + path: { + company_id: companyId, + }, + body: payload, + query: { + address_details_json_schema_version: jsonSchemaVersion, + }, + }); + }, + }); +}; + + diff --git a/src/flows/CreateCompany/components/AddressDetailsStep.tsx b/src/flows/CreateCompany/components/AddressDetailsStep.tsx new file mode 100644 index 000000000..f7b2b733f --- /dev/null +++ b/src/flows/CreateCompany/components/AddressDetailsStep.tsx @@ -0,0 +1,76 @@ +import { + CompanyAddressDetailsFormPayload, + CompanyAddressDetailsSuccess, +} from '@/src/flows/CreateCompany/types'; +import { NormalizedFieldError } from '@/src/lib/mutations'; +import { $TSFixMe } from '@/src/types/remoteFlows'; +import { useCreateCompanyContext } from '@/src/flows/CreateCompany/context'; +import { CreateCompanyForm } from '@/src/flows/CreateCompany/components/CreateCompanyForm'; +import { handleStepError } from '@/src/lib/utils'; + +type AddressDetailsStepProps = { + /* + * The function is called when the form is submitted. It receives the form values as an argument. + */ + onSubmit?: (payload: CompanyAddressDetailsFormPayload) => void | Promise; + /* + * The function is called when the form submission is successful. + */ + onSuccess?: (data: CompanyAddressDetailsSuccess) => void | Promise; + /* + * The function is called when an error occurs during form submission. + */ + onError?: ({ + error, + rawError, + fieldErrors, + }: { + error: Error; + rawError: Record; + fieldErrors: NormalizedFieldError[]; + }) => void; +}; + +export function AddressDetailsStep({ + onSubmit, + onSuccess, + onError, +}: AddressDetailsStepProps) { + const { createCompanyBag } = useCreateCompanyContext(); + const handleSubmit = async (payload: $TSFixMe) => { + try { + await onSubmit?.(payload); + const response = await createCompanyBag.onSubmit(payload); + if (response?.data) { + await onSuccess?.(response?.data as CompanyAddressDetailsSuccess); + return; + } + if (response?.error) { + const structuredError = handleStepError( + response, + createCompanyBag.meta?.fields?.address_details, + ); + onError?.(structuredError); + } + } catch (error: unknown) { + const structuredError = handleStepError( + error, + createCompanyBag.meta?.fields?.address_details, + ); + + onError?.(structuredError); + } + }; + + const initialValues = + createCompanyBag.stepState.values?.address_details || + createCompanyBag.initialValues.address_details; + + return ( + + ); +} + diff --git a/src/flows/CreateCompany/components/CreateCompanyForm.tsx b/src/flows/CreateCompany/components/CreateCompanyForm.tsx new file mode 100644 index 000000000..62a54bb17 --- /dev/null +++ b/src/flows/CreateCompany/components/CreateCompanyForm.tsx @@ -0,0 +1,66 @@ +import { JSFFields } from '@/src/types/remoteFlows'; +import { JSONSchemaFormFields } from '@/src/components/form/JSONSchemaForm'; +import { Form } from '@/src/components/ui/form'; +import { useForm } from 'react-hook-form'; +import { useJsonSchemasValidationFormResolver } from '@/src/components/form/validationResolver'; +import { Components } from '@/src/types/remoteFlows'; +import { useCreateCompanyContext } from '@/src/flows/CreateCompany/context'; +import { useEffect } from 'react'; + +type CreateCompanyFormProps = { + onSubmit: (payload: Record) => Promise; + components?: Components; + fields?: JSFFields; + defaultValues: Record; +}; + +export function CreateCompanyForm({ + defaultValues, + onSubmit, + components, +}: CreateCompanyFormProps) { + const { formId, createCompanyBag, formRef } = + useCreateCompanyContext(); + + const resolver = useJsonSchemasValidationFormResolver( + createCompanyBag.handleValidation, + ); + + const form = useForm({ + resolver, + defaultValues, + shouldUnregister: false, + mode: 'onBlur', + }); + + // Register the form's setValue method with the context so other components can access it + useEffect(() => { + if (formRef?.setValue) { + formRef.setValue.current = form.setValue; + } + }, [form.setValue, formRef]); + + const handleSubmit = async (values: Record) => { + await onSubmit(values); + }; + + return ( +
+ + + + + ); +} diff --git a/src/flows/CreateCompany/components/CreateCompanySubmit.tsx b/src/flows/CreateCompany/components/CreateCompanySubmit.tsx new file mode 100644 index 000000000..343b73158 --- /dev/null +++ b/src/flows/CreateCompany/components/CreateCompanySubmit.tsx @@ -0,0 +1,28 @@ +import { ButtonHTMLAttributes, PropsWithChildren } from 'react'; +import { useCreateCompanyContext } from '@/src/flows/CreateCompany/context'; +import { useFormFields } from '@/src/context'; + +export function CreateCompanySubmit({ + children, + ...props +}: PropsWithChildren< + ButtonHTMLAttributes & Record +>) { + const { formId } = useCreateCompanyContext(); + const { components } = useFormFields(); + + const CustomButton = components?.button; + if (!CustomButton) { + throw new Error(`Button component not found`); + } + + return ( + + {children} + + ); +} diff --git a/src/flows/CreateCompany/components/SelectCountryStep.tsx b/src/flows/CreateCompany/components/SelectCountryStep.tsx new file mode 100644 index 000000000..1a9d3fc03 --- /dev/null +++ b/src/flows/CreateCompany/components/SelectCountryStep.tsx @@ -0,0 +1,83 @@ +// TODO: Correct types later +import { + CompanyBasicInfoFormPayload, + CompanyBasicInfoSuccess, +} from '@/src/flows/CreateCompany/types'; +import { NormalizedFieldError } from '@/src/lib/mutations'; +import { $TSFixMe } from '@/src/types/remoteFlows'; +import { useCreateCompanyContext } from '@/src/flows/CreateCompany/context'; +import { CreateCompanyForm } from '@/src/flows/CreateCompany/components/CreateCompanyForm'; +import { handleStepError } from '@/src/lib/utils'; + +type SelectCountryStepProps = { + /* + * The function is called when the form is submitted. It receives the form values as an argument. + */ + onSubmit?: (payload: CompanyBasicInfoFormPayload) => void | Promise; + /* + * The function is called when the form submission is successful. + */ + onSuccess?: (data: CompanyBasicInfoSuccess) => void | Promise; + /* + * The function is called when an error occurs during form submission. + */ + onError?: ({ + error, + rawError, + fieldErrors, + }: { + error: Error; + rawError: Record; + fieldErrors: NormalizedFieldError[]; + }) => void; +}; + +export function SelectCountryStep({ + onSubmit, + onSuccess, + onError, +}: SelectCountryStepProps) { + const { createCompanyBag } = useCreateCompanyContext(); + const handleSubmit = async (payload: $TSFixMe) => { + try { + await onSubmit?.({ countryCode: payload.country_code, + companyOwnerEmail: payload.company_owner_email, + companyOwnerName: payload.company_owner_name, + desiredCurrency: payload.desired_currency, + phoneNumber: payload.phone_number, + taxNumber: payload.tax_number + }); + const response = await createCompanyBag.onSubmit(payload); + if (response?.data) { + await onSuccess?.(response?.data as CompanyBasicInfoSuccess); + createCompanyBag?.next(); + return; + } + if (response?.error) { + const structuredError = handleStepError( + response, + createCompanyBag.meta?.fields?.select_country, + ); + onError?.(structuredError); + } + } catch (error: unknown) { + const structuredError = handleStepError( + error, + createCompanyBag.meta?.fields?.select_country, + ); + + onError?.(structuredError); + } + }; + + const initialValues = + createCompanyBag.stepState.values?.select_country || + createCompanyBag.initialValues.select_country; + + return ( + + ); +} diff --git a/src/flows/CreateCompany/context.ts b/src/flows/CreateCompany/context.ts new file mode 100644 index 000000000..0f6b4ed88 --- /dev/null +++ b/src/flows/CreateCompany/context.ts @@ -0,0 +1,29 @@ +import type { useCreateCompany } from '@/src/flows/CreateCompany/hooks'; +import { createContext, useContext, RefObject } from 'react'; +import { UseFormSetValue } from 'react-hook-form'; +export const CreateCompanyContext = createContext<{ + formId: string | undefined; + createCompanyBag: ReturnType | null; + formRef?: { + setValue: RefObject> | undefined>; + }; +}>({ + formId: undefined, + createCompanyBag: null, + formRef: undefined, +}); + +export const useCreateCompanyContext = () => { + const context = useContext(CreateCompanyContext); + if (!context.formId || !context.createCompanyBag) { + throw new Error( + 'useCreateCompanyContext must be used within a CreateCompanyContextProvider', + ); + } + + return { + formId: context.formId, + createCompanyBag: context.createCompanyBag, + formRef: context.formRef, + } as const; +}; diff --git a/src/flows/CreateCompany/hooks.ts b/src/flows/CreateCompany/hooks.ts new file mode 100644 index 000000000..b573147a8 --- /dev/null +++ b/src/flows/CreateCompany/hooks.ts @@ -0,0 +1,511 @@ +import { useMemo, useRef, useState } from 'react'; +import { FieldValues } from 'react-hook-form'; +import { useQuery } from '@tanstack/react-query'; +import { JSFFields } from '@/src/types/remoteFlows'; +import { Client } from '@/src/client/client'; +import { + getInitialValues, +} from '@/src/components/form/utils'; +import { + FlowOptions, +} from '@/src/flows/types'; +import { ValidationResult } from '@remoteoss/remote-json-schema-form-kit'; +import { CreateCompanyFlowProps } from '@/src/flows/CreateCompany/types'; +import { + CreateCompanyParams, + getShowFormCountry, + UpdateCompanyParams, +} from '@/src/client'; + +import { createHeadlessForm } from '@/src/common/createHeadlessForm'; +import { useClient } from '@/src/context'; +import { selectCountryStepSchema } from '@/src/flows/CreateCompany/json-schemas/selectCountryStep'; +import { + STEPS, +} from '@/src/flows/CreateCompany/utils'; +import { + getSupportedCountry, + getIndexCompanyCurrency, +} from '@/src/client'; + +import { Step, useStepState } from '@/src/flows/useStepState'; +import { createStructuredError, prettifyFormValues } from '@/src/lib/utils'; +import { JSFFieldset, Meta } from '@/src/types/remoteFlows'; +import { mutationToPromise } from '@/src/lib/mutations'; +import { + useCreateCompanyRequest, + useUpdateCompanyRequest, +} from '@/src/flows/CreateCompany/api'; + +type useCreateCompanyProps = Omit< + CreateCompanyFlowProps, + 'render' +>; + +const useCountries = (queryOptions?: { enabled?: boolean }) => { + const { client } = useClient(); + return useQuery({ + ...queryOptions, + queryKey: ['countries'], + retry: false, + queryFn: async () => { + const response = await getSupportedCountry({ + client: client as Client, + headers: { + Authorization: ``, + }, + }); + + // If response status is 404 or other error, throw an error to trigger isError + if (response.error || !response.data) { + throw new Error('Failed to fetch supported countries'); + } + + return response; + }, + select: ({ data }) => { + return ( + data?.data + ?.filter((country) => country.eor_onboarding) + .map((country) => { + return { + label: country.name, + value: country.code, + }; + }) || [] + ); + }, + }); +}; +const useCompanyCurrencies = (queryOptions?: { enabled?: boolean }) => { + const { client } = useClient(); + return useQuery({ + ...queryOptions, + queryKey: ['company-currencies'], + retry: false, + queryFn: async () => { + const response = await getIndexCompanyCurrency({ + client: client as Client, + headers: { + Authorization: ``, + }, + }); + + if (response.error || !response.data) { + throw new Error('Failed to fetch company currencies'); + } + + return response; + }, + select: ({ data }) => { + return ( + data?.data?.company_currencies.map((currency) => ({ + value: currency.code, + label: currency.code, + })) || [] + ); + }, + }); +}; + +const useCountriesSchemaField = ( + options?: Omit & { + queryOptions?: { enabled?: boolean }; + }, +) => { + const { data: countries, isLoading: isLoadingCountries } = useCountries(options?.queryOptions); + const { data: currencies, isLoading: isLoadingCurrencies } = useCompanyCurrencies(options?.queryOptions); + + const selectCountryForm = createHeadlessForm( + selectCountryStepSchema.data.schema, + {}, + options, + ); + + if (countries) { + const countryField = selectCountryForm.fields.find( + (field) => field.name === 'country_code', + ); + if (countryField) { + countryField.options = countries; + } + } + + if (currencies) { + const currencyField = selectCountryForm.fields.find( + (field) => field.name === 'desired_currency', + ); + if (currencyField) { + currencyField.options = currencies; + } + } + + return { + isLoading: isLoadingCountries || isLoadingCurrencies, + selectCountryForm, + }; +}; + + +function nowUtcFormatted() { + const now = new Date(); + + const pad = (n: number) => String(n).padStart(2, '0'); + + return ( + now.getUTCFullYear() + '-' + + pad(now.getUTCMonth() + 1) + '-' + + pad(now.getUTCDate()) + ' ' + + pad(now.getUTCHours()) + ':' + + pad(now.getUTCMinutes()) + ':' + + pad(now.getUTCSeconds()) + 'Z' + ); +} +const useAddressDetailsSchema = ({ + countryCode, + fieldValues, + options, +}: { + countryCode: string | null; + fieldValues: FieldValues; + options?: FlowOptions & { queryOptions?: { enabled?: boolean } }; +}) => { + const { client } = useClient(); + return useQuery({ + queryKey: ['company-address-details-schema', countryCode], + retry: false, + queryFn: async () => { + if (!countryCode) { + throw new Error('Country code is required'); + } + const response = await getShowFormCountry({ + client: client as Client, + headers: { + Authorization: ``, + }, + path: { + country_code: countryCode, + form: 'address_details', + }, + query: { + json_schema_version: options?.jsonSchemaVersion?.form_schema?.address_details || 'latest', + }, + }); + + if (response.error || !response.data) { + throw new Error('Failed to fetch address details schema'); + } + + return response; + }, + enabled: options?.queryOptions?.enabled && !!countryCode, + select: ({ data }) => { + const jsfSchema = data?.data || {}; + return createHeadlessForm(jsfSchema, fieldValues, options); + }, + }); +}; + +export const useCreateCompany = ({ + countryCode, + options, +}: useCreateCompanyProps) => { + +const createCompanyMutation = useCreateCompanyRequest(); +const updateCompanyMutation = useUpdateCompanyRequest(); + +const { mutateAsync: createCompanyMutationAsync } = mutationToPromise( + createCompanyMutation, +); +const { mutateAsync: updateCompanyMutationAsync } = mutationToPromise( + updateCompanyMutation, +); + + const [internalCountryCode, setInternalCountryCode] = useState( + countryCode || null, + ); + const [createdCompanyId, setCreatedCompanyId] = useState(null); + const fieldsMetaRef = useRef<{ + select_country: Meta; + address_details: Meta; + }>({ + select_country: {}, + address_details: {}, + }); + + const { + fieldValues, + stepState, + setFieldValues, + previousStep, + nextStep, + goToStep, + } = useStepState( + STEPS as Record>, + ); + + + const { selectCountryForm, isLoading: isLoadingCountries } = + useCountriesSchemaField({ + jsfModify: options?.jsfModify?.select_country, + queryOptions: { + enabled: stepState.currentStep.name === 'select_country', + }, + }); + + const { data: addressDetailsForm, isLoading: isLoadingAddressDetails } = + useAddressDetailsSchema({ + countryCode: internalCountryCode, + fieldValues: fieldValues.address_details || {}, + options: { + ...options, + jsfModify: options?.jsfModify?.address_details, + queryOptions: { + enabled: + stepState.currentStep.name === 'address_details' && + !!internalCountryCode && + !!createdCompanyId, + }, + }, + }); + + const stepFields: Record = useMemo( + () => ({ + select_country: selectCountryForm?.fields || [], + address_details: addressDetailsForm?.fields || [], + }), + [ + selectCountryForm?.fields, + addressDetailsForm?.fields, + ], + ); + + const stepFieldsWithFlatFieldsets: Record< + keyof typeof STEPS, + JSFFieldset | null | undefined + > = { + select_country: null, + address_details: addressDetailsForm?.meta?.['x-jsf-fieldsets'] || null, + }; + + + const selectCountryInitialValues = useMemo( + () => + getInitialValues(stepFields.select_country, { + country: internalCountryCode || '', + }), + [stepFields.select_country, internalCountryCode], + ); + + const addressDetailsInitialValues = useMemo( + () => + getInitialValues(stepFields.address_details, {}), + [stepFields.address_details], + ); + + const initialValues = useMemo(() => { + return { + select_country: selectCountryInitialValues, + address_details: addressDetailsInitialValues, + }; + }, [ + selectCountryInitialValues, + addressDetailsInitialValues, + ]); + + const goTo = (step: keyof typeof STEPS) => { + goToStep(step); + }; + + const parseFormValues = async (values: FieldValues) => { + if (selectCountryForm && stepState.currentStep.name === 'select_country') { + return values; + } + if (addressDetailsForm && stepState.currentStep.name === 'address_details') { + return values; + } + return {}; + }; + + async function onSubmit(values: FieldValues) { + const currentStepName = stepState.currentStep.name; + if (currentStepName in fieldsMetaRef.current) { + fieldsMetaRef.current[ + currentStepName as keyof typeof fieldsMetaRef.current + ] = prettifyFormValues(values, stepFields[currentStepName]); + } + const parsedValues = await parseFormValues(values); + switch (stepState.currentStep.name) { + case 'select_country': { + setInternalCountryCode(parsedValues.country_code); + const payload: CreateCompanyParams = { + country_code: parsedValues.country_code, + company_owner_email: parsedValues.company_owner_email, + company_owner_name: parsedValues.company_owner_name, + desired_currency: parsedValues.desired_currency, + name: parsedValues.name, + phone_number: parsedValues.phone_number, + tax_number: parsedValues.tax_number, + terms_of_service_accepted_at: nowUtcFormatted(), + }; + + const response = await createCompanyMutationAsync(payload); + + // Check for errors from the mutation + if (response.error) { + return Promise.resolve({ + data: null, + error: response.error, + rawError: response.rawError, + fieldErrors: response.fieldErrors, + }); + } + + // Handle both CompanyResponse and CompanyWithTokensResponse structures + // CompanyResponse: { data: { company?: Company } } + // CompanyWithTokensResponse: { company?: Company; tokens?: OAuth2Tokens } + const responseData = response.data?.data; + let companyId: string | undefined; + if (responseData) { + if ('data' in responseData && responseData.data) { + // CompanyResponse structure: response.data.data.data.company.id + companyId = (responseData.data as { company?: { id: string } }).company?.id; + } else if ('company' in responseData) { + // CompanyWithTokensResponse structure: response.data.data.company.id + companyId = (responseData as { company?: { id: string } }).company?.id; + } + } + if (companyId) { + setCreatedCompanyId(companyId); + } + return Promise.resolve({ data: { countryCode: parsedValues.country_code } }); + } + + case 'address_details': { + if (!createdCompanyId) { + throw createStructuredError('Company ID is required to update address details'); + } + const payload: UpdateCompanyParams = { + address_details: parsedValues, + }; + + const response = await updateCompanyMutationAsync({ + companyId: createdCompanyId, + payload, + jsonSchemaVersion: options?.jsonSchemaVersion?.form_schema?.address_details, + }); + + // Check for errors from the mutation + if (response.error) { + return Promise.resolve({ + data: null, + error: response.error, + rawError: response.rawError, + fieldErrors: response.fieldErrors, + }); + } + + return Promise.resolve({ data: response.data }); + } + + default: { + throw createStructuredError('Invalid step state'); + } + } + } + + const isLoading = + isLoadingCountries || isLoadingAddressDetails + + return { + /** + * Loading state indicating if the flow is loading data + */ + isLoading, + + /** + * Current state of the form fields for the current step. + */ + fieldValues, + + /** + * Current step state containing the current step and total number of steps + */ + stepState, + + /** + * Function to update the current form field values + * @param values - New form values to set + */ + checkFieldUpdates: setFieldValues, + + /** + * Function to handle going back to the previous step + * @returns {void} + */ + back: previousStep, + + /** + * Function to handle going to the next step + * @returns {void} + */ + next: nextStep, + + /** + * Function to handle going to a specific step + * @param step The step to go to. + * @returns {void} + */ + goTo: goTo, + + /** + * Function to handle form submission + * @param values - Form values to submit + * @returns Promise resolving to the mutation result + */ + onSubmit, + + /** + * Array of form fields from the onboarding schema + */ + fields: stepFields[stepState.currentStep.name], + + /** + * Fields metadata for each step + */ + meta: { + fields: fieldsMetaRef.current, + fieldsets: stepFieldsWithFlatFieldsets[stepState.currentStep.name], + }, + + /** + * Function to parse form values before submission + * @param values - Form values to parse + * @returns Parsed form values + */ + parseFormValues, + + /** + * Function to validate form values against the onboarding schema + * @param values - Form values to validate + * @returns Validation result or null if no schema is available + */ + handleValidation: async ( + values: FieldValues, + ): Promise => { + if (stepState.currentStep.name === 'select_country') { + return selectCountryForm.handleValidation(values); + } + if (stepState.currentStep.name === 'address_details' && addressDetailsForm) { + return addressDetailsForm.handleValidation(values); + } + + return null; + }, + + /** + * Initial form values + */ + initialValues, + }; +}; diff --git a/src/flows/CreateCompany/index.ts b/src/flows/CreateCompany/index.ts new file mode 100644 index 000000000..031485cbc --- /dev/null +++ b/src/flows/CreateCompany/index.ts @@ -0,0 +1,5 @@ +export {CreateCompanyFlow} from './CreateCompany'; +export type { + CreateCompanyFlowProps, + CreateCompanyRenderProps, +} from './types'; diff --git a/src/flows/CreateCompany/json-schemas/selectCountryStep.ts b/src/flows/CreateCompany/json-schemas/selectCountryStep.ts new file mode 100644 index 000000000..30b1096fe --- /dev/null +++ b/src/flows/CreateCompany/json-schemas/selectCountryStep.ts @@ -0,0 +1,82 @@ +export const selectCountryStepSchema = { + data: { + version: 7, + schema: { + additionalProperties: false, + properties: { + country_code: { + title: 'Country', + description: '', + type: 'string', + oneOf: [], + 'x-jsf-presentation': { + inputType: 'select', + }, + }, + company_owner_email: { + title: 'Company Owner Email', + description: '', + type: 'string', + oneOf: [], + 'x-jsf-presentation': { + inputType: 'text', + }, + + }, + company_owner_name: { + title: 'Company Owner Name', + description: '', + type: 'string', + oneOf: [], + 'x-jsf-presentation': { + inputType: 'text', + }, + + }, + desired_currency: { + title: 'Desired Currency', + description: '', + type: 'string', + oneOf: [], + 'x-jsf-presentation': { + inputType: 'select', + }, + + }, + name: { + title: 'Name', + description: '', + type: 'string', + oneOf: [], + 'x-jsf-presentation': { + inputType: 'text', + }, + + }, + phone_number: { + title: 'Phone Number', + description: '', + type: 'string', + oneOf: [], + 'x-jsf-presentation': { + inputType: 'text', + }, + + }, + tax_number: { + title: 'Tax Number', + description: '', + type: 'string', + oneOf: [], + 'x-jsf-presentation': { + inputType: 'text', + }, + + }, + }, + required: ['country_code', 'company_owner_email'], + type: 'object', + 'x-jsf-order': ['country_code', 'company_owner_email'], + }, + }, +}; diff --git a/src/flows/CreateCompany/types.ts b/src/flows/CreateCompany/types.ts new file mode 100644 index 000000000..cc2845b1f --- /dev/null +++ b/src/flows/CreateCompany/types.ts @@ -0,0 +1,87 @@ +import { useCreateCompany } from '@/src/flows/CreateCompany/hooks'; +import { SelectCountryStep } from '@/src/flows/CreateCompany/components/SelectCountryStep'; +import { AddressDetailsStep } from '@/src/flows/CreateCompany/components/AddressDetailsStep'; +import { FlowOptions, JSFModify } from '@/src/flows/types'; +import { CreateCompanySubmit } from '@/src/flows/CreateCompany/components/CreateCompanySubmit'; + +type countryFormFields = { + countryCode: string; + companyOwnerEmail: string; + companyOwnerName: string; + desiredCurrency: string; + phoneNumber: string; + taxNumber: string; +} + +export type CompanyBasicInfoFormPayload = countryFormFields; + +export type CompanyBasicInfoSuccess = countryFormFields; + +export type CompanyAddressDetailsFormPayload = Record; + +export type CompanyAddressDetailsSuccess = Record; + +export type CreateCompanyRenderProps = { + /** + * The create company bag returned by the useCreateCompany hook. + * This bag contains all the methods and properties needed to handle the create company flow. + * @see {@link useCreateCompany} + */ + createCompanyBag: ReturnType; + /** + * The components used in the create company flow. + * @see {@link SelectCountryStep} + * @see {@link AddressDetailsStep} + */ + components: { + SelectCountryStep: typeof SelectCountryStep; + AddressDetailsStep: typeof AddressDetailsStep; + SubmitButton: typeof CreateCompanySubmit; + + }; +}; + +export type CreateCompanyFlowProps = { + /** + * The country code to use for the onboarding. + */ + countryCode?: string; + + + /** + * The render prop function with the params passed by the useCreateCompany hook and the components available to use for this flow + */ + render: ({ + createCompanyBag, + components, + }: CreateCompanyRenderProps) => React.ReactNode; + /** + * The options for the create company flow. + */ + options?: Omit & { + jsfModify?: { + select_country?: JSFModify; + address_details?: JSFModify; + }; + }; + + /** + * Initial values to pre-populate the form fields. + * These are flat field values that will be automatically mapped to the correct step. + * Server data will override these values. This happens when you pass employmentId and the server returns an employment object. + */ + initialValues?: Record; +}; + + +export type BasicInformationFormPayload = { + company_owner_email: string; + company_owner_name: string; + country_code: string; + desired_currency: string; + name: string; + phone_number: string; + tax_number: string; + tax_servicing_countries: string[]; +}; + diff --git a/src/flows/CreateCompany/utils.ts b/src/flows/CreateCompany/utils.ts new file mode 100644 index 000000000..88c5e183d --- /dev/null +++ b/src/flows/CreateCompany/utils.ts @@ -0,0 +1,10 @@ +import { Step } from '@/src/flows/useStepState'; + +type StepKeys = + | 'select_country' + | 'address_details'; + +export const STEPS: Record> = { + select_country: { index: 0, name: 'select_country' }, + address_details: { index: 1, name: 'address_details' }, +} as const; diff --git a/src/index.tsx b/src/index.tsx index d2cda1d92..07b13b3ee 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -83,6 +83,14 @@ export type { export type { ContractPreviewStatementProps } from '@/src/flows/ContractorOnboarding/components/ContractPreviewStatement'; +export { CreateCompanyFlow } from '@/src/flows/CreateCompany'; +export type { + CreateCompanyFlowProps, + CreateCompanyRenderProps, +} from '@/src/flows/CreateCompany'; + + + export type * from '@/src/flows/CostCalculator/types'; export { useMagicLink } from '@/src/common/api';