diff --git a/.changeset/beige-snakes-guess.md b/.changeset/beige-snakes-guess.md new file mode 100644 index 00000000000..2a302134b88 --- /dev/null +++ b/.changeset/beige-snakes-guess.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Improved keyless content and animation. diff --git a/.changeset/rotten-apes-carry.md b/.changeset/rotten-apes-carry.md new file mode 100644 index 00000000000..6110277b583 --- /dev/null +++ b/.changeset/rotten-apes-carry.md @@ -0,0 +1,5 @@ +--- +"@clerk/testing": patch +--- + +Improved keyless selectors. diff --git a/integration/testUtils/keylessHelpers.ts b/integration/testUtils/keylessHelpers.ts index e4deaf5813f..29480917ac4 100644 --- a/integration/testUtils/keylessHelpers.ts +++ b/integration/testUtils/keylessHelpers.ts @@ -46,10 +46,6 @@ export async function testToggleCollapsePopoverAndClaim({ await u.po.keylessPopover.waitForMounted(); - expect(await u.po.keylessPopover.isExpanded()).toBe(false); - await u.po.keylessPopover.toggle(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - const claim = u.po.keylessPopover.promptsToClaim(); const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]); @@ -133,7 +129,6 @@ export async function testKeylessRemovedAfterEnvAndRestart({ await u.page.goToAppHome(); await u.po.keylessPopover.waitForMounted(); - expect(await u.po.keylessPopover.isExpanded()).toBe(false); // Copy keys from keyless.json to .env await app.keylessToEnv(); diff --git a/packages/testing/src/playwright/unstable/page-objects/keylessPopover.ts b/packages/testing/src/playwright/unstable/page-objects/keylessPopover.ts index 69b5bbd4728..bca48c29274 100644 --- a/packages/testing/src/playwright/unstable/page-objects/keylessPopover.ts +++ b/packages/testing/src/playwright/unstable/page-objects/keylessPopover.ts @@ -2,20 +2,15 @@ import type { EnhancedPage } from './app'; export const createKeylessPopoverPageObject = (testArgs: { page: EnhancedPage }) => { const { page } = testArgs; - // TODO: Is this the ID we really want ? - const elementId = '#--clerk-keyless-prompt-button'; + const button = page.getByRole('button', { name: 'Keyless prompt' }); const self = { - waitForMounted: () => page.waitForSelector(elementId, { state: 'attached' }), - waitForUnmounted: () => page.waitForSelector(elementId, { state: 'detached' }), - isExpanded: () => - page - .locator(elementId) - .getAttribute('aria-expanded') - .then(val => val === 'true'), - toggle: () => page.locator(elementId).click(), + waitForMounted: () => button.waitFor({ state: 'attached' }), + waitForUnmounted: () => button.waitFor({ state: 'detached' }), + isExpanded: () => button.getAttribute('aria-expanded').then(val => val === 'true'), + toggle: () => button.click(), promptsToClaim: () => { - return page.getByRole('link', { name: /^claim application$/i }); + return page.getByRole('link', { name: /^configure your application$/i }); }, promptToUseClaimedKeys: () => { return page.getByRole('link', { name: /^get api keys$/i }); diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/KeylessPrompt.test.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/KeylessPrompt.test.tsx new file mode 100644 index 00000000000..d0ab9733be0 --- /dev/null +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/KeylessPrompt.test.tsx @@ -0,0 +1,335 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +import { getCurrentState, getResolvedContent, KeylessPrompt } from '../index'; + +const { createFixtures } = bindCreateFixtures('KeylessPrompt' as any); + +describe('getCurrentState', () => { + it('returns completed when success is true', () => { + expect(getCurrentState(true, true, true)).toBe('completed'); + expect(getCurrentState(true, true, false)).toBe('completed'); + expect(getCurrentState(false, true, true)).toBe('completed'); + expect(getCurrentState(false, true, false)).toBe('completed'); + }); + + it('returns claimed when claimed is true and success is false', () => { + expect(getCurrentState(true, false, true)).toBe('claimed'); + expect(getCurrentState(true, false, false)).toBe('claimed'); + }); + + it('returns userCreated when isSignedIn is true and claimed/success are false', () => { + expect(getCurrentState(false, false, true)).toBe('userCreated'); + }); + + it('returns idle when all flags are false', () => { + expect(getCurrentState(false, false, false)).toBe('idle'); + }); + + it('follows precedence: completed > claimed > userCreated > idle', () => { + // All true -> completed + expect(getCurrentState(true, true, true)).toBe('completed'); + + // claimed + isSignedIn but no success -> claimed + expect(getCurrentState(true, false, true)).toBe('claimed'); + + // isSignedIn but no claimed/success -> userCreated + expect(getCurrentState(false, false, true)).toBe('userCreated'); + + // All false -> idle + expect(getCurrentState(false, false, false)).toBe('idle'); + }); +}); + +describe('getResolvedContent', () => { + const baseContext = { + appName: 'Test App', + instanceUrl: 'https://dashboard.clerk.com/apps/app_123/instances/ins_456', + claimUrl: 'https://dashboard.clerk.com/claim', + onDismiss: null, + }; + + describe('idle state', () => { + it('builds correct view model for idle state', () => { + const resolvedContent = getResolvedContent('idle', baseContext); + + expect(resolvedContent.state).toBe('idle'); + expect(resolvedContent.title).toBe('Configure your application'); + expect(resolvedContent.triggerWidth).toBe('14.25rem'); + expect(resolvedContent.cta.kind).toBe('link'); + expect(resolvedContent.cta.text).toBe('Configure your application'); + if (resolvedContent.cta.kind === 'link') { + expect(resolvedContent.cta.href).toBe(baseContext.claimUrl); + } + }); + + it('resolves static description correctly', () => { + const resolvedContent = getResolvedContent('idle', baseContext); + expect(resolvedContent.description).toBeDefined(); + expect(React.isValidElement(resolvedContent.description)).toBe(true); + }); + }); + + describe('userCreated state', () => { + it('builds correct view model for userCreated state', () => { + const resolvedContent = getResolvedContent('userCreated', baseContext); + + expect(resolvedContent.state).toBe('userCreated'); + expect(resolvedContent.title).toBe("You've created your first user!"); + expect(resolvedContent.triggerWidth).toBe('15.75rem'); + expect(resolvedContent.cta.kind).toBe('link'); + expect(resolvedContent.cta.text).toBe('Configure your application'); + if (resolvedContent.cta.kind === 'link') { + expect(resolvedContent.cta.href).toBe(baseContext.claimUrl); + } + }); + }); + + describe('claimed state', () => { + it('builds correct view model for claimed state', () => { + const resolvedContent = getResolvedContent('claimed', baseContext); + + expect(resolvedContent.state).toBe('claimed'); + expect(resolvedContent.title).toBe('Missing environment keys'); + expect(resolvedContent.triggerWidth).toBe('14.25rem'); + expect(resolvedContent.cta.kind).toBe('link'); + expect(resolvedContent.cta.text).toBe('Get API keys'); + if (resolvedContent.cta.kind === 'link') { + expect(resolvedContent.cta.href).toBe(baseContext.claimUrl); + } + }); + }); + + describe('completed state', () => { + it('builds correct view model for completed state', () => { + const resolvedContent = getResolvedContent('completed', baseContext); + + expect(resolvedContent.state).toBe('completed'); + expect(resolvedContent.title).toBe('Your app is ready'); + expect(resolvedContent.triggerWidth).toBe('10.5rem'); + expect(resolvedContent.cta.kind).toBe('action'); + expect(resolvedContent.cta.text).toBe('Dismiss'); + if (resolvedContent.cta.kind === 'action') { + expect(typeof resolvedContent.cta.onClick).toBe('function'); + } + }); + + it('resolves function-based description with context', () => { + const resolvedContent = getResolvedContent('completed', baseContext); + expect(resolvedContent.description).toBeDefined(); + expect(React.isValidElement(resolvedContent.description)).toBe(true); + }); + + it('creates onClick handler that calls onDismiss', async () => { + const onDismiss = vi.fn().mockResolvedValue(undefined); + // Note: window.location.reload cannot be easily mocked in jsdom, + // so we verify that onDismiss is called correctly + // The reload side effect is tested at integration level + + const resolvedContent = getResolvedContent('completed', { + ...baseContext, + onDismiss, + }); + + expect(resolvedContent.cta.kind).toBe('action'); + if (resolvedContent.cta.kind === 'action') { + resolvedContent.cta.onClick(); + // Wait for the promise chain to complete + await new Promise(resolve => setTimeout(resolve, 0)); + expect(onDismiss).toHaveBeenCalledOnce(); + // Note: window.location.reload() is called but cannot be verified in jsdom + } + }); + + it('handles null onDismiss gracefully', () => { + // Note: window.location.reload cannot be easily mocked in jsdom, + // so we verify the handler executes without error + // The reload side effect is tested at integration level + + const resolvedContent = getResolvedContent('completed', { + ...baseContext, + onDismiss: null, + }); + + expect(resolvedContent.cta.kind).toBe('action'); + if (resolvedContent.cta.kind === 'action') { + const onClick = resolvedContent.cta.onClick; + // Should execute without throwing an error even when onDismiss is null + expect(() => { + onClick(); + }).not.toThrow(); + // Note: window.location.reload() is called but cannot be verified in jsdom + // The onClick handler uses void to fire-and-forget the promise chain + } + }); + }); + + describe('CTA href resolution', () => { + it('resolves function-based href with context', () => { + const context = { + ...baseContext, + claimUrl: 'https://custom-claim.com', + instanceUrl: 'https://custom-instance.com', + }; + + const resolvedContent = getResolvedContent('idle', context); + expect(resolvedContent.cta.kind).toBe('link'); + if (resolvedContent.cta.kind === 'link') { + expect(resolvedContent.cta.href).toBe(context.claimUrl); + } + }); + + it('resolves string-based href directly', () => { + // This test verifies that if we had a string href, it would work + // Currently all states use function-based hrefs, but the logic supports both + const resolvedContent = getResolvedContent('idle', baseContext); + expect(resolvedContent.cta.kind).toBe('link'); + if (resolvedContent.cta.kind === 'link') { + expect(typeof resolvedContent.cta.href).toBe('string'); + } + }); + }); + + describe('description resolution', () => { + it('resolves static descriptions', () => { + const resolvedContent = getResolvedContent('idle', baseContext); + expect(React.isValidElement(resolvedContent.description)).toBe(true); + }); + + it('resolves function-based descriptions with context', () => { + const resolvedContent = getResolvedContent('completed', { + ...baseContext, + appName: 'My Test App', + instanceUrl: 'https://test-instance.com', + }); + + expect(React.isValidElement(resolvedContent.description)).toBe(true); + // The description should contain the app name + const descriptionString = JSON.stringify(resolvedContent.description); + expect(descriptionString).toContain('My Test App'); + }); + }); +}); + +describe('KeylessPrompt component', () => { + it('renders with idle state content when user is not signed in', async () => { + const { wrapper } = await createFixtures(); + const { getAllByText } = render( + , + { wrapper }, + ); + + // The text appears in both the trigger button and the CTA link + const elements = getAllByText('Configure your application'); + expect(elements).toHaveLength(2); + expect(elements[0]).toBeInTheDocument(); + expect(elements[1]).toBeInTheDocument(); + }); + + it('renders with userCreated state content when user is signed in', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { getByText } = render( + , + { wrapper }, + ); + + expect(getByText("You've created your first user!")).toBeInTheDocument(); + }); + + it('renders CTA link with correct href for idle state', async () => { + const { wrapper } = await createFixtures(); + const claimUrl = 'https://dashboard.clerk.com/claim?test=123'; + + const { getByRole } = render( + , + { wrapper }, + ); + + const link = getByRole('link', { name: 'Configure your application' }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', expect.stringContaining('claim')); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('renders CTA button for completed state when onDismiss is provided', async () => { + const onDismiss = vi.fn().mockResolvedValue(undefined); + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + // Mock environment to simulate claimed state + f.withClaimedAt(new Date().toISOString()); + }); + + const { getByRole } = render( + , + { wrapper }, + ); + + const button = getByRole('button', { name: 'Dismiss' }); + expect(button).toBeInTheDocument(); + expect(button.tagName).toBe('BUTTON'); + }); + + it('toggles expanded state when trigger button is clicked', async () => { + const { wrapper } = await createFixtures(); + const { getByRole, container } = render( + , + { wrapper }, + ); + + const triggerButton = getByRole('button', { name: /keyless prompt/i }); + const promptContainer = container.querySelector('[data-expanded]'); + + // Initially should be expanded (isOpen defaults to true) + expect(promptContainer).toHaveAttribute('data-expanded', 'true'); + + // Click to collapse + await triggerButton.click(); + expect(promptContainer).toHaveAttribute('data-expanded', 'false'); + + // Click again to expand + await triggerButton.click(); + expect(promptContainer).toHaveAttribute('data-expanded', 'true'); + }); + + it('renders description content correctly for idle state', async () => { + const { wrapper } = await createFixtures(); + const { getByText } = render( + , + { wrapper }, + ); + + expect(getByText(/Temporary API keys are enabled/i)).toBeInTheDocument(); + expect(getByText(/Add SSO connections/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx index cc445a75774..5bd8d6517ce 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx @@ -1,21 +1,10 @@ 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 { type ReactNode, useId, 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 { handleDashboardUrlParsing } from '../shared'; import { useRevalidateEnvironment } from './use-revalidate-environment'; type KeylessPromptProps = { @@ -24,548 +13,564 @@ 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 */ -function withLastActiveFallback(cb: () => string): string { +function withLastActiveFallback(callback: () => string): string { try { - return cb(); + return callback(); } catch { return 'https://dashboard.clerk.com/last-active'; } } -const KeylessPromptInternal = (_props: KeylessPromptProps) => { - const { isSignedIn } = useUser(); - const [isExpanded, setIsExpanded] = useState(false); +const WIDTH_OPEN = '18rem'; +const DURATION_OPEN = '220ms'; +const DURATION_CLOSE = '180ms'; +const EASE_BEZIER = 'cubic-bezier(0.2, 0, 0, 1)'; +const CSS_RESET = css` + margin: 0; + padding: 0; + box-sizing: border-box; + background: none; + border: none; + font-family: + -apple-system, + BlinkMacSystemFont, + avenir next, + avenir, + segoe ui, + helvetica neue, + helvetica, + Cantarell, + Ubuntu, + roboto, + noto, + arial, + sans-serif; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + text-decoration: none; + color: inherit; + appearance: none; +`; + +function getDuration(isOpen: boolean): string { + return isOpen ? DURATION_OPEN : DURATION_CLOSE; +} - useEffect(() => { - if (isSignedIn) { - setIsExpanded(true); +const ctaButtonStyles = css` + ${CSS_RESET}; + margin: 0.75rem 0 0; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 1.75rem; + padding: 0.25rem 0.625rem; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.12px; + color: #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; + outline: none; + &:hover { + background: #4b4b4b; + transition: background-color 120ms ease-in-out; + + @media (prefers-reduced-motion: reduce) { + transition: none; } - }, [isSignedIn]); + } + &:focus-visible { + outline: 2px solid #6c47ff; + outline-offset: 2px; + } +`; + +export type STATES = 'idle' | 'userCreated' | 'claimed' | 'completed'; + +type DescriptionContent = ReactNode | ((context: { appName: string; instanceUrl: string }) => ReactNode); + +type CtaLink = { + kind: 'link'; + text: string; + href: string | ((urls: { claimUrl: string; instanceUrl: string }) => string); +}; + +type CtaAction = { + kind: 'action'; + text: string; + onClick: (onDismiss: (() => Promise) | undefined | null) => void; +}; + +type ContentItem = { + triggerWidth: string; + title: string; + description: DescriptionContent; + cta: CtaLink | CtaAction; +}; + +const CONTENT: Record = { + idle: { + triggerWidth: '14.25rem', + title: 'Configure your application', + description: ( + <> +

Temporary API keys are enabled so you can get started immediately.

+
    + {['Add SSO connections (eg. GitHub)', 'Set up B2B authentication', 'Enable MFA'].map(item => ( +
  • {item}
  • + ))} +
+

Access the dashboard to customize auth settings and explore Clerk features.

+ + ), + cta: { + kind: 'link', + text: 'Configure your application', + href: ({ claimUrl }) => claimUrl, + }, + }, + userCreated: { + triggerWidth: '15.75rem', + title: "You've created your first user!", + description: ( +

Head to the dashboard to customize authentication settings, view user info, and explore more features.

+ ), + cta: { + kind: 'link', + text: 'Configure your application', + href: ({ claimUrl }) => claimUrl, + }, + }, + claimed: { + triggerWidth: '14.25rem', + title: 'Missing environment keys', + description: ( +

+ You claimed this application but haven't set keys in your environment. Get them from the Clerk Dashboard. +

+ ), + cta: { + kind: 'link', + text: 'Get API keys', + href: ({ claimUrl }) => claimUrl, + }, + }, + completed: { + triggerWidth: '10.5rem', + title: 'Your app is ready', + description: ({ appName, instanceUrl }) => ( +

+ Your application{' '} + + {appName} + {' '} + has been configured. You may now customize your settings in the Clerk dashboard. +

+ ), + cta: { + kind: 'action', + text: 'Dismiss', + onClick: onDismiss => { + void onDismiss?.().then(() => { + window.location.reload(); + }); + }, + }, + }, +}; + +/** + * Determines the current state based on application lifecycle flags. + * State precedence: completed -> claimed -> userCreated -> idle + * + * Note: This is a structural refactor - the actual runtime behavior for + * `claimed` and `success` is determined by environment state and props. + * Currently, `claimed` comes from `environment.authConfig.claimedAt` and + * `success` is derived from `onDismiss` prop presence + claimed state. + */ +export function getCurrentState(claimed: boolean, success: boolean, isSignedIn: boolean): STATES { + if (success) { + return 'completed'; + } + if (claimed) { + return 'claimed'; + } + if (isSignedIn) { + return 'userCreated'; + } + return 'idle'; +} +type ResolvedContentContext = { + appName: string; + instanceUrl: string; + claimUrl: string; + onDismiss: (() => Promise) | undefined | null; +}; + +type ResolvedContent = { + state: STATES; + triggerWidth: string; + title: string; + description: ReactNode; + cta: + | { + kind: 'link'; + text: string; + href: string; + } + | { + kind: 'action'; + text: string; + onClick: () => void; + }; +}; + +/** + * Gets resolved content from state and context. + * This is a pure function that can be easily unit tested. + */ +export function getResolvedContent(state: STATES, context: ResolvedContentContext): ResolvedContent { + const content = CONTENT[state]; + + const description = + typeof content.description === 'function' + ? content.description({ appName: context.appName, instanceUrl: context.instanceUrl }) + : content.description; + + const ctaItem = content.cta; + const cta: ResolvedContent['cta'] = + ctaItem.kind === 'link' + ? { + kind: 'link', + text: ctaItem.text, + href: + typeof ctaItem.href === 'function' + ? ctaItem.href({ claimUrl: context.claimUrl, instanceUrl: context.instanceUrl }) + : ctaItem.href, + } + : { + kind: 'action', + text: ctaItem.text, + onClick: () => ctaItem.onClick(context.onDismiss), + }; + + return { + state, + triggerWidth: content.triggerWidth, + title: content.title, + description, + cta, + }; +} + +function KeylessPromptInternal(props: KeylessPromptProps) { + const id = useId(); const environment = useRevalidateEnvironment(); + const claimed = Boolean(environment.authConfig.claimedAt); - const success = typeof _props.onDismiss === 'function' && claimed; + const success = typeof props.onDismiss === 'function' && claimed; + const { isSignedIn } = useUser(); const appName = environment.displayConfig.applicationName; - const isForcedExpanded = claimed || success || isExpanded; const claimUrlToDashboard = useMemo(() => { if (claimed) { - return _props.copyKeysUrl; + return props.copyKeysUrl; } - - const url = new URL(_props.claimUrl); - // Clerk Dashboard accepts a `return_url` query param when visiting `/apps/claim`. + const url = new URL(props.claimUrl); 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]); - - 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; - `; + const [isOpen, setIsOpen] = useState(true); + const currentState = getCurrentState(claimed, success, Boolean(isSignedIn)); + + const resolvedContent = useMemo( + () => + getResolvedContent(currentState, { + appName, + instanceUrl: instanceUrlToDashboard, + claimUrl: claimUrlToDashboard, + onDismiss: props.onDismiss, + }), + [currentState, appName, instanceUrlToDashboard, claimUrlToDashboard, props.onDismiss], + ); + + const ctaElement: ReactNode = + resolvedContent.cta.kind === 'link' ? ( + + {resolvedContent.cta.text} + + ) : ( + + ); return ( - - ({ - position: 'fixed', - bottom: '1.25rem', - insetInlineEnd: '1.25rem', - height: `${t.sizes.$10}`, - minWidth: '13.4rem', - paddingInlineStart: `${t.space.$3}`, - borderRadius: '1.25rem', - transition: 'all 195ms cubic-bezier(0.2, 0.61, 0.1, 1)', - - '&[data-expanded="false"]:hover': { - background: 'linear-gradient(180deg, rgba(255, 255, 255, 0.20) 0%, rgba(255, 255, 255, 0) 100%), #1f1f1f', - }, - - '&[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)', - }, - })} +
+ - - ({ - flexDirection: 'column', - gap: t.space.$3, - })} + `} + viewBox='0 0 16 16' + fill='none' + aria-hidden='true' + xmlns='http://www.w3.org/2000/svg' + > + + + +
+
- {isForcedExpanded && - (success ? ( - - ) : ( - ({ - flexDirection: 'column', - alignItems: 'center', - gap: t.space.$2x5, - })} - > - - {claimed ? 'Get API keys' : 'Claim application'} - - - ))} - - - - - - + {ctaElement} +
+
+
+ ); -}; - -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; -}; +export function KeylessPrompt(props: KeylessPromptProps) { + return ( + + + + ); +} diff --git a/packages/ui/src/test/create-fixtures.tsx b/packages/ui/src/test/create-fixtures.tsx index 628ca78fa6b..6824c98ec14 100644 --- a/packages/ui/src/test/create-fixtures.tsx +++ b/packages/ui/src/test/create-fixtures.tsx @@ -106,6 +106,7 @@ const unboundCreateFixtures = ( 'SubscriptionDetails', 'PlanDetails', 'Checkout', + 'KeylessPrompt', ]; const contextWrappedChildren = !componentsWithoutContext.includes(componentName) ? ( { const withReverification = () => { ac.reverification = true; }; - return { withMultiSessionMode, withReverification }; + const withClaimedAt = (claimedAt: string | null) => { + ac.claimed_at = claimedAt; + }; + return { withMultiSessionMode, withReverification, withClaimedAt }; }; const createDisplayConfigFixtureHelpers = (environment: EnvironmentJSON) => {