feat: support language in embedding (#2364)

This commit is contained in:
Ted Liang 2026-03-18 16:17:23 +11:00 committed by GitHub
parent f48aa84c9e
commit 5dcdac7ecd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 142 additions and 22 deletions

View file

@ -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 |

View file

@ -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 |

View file

@ -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. |

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<string | null>(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() {
</div>
)}
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
Language (optional)
</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
>
<option value="">Default (en)</option>
<option value="de">German (de)</option>
<option value="en">English (en)</option>
<option value="es">Spanish (es)</option>
<option value="fr">French (fr)</option>
<option value="it">Italian (it)</option>
<option value="ja">Japanese (ja)</option>
<option value="ko">Korean (ko)</option>
<option value="nl">Dutch (nl)</option>
<option value="pl">Polish (pl)</option>
<option value="pt-BR">Portuguese - Brazil (pt-BR)</option>
<option value="zh">Chinese (zh)</option>
</select>
</div>
<h3 style={{ fontSize: '14px', margin: '12px 0 4px' }}>Feature Flags</h3>
{renderCheckboxGroup('General', generalFeatures, setGeneralFeatures)}

View file

@ -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<typeof loader>();
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 (
<div className="flex min-h-screen items-center justify-center">
<Spinner />
</div>
);
}
if (!hasValidToken) {
return (
<div>

View file

@ -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<typeof loader>();
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}
>
<Outlet />
{hasFinishedInit ? (
<Outlet />
) : (
<div className="flex min-h-screen items-center justify-center">
<Spinner />
</div>
)}
</LimitsProvider>
</TrpcProvider>
</TeamProvider>

View file

@ -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.

View file

@ -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');

View file

@ -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(),
});

View file

@ -79,7 +79,7 @@ export const DocumentGlobalAuthAccessTooltip = () => (
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document access</Trans>

View file

@ -79,7 +79,7 @@ export const DocumentGlobalAuthActionTooltip = () => (
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<Trans>Global recipient action authentication</Trans>
</h2>

View file

@ -70,7 +70,7 @@ export const DocumentVisibilityTooltip = () => {
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document visibility</Trans>

View file

@ -83,7 +83,7 @@ export const RecipientActionAuthSelect = ({
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<TooltipContent className="max-w-md p-4 text-foreground">
<h2>
<strong>
<Trans>Recipient action authentication</Trans>

View file

@ -104,10 +104,10 @@ export const FieldSelector = ({
)}
>
<CardContent className="relative flex items-center justify-center gap-x-2 px-6 py-4">
{Icon && <Icon className="text-muted-foreground h-4 w-4" />}
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<span
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-sm',
'text-sm text-muted-foreground group-data-[selected]:text-foreground',
field.type === FieldType.SIGNATURE && 'invisible',
)}
>
@ -115,7 +115,7 @@ export const FieldSelector = ({
</span>
{field.type === FieldType.SIGNATURE && (
<div className="text-muted-foreground font-signature absolute inset-0 flex items-center justify-center text-lg">
<div className="absolute inset-0 flex items-center justify-center font-signature text-lg text-muted-foreground">
<Trans>Signature</Trans>
</div>
)}