From 5dcdac7ecd6190c1ad6b9dc2fd103f8bfc073a1c Mon Sep 17 00:00:00 2001 From: Ted Liang Date: Wed, 18 Mar 2026 16:17:23 +1100 Subject: [PATCH] feat: support language in embedding (#2364) --- .../developers/embedding/authoring/v1.mdx | 1 + .../developers/embedding/authoring/v2.mdx | 1 + .../docs/developers/embedding/index.mdx | 2 + .../embed-direct-template-client-page.tsx | 13 +++++- .../embed/embed-document-signing-page-v1.tsx | 13 +++++- .../embed/embed-document-signing-page-v2.tsx | 13 +++++- apps/remix/app/routes/embed+/playground.tsx | 43 ++++++++++++++++++- .../routes/embed+/v1+/authoring+/_layout.tsx | 27 +++++++++++- .../routes/embed+/v2+/authoring+/_layout.tsx | 27 ++++++++++-- packages/lib/constants/i18n.ts | 3 -- packages/lib/constants/locales.ts | 4 ++ packages/lib/types/embed-base-schemas.ts | 3 ++ .../document-global-auth-access-select.tsx | 2 +- .../document-global-auth-action-select.tsx | 2 +- .../document/document-visibility-select.tsx | 2 +- .../recipient-action-auth-select.tsx | 2 +- packages/ui/primitives/field-selector.tsx | 6 +-- 17 files changed, 142 insertions(+), 22 deletions(-) diff --git a/apps/docs/content/docs/developers/embedding/authoring/v1.mdx b/apps/docs/content/docs/developers/embedding/authoring/v1.mdx index 9d33e57e9..f10763fb3 100644 --- a/apps/docs/content/docs/developers/embedding/authoring/v1.mdx +++ b/apps/docs/content/docs/developers/embedding/authoring/v1.mdx @@ -141,6 +141,7 @@ const TemplateEditor = ({ presignToken, templateId }) => { | `css` | `string` | No | Custom CSS string (Platform Plan) | | `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) | | `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) | +| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) | | `className` | `string` | No | CSS class for the iframe | | `features` | `object` | No | Feature toggles for the authoring experience | diff --git a/apps/docs/content/docs/developers/embedding/authoring/v2.mdx b/apps/docs/content/docs/developers/embedding/authoring/v2.mdx index 8836548f1..ef4091164 100644 --- a/apps/docs/content/docs/developers/embedding/authoring/v2.mdx +++ b/apps/docs/content/docs/developers/embedding/authoring/v2.mdx @@ -110,6 +110,7 @@ const EnvelopeEditor = ({ presignToken, envelopeId }) => { | `css` | `string` | No | Custom CSS string (Platform Plan) | | `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) | | `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) | +| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) | | `className` | `string` | No | CSS class for the iframe | | `features` | `object` | No | Feature toggles for the authoring experience | diff --git a/apps/docs/content/docs/developers/embedding/index.mdx b/apps/docs/content/docs/developers/embedding/index.mdx index 3161907d4..1b96a457d 100644 --- a/apps/docs/content/docs/developers/embedding/index.mdx +++ b/apps/docs/content/docs/developers/embedding/index.mdx @@ -161,6 +161,7 @@ If you prefer not to use any SDK, you can embed signing using [Direct Links](/do | `css` | `string` | Custom CSS string (Platform Plan). | | `cssVars` | `object` | CSS variable overrides for theming (Platform Plan). | | `darkModeDisabled` | `boolean` | Disable dark mode in the embed (Platform Plan). | +| `language` | `string` | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts). | | `onDocumentReady` | `function` | Called when the document is loaded and ready. | | `onDocumentCompleted` | `function` | Called when signing is completed. | | `onDocumentError` | `function` | Called when an error occurs. | @@ -175,6 +176,7 @@ If you prefer not to use any SDK, you can embed signing using [Direct Links](/do | `host` | `string` | Documenso instance URL. Defaults to `https://app.documenso.com`. | | `name` | `string` | Pre-fill the signer's name. | | `lockName` | `boolean` | Prevent the signer from changing their name. | +| `language` | `string` | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts). | | `onDocumentReady` | `function` | Called when the document is loaded and ready. | | `onDocumentCompleted` | `function` | Called when signing is completed. | | `onDocumentError` | `function` | Called when an error occurs. | diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index 8d86d6944..62b53d308 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -17,6 +17,7 @@ import { useSearchParams } from 'react-router'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema'; @@ -26,6 +27,7 @@ import { } from '@documenso/lib/utils/advanced-fields-helpers'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { trpc } from '@documenso/trpc/react'; import type { @@ -290,12 +292,19 @@ export const EmbedDirectTemplateClientPage = ({ cssVars: data.cssVars, }); } + + if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) { + void dynamicActivate(data.language).finally(() => { + setHasFinishedInit(true); + }); + } else { + setHasFinishedInit(true); + } } catch (err) { console.error(err); + setHasFinishedInit(true); } - setHasFinishedInit(true); - // !: While the two setters are stable we still want to ensure we're avoiding // !: re-renders. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index 149fd30dd..035ac9f0e 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -8,11 +8,13 @@ import { type Field, RecipientRole, SigningStatus } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; +import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import { trpc } from '@documenso/trpc/react'; @@ -232,12 +234,19 @@ export const EmbedSignDocumentV1ClientPage = ({ cssVars: data.cssVars, }); } + + if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) { + void dynamicActivate(data.language).finally(() => { + setHasFinishedInit(true); + }); + } else { + setHasFinishedInit(true); + } } catch (err) { console.error(err); + setHasFinishedInit(true); } - setHasFinishedInit(true); - // !: While the two setters are stable we still want to ensure we're avoiding // !: re-renders. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx index df7def6a5..0df386134 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx @@ -3,8 +3,10 @@ import { useEffect, useLayoutEffect, useState } from 'react'; import { useLingui } from '@lingui/react'; import { EnvelopeType } from '@prisma/client'; +import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; +import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { injectCss } from '~/utils/css-vars'; @@ -162,12 +164,19 @@ export const EmbedSignDocumentV2ClientPage = ({ cssVars: data.cssVars, }); } + + if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) { + void dynamicActivate(data.language).finally(() => { + setHasFinishedInit(true); + }); + } else { + setHasFinishedInit(true); + } } catch (err) { console.error(err); + setHasFinishedInit(true); } - setHasFinishedInit(true); - // !: While the setters are stable we still want to ensure we're avoiding // !: re-renders. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/remix/app/routes/embed+/playground.tsx b/apps/remix/app/routes/embed+/playground.tsx index d3cd08be7..5c4ec297f 100644 --- a/apps/remix/app/routes/embed+/playground.tsx +++ b/apps/remix/app/routes/embed+/playground.tsx @@ -37,6 +37,7 @@ export default function EmbedPlaygroundPage() { () => (searchParams.get('envelopeType') as 'DOCUMENT' | 'TEMPLATE') || 'DOCUMENT', ); const [folderId, setFolderId] = useState(() => searchParams.get('folderId') || ''); + const [language, setLanguage] = useState(() => searchParams.get('language') || ''); // Auto-launch if query params are present on mount const [iframeSrc, setIframeSrc] = useState(null); @@ -203,6 +204,7 @@ export default function EmbedPlaygroundPage() { envelopeId: string; envelopeType: string; folderId: string; + language: string; }) => { const newParams = new URLSearchParams(); @@ -230,6 +232,10 @@ export default function EmbedPlaygroundPage() { newParams.set('folderId', params.folderId); } + if (params.language) { + newParams.set('language', params.language); + } + const qs = newParams.toString(); void navigate(qs ? `?${qs}` : '.', { replace: true }); @@ -270,6 +276,7 @@ export default function EmbedPlaygroundPage() { externalId: externalId || undefined, type: mode === 'create' ? envelopeType : undefined, folderId: mode === 'create' && folderId ? folderId : undefined, + language: language || undefined, darkModeDisabled: darkModeDisabled || undefined, css: rawCss || undefined, cssVars: Object.keys(filteredCssVars).length > 0 ? filteredCssVars : undefined, @@ -299,7 +306,15 @@ export default function EmbedPlaygroundPage() { setIframeSrc(buildIframeSrc(basePath, presignToken, hash)); setIframeKey((prev) => prev + 1); - updateQueryParams({ token: inputToken, externalId, mode, envelopeId, envelopeType, folderId }); + updateQueryParams({ + token: inputToken, + externalId, + mode, + envelopeId, + envelopeType, + folderId, + language, + }); }; const handleSubmit = useCallback( @@ -314,6 +329,7 @@ export default function EmbedPlaygroundPage() { envelopeId, envelopeType, folderId, + language, generalFeatures, settingsFeatures, actionsFeatures, @@ -333,6 +349,7 @@ export default function EmbedPlaygroundPage() { setEnvelopeId(''); setEnvelopeType('DOCUMENT'); setFolderId(''); + setLanguage(''); setIframeSrc(null); setMessages([]); setTokenError(null); @@ -477,6 +494,30 @@ export default function EmbedPlaygroundPage() { )} +
+ + +
+

Feature Flags

{renderCheckboxGroup('General', generalFeatures, setGeneralFeatures)} diff --git a/apps/remix/app/routes/embed+/v1+/authoring+/_layout.tsx b/apps/remix/app/routes/embed+/v1+/authoring+/_layout.tsx index ba087c5b3..351b6a7ca 100644 --- a/apps/remix/app/routes/embed+/v1+/authoring+/_layout.tsx +++ b/apps/remix/app/routes/embed+/v1+/authoring+/_layout.tsx @@ -1,12 +1,15 @@ -import { useLayoutEffect } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { Trans } from '@lingui/react/macro'; import { Outlet, useLoaderData } from 'react-router'; +import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims'; import { ZBaseEmbedAuthoringSchema } from '@documenso/lib/types/embed-authoring-base-schema'; +import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { TrpcProvider } from '@documenso/trpc/react'; +import { Spinner } from '@documenso/ui/primitives/spinner'; import { injectCss } from '~/utils/css-vars'; @@ -46,6 +49,8 @@ export const loader = async ({ request }: Route.LoaderArgs) => { export default function AuthoringLayout() { const { token, hasValidToken, allowEmbedAuthoringWhiteLabel } = useLoaderData(); + const [hasFinishedInit, setHasFinishedInit] = useState(false); + useLayoutEffect(() => { try { const hash = window.location.hash.slice(1); @@ -55,10 +60,11 @@ export default function AuthoringLayout() { ); if (!result.success) { + setHasFinishedInit(true); return; } - const { css, cssVars, darkModeDisabled } = result.data; + const { css, cssVars, darkModeDisabled, language } = result.data; if (darkModeDisabled) { document.documentElement.classList.add('dark-mode-disabled'); @@ -70,11 +76,28 @@ export default function AuthoringLayout() { cssVars, }); } + + if (language && language !== APP_I18N_OPTIONS.sourceLang) { + void dynamicActivate(language).finally(() => { + setHasFinishedInit(true); + }); + } else { + setHasFinishedInit(true); + } } catch (error) { console.error(error); + setHasFinishedInit(true); } }, []); + if (!hasFinishedInit) { + return ( +
+ +
+ ); + } + if (!hasValidToken) { return (
diff --git a/apps/remix/app/routes/embed+/v2+/authoring+/_layout.tsx b/apps/remix/app/routes/embed+/v2+/authoring+/_layout.tsx index d4ed05047..17c1c98c3 100644 --- a/apps/remix/app/routes/embed+/v2+/authoring+/_layout.tsx +++ b/apps/remix/app/routes/embed+/v2+/authoring+/_layout.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { Trans } from '@lingui/react/macro'; import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client'; @@ -8,12 +8,15 @@ import { match } from 'ts-pattern'; import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants'; import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client'; import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation'; +import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims'; import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings'; import { ZBaseEmbedDataSchema } from '@documenso/lib/types/embed-base-schemas'; +import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { TrpcProvider } from '@documenso/trpc/react'; import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types'; +import { Spinner } from '@documenso/ui/primitives/spinner'; import { TeamProvider } from '~/providers/team'; import { injectCss } from '~/utils/css-vars'; @@ -60,6 +63,8 @@ export const loader = async ({ request }: Route.LoaderArgs) => { export default function AuthoringLayout() { const { token, teamId, organisationClaim, preferences } = useLoaderData(); + const [hasFinishedInit, setHasFinishedInit] = useState(false); + const allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false; useLayoutEffect(() => { @@ -69,10 +74,11 @@ export default function AuthoringLayout() { const result = ZBaseEmbedDataSchema.safeParse(JSON.parse(decodeURIComponent(atob(hash)))); if (!result.success) { + setHasFinishedInit(true); return; } - const { css, cssVars, darkModeDisabled } = result.data; + const { css, cssVars, darkModeDisabled, language } = result.data; if (darkModeDisabled) { document.documentElement.classList.add('dark-mode-disabled'); @@ -84,8 +90,17 @@ export default function AuthoringLayout() { cssVars, }); } + + if (language && language !== APP_I18N_OPTIONS.sourceLang) { + void dynamicActivate(language).finally(() => { + setHasFinishedInit(true); + }); + } else { + setHasFinishedInit(true); + } } catch (error) { console.error(error); + setHasFinishedInit(true); } }, []); @@ -139,7 +154,13 @@ export default function AuthoringLayout() { }} teamId={team.id} > - + {hasFinishedInit ? ( + + ) : ( +
+ +
+ )} diff --git a/packages/lib/constants/i18n.ts b/packages/lib/constants/i18n.ts index 42c9cdd94..fceee6b07 100644 --- a/packages/lib/constants/i18n.ts +++ b/packages/lib/constants/i18n.ts @@ -1,13 +1,10 @@ import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; -import { z } from 'zod'; import { SUPPORTED_LANGUAGE_CODES, type SupportedLanguageCodes } from './locales'; export * from './locales'; -export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en'); - export type I18nLocaleData = { /** * The supported language extracted from the locale. diff --git a/packages/lib/constants/locales.ts b/packages/lib/constants/locales.ts index 22c85a68e..ab172783d 100644 --- a/packages/lib/constants/locales.ts +++ b/packages/lib/constants/locales.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + export const SUPPORTED_LANGUAGE_CODES = [ 'de', 'en', @@ -19,3 +21,5 @@ export const APP_I18N_OPTIONS = { sourceLang: 'en', defaultLocale: 'en-US', } as const; + +export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en'); diff --git a/packages/lib/types/embed-base-schemas.ts b/packages/lib/types/embed-base-schemas.ts index 003553301..c11b97b35 100644 --- a/packages/lib/types/embed-base-schemas.ts +++ b/packages/lib/types/embed-base-schemas.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/locales'; + import { ZCssVarsSchema } from './css-vars'; export const ZBaseEmbedDataSchema = z.object({ @@ -9,4 +11,5 @@ export const ZBaseEmbedDataSchema = z.object({ .optional() .transform((value) => value || undefined), cssVars: ZCssVarsSchema.optional().default({}), + language: ZSupportedLanguageCodeSchema.optional(), }); diff --git a/packages/ui/components/document/document-global-auth-access-select.tsx b/packages/ui/components/document/document-global-auth-access-select.tsx index ed4cbce6f..d5e99ba74 100644 --- a/packages/ui/components/document/document-global-auth-access-select.tsx +++ b/packages/ui/components/document/document-global-auth-access-select.tsx @@ -79,7 +79,7 @@ export const DocumentGlobalAuthAccessTooltip = () => ( - +

Document access diff --git a/packages/ui/components/document/document-global-auth-action-select.tsx b/packages/ui/components/document/document-global-auth-action-select.tsx index 71db18219..a2e872fd2 100644 --- a/packages/ui/components/document/document-global-auth-action-select.tsx +++ b/packages/ui/components/document/document-global-auth-action-select.tsx @@ -79,7 +79,7 @@ export const DocumentGlobalAuthActionTooltip = () => ( - +

Global recipient action authentication

diff --git a/packages/ui/components/document/document-visibility-select.tsx b/packages/ui/components/document/document-visibility-select.tsx index fad53e18b..0dd96db71 100644 --- a/packages/ui/components/document/document-visibility-select.tsx +++ b/packages/ui/components/document/document-visibility-select.tsx @@ -70,7 +70,7 @@ export const DocumentVisibilityTooltip = () => { - +

Document visibility diff --git a/packages/ui/components/recipient/recipient-action-auth-select.tsx b/packages/ui/components/recipient/recipient-action-auth-select.tsx index 678b794e1..e6902d6fe 100644 --- a/packages/ui/components/recipient/recipient-action-auth-select.tsx +++ b/packages/ui/components/recipient/recipient-action-auth-select.tsx @@ -83,7 +83,7 @@ export const RecipientActionAuthSelect = ({ - +

Recipient action authentication diff --git a/packages/ui/primitives/field-selector.tsx b/packages/ui/primitives/field-selector.tsx index b814b5db6..eab02c894 100644 --- a/packages/ui/primitives/field-selector.tsx +++ b/packages/ui/primitives/field-selector.tsx @@ -104,10 +104,10 @@ export const FieldSelector = ({ )} > - {Icon && } + {Icon && } @@ -115,7 +115,7 @@ export const FieldSelector = ({ {field.type === FieldType.SIGNATURE && ( -
+
Signature
)}