diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx index 9844b8ef814..77be3ce743f 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx @@ -1,21 +1,11 @@ import { useUser } from '@clerk/shared/react'; // eslint-disable-next-line no-restricted-imports import { css } from '@emotion/react'; -import type { PropsWithChildren } from 'react'; -import { useEffect, useMemo, useState } from 'react'; -import { createPortal } from 'react-dom'; +import React, { useMemo, useState } from 'react'; -import { Flex, Link } from '../../../customizables'; import { Portal } from '../../../elements/Portal'; -import { InternalThemeProvider } from '../../../styledSystem'; -import { - basePromptElementStyles, - ClerkLogoIcon, - handleDashboardUrlParsing, - PromptContainer, - PromptSuccessIcon, -} from '../shared'; -import { KeySlashIcon } from './KeySlashIcon'; +import { MosaicThemeProvider, useMosaicTheme } from '../../../mosaic/theme-provider'; +import { handleDashboardUrlParsing } from '../shared'; import { useRevalidateEnvironment } from './use-revalidate-environment'; type KeylessPromptProps = { @@ -24,10 +14,6 @@ type KeylessPromptProps = { onDismiss: (() => Promise) | undefined | null; }; -const buttonIdentifierPrefix = `--clerk-keyless-prompt`; -const buttonIdentifier = `${buttonIdentifierPrefix}-button`; -const contentIdentifier = `${buttonIdentifierPrefix}-content`; - /** * If we cannot reconstruct the url properly, then simply fallback to Clerk Dashboard */ @@ -39,531 +25,572 @@ function withLastActiveFallback(cb: () => string): string { } } -const KeylessPromptInternal = (_props: KeylessPromptProps) => { +function KeylessPromptInternal(props: KeylessPromptProps) { const { isSignedIn } = useUser(); - const [isExpanded, setIsExpanded] = useState(false); - - useEffect(() => { - if (isSignedIn) { - setIsExpanded(true); - } - }, [isSignedIn]); - const environment = useRevalidateEnvironment(); const claimed = Boolean(environment.authConfig.claimedAt); - const success = typeof _props.onDismiss === 'function' && claimed; + const success = typeof props.onDismiss === 'function' && claimed; const appName = environment.displayConfig.applicationName; + const isLocked = claimed || success; + + const [isOpen, setIsOpen] = useState(isSignedIn || isLocked); + const [hasMounted, setHasMounted] = useState(false); + const id = React.useId(); + const containerRef = React.useRef(null); + const theme = useMosaicTheme(); + + React.useEffect(() => { + setHasMounted(true); + }, []); + + React.useEffect(() => { + if (hasMounted && isSignedIn) { + setIsOpen(true); + } + }, [hasMounted, isSignedIn]); + + React.useEffect(() => { + if (isLocked) { + setIsOpen(true); + } + }, [isLocked]); - const isForcedExpanded = claimed || success || isExpanded; const claimUrlToDashboard = useMemo(() => { if (claimed) { - return _props.copyKeysUrl; + return props.copyKeysUrl; } - const url = new URL(_props.claimUrl); + const url = new URL(props.claimUrl); // Clerk Dashboard accepts a `return_url` query param when visiting `/apps/claim`. url.searchParams.append('return_url', window.location.href); return url.href; - }, [claimed, _props.copyKeysUrl, _props.claimUrl]); + }, [claimed, props.copyKeysUrl, props.claimUrl]); const instanceUrlToDashboard = useMemo(() => { return withLastActiveFallback(() => { - const redirectUrlParts = handleDashboardUrlParsing(_props.copyKeysUrl); + const redirectUrlParts = handleDashboardUrlParsing(props.copyKeysUrl); const url = new URL( `${redirectUrlParts.baseDomain}/apps/${redirectUrlParts.appId}/instances/${redirectUrlParts.instanceId}/user-authentication/email-phone-username`, ); return url.href; }); - }, [_props.copyKeysUrl]); + }, [props.copyKeysUrl]); - const getKeysUrlFromLastActive = useMemo(() => { - return withLastActiveFallback(() => { - const redirectUrlParts = handleDashboardUrlParsing(_props.copyKeysUrl); - const url = new URL(`${redirectUrlParts.baseDomain}/last-active?path=api-keys`); - return url.href; - }); - }, [_props.copyKeysUrl]); + function getStatusText() { + if (success) { + return 'Claim completed'; + } + if (claimed) { + return 'Missing environment keys'; + } + return 'Clerk is in keyless mode'; + } - const mainCTAStyles = css` - ${basePromptElementStyles}; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 1.75rem; - max-width: 14.625rem; - padding: 0.25rem 0.625rem; - border-radius: 0.375rem; - font-size: 0.75rem; - font-weight: 500; - letter-spacing: 0.12px; - color: ${claimed ? 'white' : success ? 'white' : '#fde047'}; - text-shadow: 0px 1px 2px rgba(0, 0, 0, 0.32); - white-space: nowrap; - user-select: none; - cursor: pointer; - background: linear-gradient(180deg, rgba(0, 0, 0, 0) 30.5%, rgba(0, 0, 0, 0.05) 100%), #454545; - box-shadow: - 0px 0px 0px 1px rgba(255, 255, 255, 0.04) inset, - 0px 1px 0px 0px rgba(255, 255, 255, 0.04) inset, - 0px 0px 0px 1px rgba(0, 0, 0, 0.12), - 0px 1.5px 2px 0px rgba(0, 0, 0, 0.48), - 0px 0px 4px 0px rgba(243, 107, 22, 0) inset; - `; + React.useEffect(() => { + if (isLocked) { + return; + } - return ( - - ({ - position: 'fixed', - bottom: '1.25rem', - right: '1.25rem', - height: `${t.sizes.$10}`, - minWidth: '13.4rem', - paddingLeft: `${t.space.$3}`, - borderRadius: '1.25rem', - transition: 'all 195ms cubic-bezier(0.2, 0.61, 0.1, 1)', + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + setIsOpen(false); + } + }; + + const handleClickOutside = (e: MouseEvent) => { + if (isOpen && containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; - '&[data-expanded="false"]:hover': { - background: 'linear-gradient(180deg, rgba(255, 255, 255, 0.20) 0%, rgba(255, 255, 255, 0) 100%), #1f1f1f', - }, + window.addEventListener('keydown', handleKeyDown); + document.addEventListener('mousedown', handleClickOutside); - '&[data-expanded="true"]': { - flexDirection: 'column', - alignItems: 'flex-center', - justifyContent: 'flex-center', - height: claimed || success ? 'fit-content' : isSignedIn ? '8.5rem' : '12rem', - overflow: 'hidden', - width: 'fit-content', - minWidth: '16.125rem', - gap: `${t.space.$1x5}`, - padding: `${t.space.$2x5} ${t.space.$3} ${t.space.$3} ${t.space.$3}`, - borderRadius: `${t.radii.$xl}`, - transition: 'all 230ms cubic-bezier(0.28, 1, 0.32, 1)', - }, - })} + return () => { + window.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen, isLocked]); + + return ( + +
- ({ - flexDirection: 'column', - gap: t.space.$3, - })} +
- - {isForcedExpanded && - (success ? ( - - ) : ( - ({ - flexDirection: 'column', - alignItems: 'center', - gap: t.space.$2x5, - })} - > - {claimed ? 'Get API keys' : 'Claim application'} + + {claimed ? 'Get API keys' : 'Claim application'} + + - - ))} - - - - - Skip to Clerk keyless mode content - - + )} +
+
+ +
); -}; +} export const KeylessPrompt = (props: KeylessPromptProps) => ( - + - + ); - -const BodyPortal = ({ children }: PropsWithChildren) => { - const [portalContainer, setPortalContainer] = useState(null); - - useEffect(() => { - const container = document.createElement('div'); - setPortalContainer(container); - document.body.insertBefore(container, document.body.firstChild); - return () => { - if (container) { - document.body.removeChild(container); - } - }; - }, []); - - // Render the children inside the dynamically created div - return portalContainer ? createPortal(children, portalContainer) : null; -}; diff --git a/packages/ui/src/mosaic/theme-provider.tsx b/packages/ui/src/mosaic/theme-provider.tsx new file mode 100644 index 00000000000..f8b25690e5f --- /dev/null +++ b/packages/ui/src/mosaic/theme-provider.tsx @@ -0,0 +1,19 @@ +// eslint-disable-next-line no-restricted-imports +import createCache from '@emotion/cache'; +// eslint-disable-next-line no-restricted-imports +import { CacheProvider, ThemeContext } from '@emotion/react'; +import React from 'react'; + +import { type MosaicTheme, mosaicTheme } from './theme'; + +const mosaicCache = createCache({ key: 'mosaic' }); + +export const MosaicThemeProvider = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); +}; + +export const useMosaicTheme = () => React.useContext(ThemeContext) as MosaicTheme; diff --git a/packages/ui/src/mosaic/theme.ts b/packages/ui/src/mosaic/theme.ts new file mode 100644 index 00000000000..97a6a2bef71 --- /dev/null +++ b/packages/ui/src/mosaic/theme.ts @@ -0,0 +1,265 @@ +// Ceramic Colors (from CSS variables) +const gray = { + 50: '#fafafb', + 100: '#f6f6f7', + 200: '#ececee', + 300: '#dbdbe0', + 400: '#c7c7cf', + 500: '#adadb7', + 600: '#90909d', + 700: '#767684', + 800: '#5f5f6f', + 900: '#4c4c5c', + 1000: '#3d3d4a', + 1100: '#33333e', + 1200: '#2b2b34', + 1300: '#232328', + 1400: '#1b1b1f', + 1500: '#111113', +} as const; + +const purple = { + 50: '#f5f3ff', + 100: '#e3e0ff', + 200: '#ccc8ff', + 300: '#bab0ff', + 400: '#a698ff', + 500: '#9280ff', + 600: '#846bff', + 700: '#6c47ff', + 800: '#5f15fe', + 900: '#4d06d1', + 1000: '#3707a6', + 1100: '#27057c', + 1200: '#1c045f', + 1300: '#16034b', +} as const; + +const green = { + 50: '#effdf1', + 100: '#aff9bf', + 200: '#65f088', + 300: '#49dc6e', + 400: '#31c854', + 500: '#1eb43c', + 600: '#199d34', + 700: '#15892b', + 800: '#107524', + 900: '#09661c', + 1000: '#0b5619', + 1100: '#0c4919', + 1200: '#0c3c18', + 1300: '#053211', +} as const; + +const red = { + 50: '#fef8f8', + 100: '#fedddd', + 200: '#fec4c4', + 300: '#fca9a9', + 400: '#f98a8a', + 500: '#f86969', + 600: '#f73d3d', + 700: '#e02e2e', + 800: '#c22a2a', + 900: '#aa1b1b', + 1000: '#921414', + 1100: '#7a1313', + 1200: '#651414', + 1300: '#550e0e', + 1400: '#3d0101', + 1500: '#2d0101', +} as const; + +const orange = { + 50: '#fff8f2', + 100: '#ffe4c4', + 200: '#fecc9f', + 300: '#feb166', + 400: '#fd9357', + 500: '#fd7224', + 600: '#e06213', + 700: '#c3540f', + 800: '#a8470c', + 900: '#9d3405', + 1000: '#8a2706', + 1100: '#75220b', + 1200: '#5f1e0c', + 1300: '#50170a', +} as const; + +const yellow = { + 50: '#fefbdc', + 100: '#f7ed55', + 200: '#e5d538', + 300: '#d7be35', + 400: '#c0aa18', + 500: '#bd9005', + 600: '#a47c04', + 700: '#8d6b03', + 800: '#775902', + 900: '#674401', + 1000: '#563202', + 1100: '#412303', + 1200: '#321904', + 1300: '#2a1203', +} as const; + +const blue = { + 50: '#f6faff', + 100: '#daeafe', + 200: '#b4d5fe', + 300: '#8dc2fd', + 400: '#73acfa', + 500: '#6694f8', + 600: '#307ff6', + 700: '#236dd7', + 800: '#1c5bb6', + 900: '#1744a6', + 1000: '#0f318e', + 1100: '#0e2369', + 1200: '#0b1c49', + 1300: '#0c1637', +} as const; + +// Typography - Labels +const label = { + 1: { fontSize: '1rem', lineHeight: '1.375rem', fontWeight: 500 }, + 2: { fontSize: '0.875rem', lineHeight: '1.25rem', fontWeight: 500 }, + 3: { fontSize: '0.75rem', lineHeight: '1rem', fontWeight: 500 }, + 4: { + fontSize: '0.6875rem', + lineHeight: '0.875rem', + fontWeight: 500, + letterSpacing: '0.015em', + }, + 5: { fontSize: '0.625rem', lineHeight: '0.8125rem', fontWeight: 500 }, +} as const; + +// Typography - Headings +const heading = { + 1: { + fontSize: '2.25rem', + lineHeight: '2.5rem', + fontWeight: 500, + letterSpacing: '-0.02em', + }, + 2: { + fontSize: '2rem', + lineHeight: '2.25rem', + fontWeight: 500, + letterSpacing: '-0.02em', + }, + 3: { + fontSize: '1.75rem', + lineHeight: '2.125rem', + fontWeight: 500, + letterSpacing: '-0.015em', + }, + 4: { + fontSize: '1.5rem', + lineHeight: '2rem', + fontWeight: 500, + letterSpacing: '-0.01em', + }, + 5: { + fontSize: '1.25rem', + lineHeight: '1.75rem', + fontWeight: 500, + letterSpacing: '-0.01em', + }, + 6: { fontSize: '1.0625rem', lineHeight: '1.5rem', fontWeight: 500 }, +} as const; + +// Typography - Body +const body = { + 1: { fontSize: '1rem', lineHeight: '1.375rem', fontWeight: 400 }, + 2: { fontSize: '0.875rem', lineHeight: '1.25rem', fontWeight: 400 }, + 3: { + fontSize: '0.75rem', + lineHeight: '1rem', + fontWeight: 400, + letterSpacing: '0.01em', + }, + 4: { fontSize: '0.6875rem', lineHeight: '0.875rem', fontWeight: 400 }, +} as const; + +const fontWeights = { + normal: 400, + medium: 500, + semibold: 600, + bold: 700, +} as const; + +const fontFamilies = { + sans: 'system-ui, sans-serif', + mono: 'ui-monospace, monospace', +} as const; + +// Spacing (Tailwind-compatible scale, 1 unit = 0.25rem = 4px) +const spacing = { + 0: '0', + px: '1px', + 0.5: '0.125rem', + 1: '0.25rem', + 1.5: '0.375rem', + 2: '0.5rem', + 2.5: '0.625rem', + 3: '0.75rem', + 3.5: '0.875rem', + 4: '1rem', + 5: '1.25rem', + 6: '1.5rem', + 7: '1.75rem', + 8: '2rem', + 9: '2.25rem', + 10: '2.5rem', + 11: '2.75rem', + 12: '3rem', + 14: '3.5rem', + 16: '4rem', + 20: '5rem', + 24: '6rem', + 28: '7rem', + 32: '8rem', + 36: '9rem', + 40: '10rem', + 44: '11rem', + 48: '12rem', + 52: '13rem', + 56: '14rem', + 60: '15rem', + 64: '16rem', + 72: '18rem', + 80: '20rem', + 96: '24rem', +} as const; + +const alpha = (color: string, opacity: number) => `color-mix(in srgb, ${color} ${opacity}%, transparent)`; +const negative = (value: string) => `-${value}`; + +export const mosaicTheme = { + colors: { + gray, + purple, + green, + red, + orange, + yellow, + blue, + white: '#fff', + black: '#000', + }, + typography: { + label, + heading, + body, + }, + fontWeights, + fontFamilies, + spacing, + alpha, + negative, +} as const; + +export type MosaicTheme = typeof mosaicTheme;