diff --git a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx index c76bc00a2..9bbec2636 100644 --- a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -1,7 +1,7 @@ import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useSession } from '@documenso/lib/client-only/providers/session'; -import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { AppError } from '@documenso/lib/errors/app-error'; import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n'; @@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }: ))} diff --git a/apps/remix/app/components/forms/signin.tsx b/apps/remix/app/components/forms/signin.tsx index 16b9943ca..6270bc948 100644 --- a/apps/remix/app/components/forms/signin.tsx +++ b/apps/remix/app/components/forms/signin.tsx @@ -1,5 +1,6 @@ import { authClient } from '@documenso/auth/client'; import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes'; +import { formatPath } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { env } from '@documenso/lib/utils/env'; import { zEmail } from '@documenso/lib/utils/zod'; @@ -44,7 +45,7 @@ const handleFallbackErrorMessages = (code: string) => { return message; }; -const LOGIN_REDIRECT_PATH = '/'; +const LOGIN_REDIRECT_PATH = formatPath('/'); export const ZSignInFormSchema = z.object({ email: zEmail().min(1), diff --git a/apps/remix/app/components/general/app-command-menu.tsx b/apps/remix/app/components/general/app-command-menu.tsx index 3a704445e..7f245872f 100644 --- a/apps/remix/app/components/general/app-command-menu.tsx +++ b/apps/remix/app/components/general/app-command-menu.tsx @@ -1,5 +1,6 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { formatPath } from '@documenso/lib/constants/app'; import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n'; import { DOCUMENTS_PAGE_SHORTCUT, @@ -862,7 +863,7 @@ const PromptLanguageCommands = ({ formData.append('lang', lang); - const response = await fetch('/api/locale', { + const response = await fetch(formatPath('/api/locale'), { method: 'post', body: formData, }); diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx index 473166f7a..509cddced 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx @@ -1,4 +1,5 @@ import { authClient } from '@documenso/auth/client'; +import { formatPath } from '@documenso/lib/constants/app'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { DialogFooter } from '@documenso/ui/primitives/dialog'; @@ -34,7 +35,9 @@ export const DocumentSigningAuthAccount = ({ const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; await authClient.signOut({ - redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`, + redirectPath: formatPath( + `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`, + ), }); } catch { setIsSigningOut(false); diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx index be9e409a0..a762eef57 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx @@ -1,4 +1,5 @@ import { authClient } from '@documenso/auth/client'; +import { formatPath } from '@documenso/lib/constants/app'; import { Button } from '@documenso/ui/primitives/button'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { msg } from '@lingui/core/macro'; @@ -21,10 +22,10 @@ export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: Document try { setIsSigningOut(true); - let redirectPath = '/signin'; + let redirectPath = formatPath('/signin'); if (email) { - redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`; + redirectPath = emailHasAccount ? formatPath(`/signin#email=${email}`) : formatPath(`/signup#email=${email}`); } await authClient.signOut({ diff --git a/apps/remix/app/components/general/settings-upsell/branding-upsell.tsx b/apps/remix/app/components/general/settings-upsell/branding-upsell.tsx new file mode 100644 index 000000000..f74432cf7 --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/branding-upsell.tsx @@ -0,0 +1,195 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { Trans } from '@lingui/react/macro'; +import { motion, useReducedMotion } from 'framer-motion'; + +import { EASE, POP, SPRING } from './motion'; +import { SettingsUpsellCard } from './settings-upsell-card'; +import { useTimedCycle } from './use-timed-cycle'; + +const DEMO_BRANDS = [ + { + name: 'Documenso', + letter: 'D', + domain: 'noreply@app.documenso.com', + accent: '#A2E771', + ink: '#162C07', + tint: '#F2FBEA', + sheen: 'rgba(162, 231, 113, 0.32)', + }, + { + name: 'Documenso', + letter: 'D', + domain: 'noreply@app.documenso.com', + accent: '#387BC7', + ink: '#ffffff', + tint: '#EDF3FA', + sheen: 'rgba(56, 123, 199, 0.28)', + }, + { + name: 'Documenso', + letter: 'D', + domain: 'noreply@app.documenso.com', + accent: '#9747F5', + ink: '#ffffff', + tint: '#F4EDFE', + sheen: 'rgba(151, 71, 245, 0.26)', + }, +]; + +/** + * Milliseconds each brand is shown before cycling to the next. + */ +const BRAND_CYCLE_INTERVAL_MS = 2400; + +export const BrandingUpsell = () => { + const organisation = useCurrentOrganisation(); + + const isReducedMotion = useReducedMotion(); + const brandIndex = useTimedCycle(DEMO_BRANDS.map(() => BRAND_CYCLE_INTERVAL_MS)); + + const isStatic = isReducedMotion ?? false; + + const brand = DEMO_BRANDS[brandIndex]; + + return ( + Teams} + title={Unlock Branding Preferences} + description={ + Put your own brand on every document you send. Branding is available on the Teams plan and above. + } + features={[ + Your logo on signing pages and emails, + Company details and website in email footers, + Separate branding per team, + ]} + preview={ +
+
+ + Brand accent + + +
+ {DEMO_BRANDS.map((dotBrand, index) => ( + + ))} +
+
+ +
+ + {/* The sender identity never changes — only the tile colours tween per brand. */} + + {brand.letter} + + + {/* + * Hardcoded inks (not theme tokens): this row sits on the + * hardcoded light `tint` band, so it pairs with hardcoded ink + * colours the same way the email sibling pairs its hardcoded + * avatar surfaces (hardcoded surface => hardcoded ink). + */} +
+
+

{brand.name}

+
+ +
+

{brand.domain}

+
+
+
+ +
+

+ Please sign: Example.pdf +

+ +

+ {organisation.name} has invited you to sign this document. +

+ + {/* Same replay split as the logo tile: colours tween on the persistent button, the pop replays per brand on the remounting label. */} + + + Sign + + +
+ +
+ + Company details + + + + + +
+ + {/* + * Keyed remount replays the sweep per brand. No opacity envelope — + * keyframe arrays are unreliable on strict-mode remounts; both + * endpoints sit outside the overflow-hidden card, so the clip + * provides the fade in/out instead. + */} + +
+
+ } + /> + ); +}; diff --git a/apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx b/apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx new file mode 100644 index 000000000..183ad3b80 --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx @@ -0,0 +1,213 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { Trans } from '@lingui/react/macro'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { BadgeCheckIcon, MailIcon } from 'lucide-react'; +import { BrandingLogoIcon } from '../branding-logo-icon'; +import { EASE, POP, SPRING } from './motion'; +import { SettingsUpsellCard } from './settings-upsell-card'; +import { useTimedCycle } from './use-timed-cycle'; + +/** + * Named sender identities cycled through while the preview is in its branded + * state — one per branded cycle step, shown as the sender name and address. + */ +const BRANDED_SENDERS = [ + { name: 'Support', email: 'support@example.com' }, + { name: 'Team', email: 'hello@example.com' }, + { name: 'Sales', email: 'sales@example.com' }, + { name: 'Example', email: 'noreply@example.com' }, +]; + +/** + * How long the initial unbranded (Documenso default) state is shown before + * the first flip starts. Shown exactly once — the cycle never returns to it. + */ +const INITIAL_STATE_DURATION_MS = 2500; + +/** + * How long each branded sender identity is shown before cycling to the next, + * giving the viewer time to read the changed address. + */ +const BRANDED_STATE_DURATION_MS = 5000; + +/** + * One duration per cycle step: the unbranded state first, then one step per + * named sender identity, derived from the identity count so the two cannot + * drift. + */ +const EMAIL_CYCLE_DURATIONS_MS = [INITIAL_STATE_DURATION_MS, ...BRANDED_SENDERS.map(() => BRANDED_STATE_DURATION_MS)]; + +export const EmailDomainsUpsell = () => { + const organisation = useCurrentOrganisation(); + + const isReducedMotion = useReducedMotion(); + + // Loop from index 1: the unbranded Documenso intro plays exactly once, + // then the cycle rotates through the branded senders only. + const cycleIndex = useTimedCycle(EMAIL_CYCLE_DURATIONS_MS, 1); + + const isBranded = cycleIndex > 0; + const brandedSender = BRANDED_SENDERS[cycleIndex - 1] ?? BRANDED_SENDERS[0]; + + const isStatic = isReducedMotion ?? false; + + return ( + Enterprise} + title={Unlock Email Domains} + description={ + Send documents from your own domain. Email domains are available on the Enterprise plan. + } + features={[ + Send emails to recipients from your domain, + Easy DNS setup with auto-generated DKIM and SPF records, + Named senders with defaults per team, template or document, + ]} + ctaLabel={Contact Sales} + ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL} + ctaExternal + preview={ +
+
+ + + {isBranded ? ( + + ) : ( + + )} + + + {isBranded ? Sending from your domain : Sending from app.documenso.com} + + + +
+ +
+
+
+ + + {/* + * Remounts with its keyed parent on every cycle step so the + * pop replays each change. Single-value spring (POP + * overshoots past 1) instead of scale keyframes — keyframe + * arrays are unreliable on strict-mode remounts. + */} + + {isBranded ? ( + + {organisation.avatarImageId && ( + + )} + {brandedSender.name[0]} + + ) : ( + + )} + + + +
+ +
+
+ + + {isBranded ? brandedSender.name : 'Documenso'} + + {/* Inside the keyed row so it exits with the name and pops back in on every cycle step. */} + {isBranded && ( + + + + )} + + +
+ +
+ + + {isBranded ? brandedSender.email : 'noreply@app.documenso.com'} + + +
+
+
+ +
+

+ Please sign: Example.pdf +

+ +

+ {organisation.name} has invited you to sign this document. +

+
+ + {/* + * Keyed remount replays the sweep on every cycle step. No opacity + * envelope — keyframe arrays are unreliable on strict-mode + * remounts; both endpoints sit outside the overflow-hidden card, + * so the clip provides the fade in/out instead. + */} + +
+
+ } + /> + ); +}; diff --git a/apps/remix/app/components/general/settings-upsell/motion.ts b/apps/remix/app/components/general/settings-upsell/motion.ts new file mode 100644 index 000000000..da8ee359f --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/motion.ts @@ -0,0 +1,9 @@ +/** + * Shared motion vocabulary for the settings upsell previews. Values ported + * from the design prototype (`design/SSO Upsell.dc.html`). + */ +export const SPRING = { type: 'spring', stiffness: 280, damping: 22 } as const; + +export const POP = { type: 'spring', stiffness: 420, damping: 16 } as const; + +export const EASE = [0.22, 0.61, 0.36, 1] as const; diff --git a/apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx b/apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx new file mode 100644 index 000000000..054ef875f --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx @@ -0,0 +1,118 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { Trans } from '@lingui/react/macro'; +import { ArrowRightIcon, CheckIcon, LockIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Link } from 'react-router'; + +export type SettingsUpsellCardProps = { + planLabel: ReactNode; + title: ReactNode; + description: ReactNode; + features: ReactNode[]; + preview: ReactNode; + + /** + * CTA label. Defaults to "Upgrade Plan". + */ + ctaLabel?: ReactNode; + + /** + * CTA destination. Defaults to the organisation billing settings page. + */ + ctaTo?: string; + + /** + * Render the CTA as an external link (new tab) instead of an internal route. + */ + ctaExternal?: boolean; +}; + +/** + * Shared split-card layout for claim-gated settings upsells on Documenso + * Cloud. The left pane pitches the feature (plan badge, title, description, + * feature list, upgrade CTA); the right pane renders a decorative scenario + * preview supplied by the caller. + * + * Callers decide *when* to render this (cloud + missing claim flag). + */ +export const SettingsUpsellCard = ({ + planLabel, + title, + description, + features, + preview, + ctaLabel, + ctaTo, + ctaExternal = false, +}: SettingsUpsellCardProps) => { + const organisation = useCurrentOrganisation(); + + const canManageBilling = canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole); + + const ctaHref = ctaTo ?? `/o/${organisation.url}/settings/billing`; + + const ctaContent = ( + <> + {ctaLabel ?? Upgrade Plan} + + + ); + + return ( +
+ {/* + * `min-w-0` on both grid items: `fr` tracks have an `auto` content + * minimum, so long preview content (e.g. a wide mono domain line) would + * otherwise widen the right track beyond its 0.92fr share — and + * re-balance the whole grid on every preview cycle (layout shift). + */} +
+ + + {planLabel} + + +

{title}

+ +

{description}

+ +
    + {features.map((feature, index) => ( +
  • + + + + {feature} +
  • + ))} +
+ + {canManageBilling ? ( + + ) : ( +

+ Contact your organisation owner to upgrade plans. +

+ )} +
+ + +
+ ); +}; diff --git a/apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx b/apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx new file mode 100644 index 000000000..1444c1ea1 --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx @@ -0,0 +1,260 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app'; +import { Trans } from '@lingui/react/macro'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { FingerprintIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { EASE, POP, SPRING } from './motion'; +import { SettingsUpsellCard } from './settings-upsell-card'; +import { useTimedCycle } from './use-timed-cycle'; + +export const SsoPortalUpsell = () => { + const isReducedMotion = useReducedMotion(); + const sceneIndex = useTimedCycle(SSO_SCENE_DURATIONS_MS); + + return ( + Enterprise} + title={Unlock the Organisation SSO Portal} + description={ + + Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan. + + } + features={[ + Works with any OIDC provider — Okta, Entra ID, Google and more, + Accounts are automatically added to your organisation on sign-in, + Restrict sign-ins by email domain and choose the default role, + ]} + ctaLabel={Contact Sales} + ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL} + ctaExternal + preview={ +
+
+ + {sceneIndex === 0 && } + {sceneIndex === 1 && } + {sceneIndex === 2 && } + +
+
+ } + /> + ); +}; + +/** + * Absolute-positioned panel each scene renders in, handling the shared + * slide-and-fade transition between scenes. + */ +const ScenePanel = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + +/** + * Scene 1: the organisation's SSO portal, with a timed faux press on the + * "Continue with SSO" button (cursor flies in, button dips, sheen sweeps). + * + * When `isStatic` is set (reduced motion) every element renders with + * `initial={false}`, skipping entrance and press animations. + */ +const PortalScene = ({ isStatic }: { isStatic: boolean }) => { + const organisation = useCurrentOrganisation(); + + const rise = (delay: number) => ({ + initial: isStatic ? false : { y: 10, opacity: 0 }, + animate: { y: 0, opacity: 1 }, + transition: { ...SPRING, delay }, + }); + + return ( + + +
+ {([...organisation.name][0] ?? 'D').toUpperCase()} +
+
+ + + Welcome to {organisation.name} + + + + Single sign-on + + +
+ + + Continue with SSO + + + + + + + + +
+
+ ); +}; + +/** + * Scene 2: redirecting to the identity provider, with a rotating ring around + * a fingerprint tile and a filling progress bar. + */ +const RedirectScene = () => { + return ( + +
+ + +
+ +
+
+ + + Redirecting to your identity provider + + +
+ +
+
+ ); +}; + +/** + * Scene 3: signed in, with an expanding pulse ring, a popping green circle, + * a drawn checkmark and the signed-in member's email. + */ +const SuccessScene = () => { + const { user } = useSession(); + + return ( + +
+ + + + + + + +
+ + + Signed in + + + + {user.email} + +
+ ); +}; + +/** + * Milliseconds each scene is shown before advancing: portal, redirect, + * success. + */ +const SSO_SCENE_DURATIONS_MS = [3000, 2500, 3000]; diff --git a/apps/remix/app/components/general/settings-upsell/use-timed-cycle.ts b/apps/remix/app/components/general/settings-upsell/use-timed-cycle.ts new file mode 100644 index 000000000..57e9b952d --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/use-timed-cycle.ts @@ -0,0 +1,47 @@ +import { useReducedMotion } from 'framer-motion'; +import { useEffect, useState } from 'react'; + +/** + * Cycles an index through `durations.length` steps, waiting `durations[i]` + * milliseconds on step `i` before advancing to the next. + * + * When the cycle wraps past the last step it continues from `loopStartIndex` + * (default `0`), letting consumers play intro-only steps exactly once and + * then loop through the remaining steps forever. + * + * Under `prefers-reduced-motion` the cycle never starts and the index stays + * at 0, so consumers render their initial state statically. + * + * Pass module-level constants for `durations` and `loopStartIndex` — their + * identities are intentionally not dependencies. + */ +export const useTimedCycle = (durations: number[], loopStartIndex = 0) => { + const [index, setIndex] = useState(0); + + const isReducedMotion = useReducedMotion(); + + useEffect(() => { + if (isReducedMotion || durations.length === 0) { + setIndex(0); + return; + } + + let current = 0; + let timeout: ReturnType; + + const tick = () => { + const next = current + 1; + + current = next >= durations.length ? Math.min(loopStartIndex, durations.length - 1) : next; + + setIndex(current); + timeout = setTimeout(tick, durations[current]); + }; + + timeout = setTimeout(tick, durations[0]); + + return () => clearTimeout(timeout); + }, [isReducedMotion]); + + return index; +}; diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index a7c29b4be..75b37495c 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -1,5 +1,6 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { SessionProvider } from '@documenso/lib/client-only/providers/session'; +import { getBasePath } from '@documenso/lib/constants/app'; import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n'; import { createPublicEnv } from '@documenso/lib/utils/env'; import { extractLocaleData } from '@documenso/lib/utils/i18n'; @@ -20,7 +21,6 @@ import { useMatches, } from 'react-router'; import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'; - import type { Route } from './+types/root'; import stylesheet from './app.css?url'; import { GenericErrorLayout } from './components/general/generic-error-layout'; @@ -68,6 +68,7 @@ export async function loader({ context, request }: Route.LoaderArgs) { lang, theme: getTheme(), disableAnimations, + basePath: getBasePath(), // Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all // SSR-rendered