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 (
+ <>
+
+
+ {STEPS.map((step, index) => (
+ -
+ {index + 1}. {step}
+
+ ))}
+
+
+
+ {createCompanyBag.isLoading ? (
+
+ ) : (
+
+ )}
+ >
+ );
+};
+
+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';