fix(interfaces): always-light public surfaces (chat, resume, file shares, unsubscribe, invite) with emcn components (#5336)

* fix(interfaces): pin deployed chat + resume to light mode, emcn-only components

* fix(interfaces): review feedback — formErrors dep in field renderer, keep overlay tint on attachment remove

* improvement(interfaces): chat header uses the platform SimWordmark instead of the svg asset

* fix(interfaces): light mode for file-share gates, unsubscribe, and invite surfaces
This commit is contained in:
Theodore Li
2026-07-01 19:20:40 -04:00
committed by GitHub
parent 44215126f4
commit 62acc9c53f
26 changed files with 564 additions and 961 deletions
@@ -1,93 +0,0 @@
export default function AuthBackgroundSVG() {
return (
<>
{/* Top-left card outline */}
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-3vw] left-[-10vw] z-[5] aspect-[344/328] w-[38vw]'
>
<svg
viewBox='0 0 344 328'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid meet'
className='h-full w-full'
>
<path
d='M322.641 326.586L335.508 326.586C339.926 326.586 343.508 323.004 343.508 318.586V153.613C343.508 149.195 339.926 145.613 335.508 145.613H228.282C223.864 145.613 220.282 142.031 220.282 137.613V-50H190.282V137.613C190.282 142.031 186.7 145.613 182.282 145.613H-157V318.586C-157 323.004 -153.418 326.586 -149 326.586H322.641Z'
fill='#1C1C1C'
stroke='#323232'
strokeOpacity='0.4'
strokeWidth='1'
/>
</svg>
</div>
{/* Top-right card outline */}
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-4vw] right-[-14vw] z-[5] aspect-[471/470] w-[42vw]'
>
<svg
viewBox='0 0 471 470'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid meet'
className='h-full w-full'
>
<path
d='M471 94.274L471 124.274L365.88 124.274C361.462 124.274 357.88 127.856 357.88 132.274L357.88 225.495C357.88 229.913 354.298 233.495 349.88 233.495L219.5 233.495C215.082 233.495 211.5 237.077 211.5 241.495L211.5 461.5C211.5 465.918 207.918 469.5 203.5 469.5L8.5 469.5C4.082 469.5 0.5 465.918 0.5 461.5L0.5 157.274C0.5 152.856 4.082 149.274 8.5 149.274L184 149.274C188.418 149.274 192 145.692 192 141.274L192 102.274C192 97.856 195.582 94.274 200 94.274L471 94.274Z'
fill='#1C1C1C'
stroke='#323232'
strokeOpacity='0.4'
strokeWidth='1'
/>
</svg>
</div>
{/* Bottom-left card outline (mirrored) */}
<div
aria-hidden='true'
className='pointer-events-none absolute bottom-[-6vw] left-[-12vw] z-[5] aspect-[471/470] w-[36vw] rotate-180'
>
<svg
viewBox='0 0 471 470'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid meet'
className='h-full w-full'
>
<path
d='M471 94.274L471 124.274L365.88 124.274C361.462 124.274 357.88 127.856 357.88 132.274L357.88 225.495C357.88 229.913 354.298 233.495 349.88 233.495L219.5 233.495C215.082 233.495 211.5 237.077 211.5 241.495L211.5 461.5C211.5 465.918 207.918 469.5 203.5 469.5L8.5 469.5C4.082 469.5 0.5 465.918 0.5 461.5L0.5 157.274C0.5 152.856 4.082 149.274 8.5 149.274L184 149.274C188.418 149.274 192 145.692 192 141.274L192 102.274C192 97.856 195.582 94.274 200 94.274L471 94.274Z'
fill='#1C1C1C'
stroke='#323232'
strokeOpacity='0.4'
strokeWidth='1'
/>
</svg>
</div>
{/* Bottom-right card outline (mirrored) */}
<div
aria-hidden='true'
className='pointer-events-none absolute right-[-12vw] bottom-[-5vw] z-[5] aspect-[344/328] w-[34vw] rotate-180'
>
<svg
viewBox='0 0 344 328'
fill='none'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid meet'
className='h-full w-full'
>
<path
d='M322.641 326.586L335.508 326.586C339.926 326.586 343.508 323.004 343.508 318.586V153.613C343.508 149.195 339.926 145.613 335.508 145.613H228.282C223.864 145.613 220.282 142.031 220.282 137.613V-50H190.282V137.613C190.282 142.031 186.7 145.613 182.282 145.613H-157V318.586C-157 323.004 -153.418 326.586 -149 326.586H322.641Z'
fill='#1C1C1C'
stroke='#323232'
strokeOpacity='0.4'
strokeWidth='1'
/>
</svg>
</div>
</>
)
}
@@ -1,17 +0,0 @@
import { cn } from '@sim/emcn'
import AuthBackgroundSVG from '@/app/(auth)/components/auth-background-svg'
type AuthBackgroundProps = {
className?: string
children?: React.ReactNode
}
export default function AuthBackground({ className, children }: AuthBackgroundProps) {
return (
<div className={cn('fixed inset-0 overflow-hidden', className)}>
<div className='-z-50 pointer-events-none absolute inset-0 bg-[var(--landing-bg)]' />
<AuthBackgroundSVG />
<div className='relative z-20 h-full overflow-auto'>{children}</div>
</div>
)
}
@@ -1,10 +1,3 @@
/** Shared className for primary auth/status CTA buttons on dark auth surfaces. */
export const AUTH_PRIMARY_CTA_BASE =
'inline-flex h-[32px] items-center justify-center gap-2 rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] px-2.5 font-[430] font-season text-[var(--auth-primary-btn-text)] text-sm transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)] hover:text-[var(--auth-primary-btn-hover-text)] disabled:cursor-not-allowed disabled:opacity-50' as const
/** Full-width variant used for primary auth form submit buttons. */
export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const
/** Shared className for inline auth action links on dark auth surfaces. */
/** Shared className for inline auth action links. */
export const AUTH_TEXT_LINK =
'font-medium text-[var(--brand-accent)] underline-offset-4 transition hover:text-[var(--brand-accent-hover)] hover:underline disabled:cursor-not-allowed disabled:opacity-50' as const
@@ -14,9 +14,8 @@ interface AuthSubmitButtonProps {
/**
* The canonical full-width primary auth action — a `primary`-variant {@link Chip}
* with the shared in-flight spinner. Replaces the legacy dark
* `AUTH_SUBMIT_BTN` class string for every in-scope auth submit (login, signup,
* verify, reset), so the primary CTA chrome lives in exactly one place.
* with the shared in-flight spinner, so the primary CTA chrome lives in
* exactly one place.
*/
export function AuthSubmitButton({
children,
@@ -426,7 +426,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}
return (
<div className='dark fixed inset-0 z-[100] flex flex-col bg-[var(--landing-bg)] text-[var(--landing-text)]'>
<div className='light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
{/* Header component */}
<ChatHeader chatConfig={chatConfig} starCount={starCount} />
@@ -2,7 +2,7 @@ import { Skeleton } from '@sim/emcn'
export default function ChatLoading() {
return (
<div className='dark fixed inset-0 z-[100] flex flex-col bg-[var(--landing-bg)] text-[var(--landing-text)]'>
<div className='light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
<div className='border-[var(--border-1)] border-b px-4 py-3'>
<div className='mx-auto flex max-w-3xl items-center justify-between'>
<div className='flex items-center gap-[12px]'>
@@ -1,5 +1,6 @@
'use client'
import { Button } from '@sim/emcn'
import { useRouter } from 'next/navigation'
interface ChatErrorStateProps {
@@ -16,12 +17,13 @@ export function ChatErrorState({ error }: ChatErrorStateProps) {
Chat Unavailable
</h1>
<p className='text-[var(--text-muted)] text-lg'>{error}</p>
<button
<Button
variant='primary'
onClick={() => router.push('/workspace')}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-[var(--text-primary)] bg-[var(--text-primary)] px-2.5 text-[var(--bg)] text-sm transition-colors hover:border-[var(--text-body)] hover:bg-[var(--text-body)] disabled:cursor-not-allowed disabled:opacity-50'
className='h-[32px] w-full gap-2 px-2.5 text-sm'
>
Return to Workspace
</button>
</Button>
</div>
</div>
)
@@ -3,6 +3,7 @@
import Image from 'next/image'
import Link from 'next/link'
import { GithubIcon } from '@/components/icons'
import { SimWordmark } from '@/app/(landing)/components/navbar/components'
import { useBrandConfig } from '@/ee/whitelabeling'
interface ChatHeaderProps {
@@ -20,13 +21,12 @@ interface ChatHeaderProps {
export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
const brand = useBrandConfig()
const primaryColor = chatConfig?.customizations?.primaryColor || 'var(--brand)'
const customImage = chatConfig?.customizations?.imageUrl || chatConfig?.customizations?.logoUrl
return (
<nav
aria-label='Chat navigation'
className={`flex w-full items-center justify-between px-4 pt-3 pb-[21px] sm:px-8 sm:pt-[8.5px] md:px-[44px] md:pt-4`}
className='flex w-full items-center justify-between px-4 pt-3 pb-[21px] sm:px-8 sm:pt-[8.5px] md:px-[44px] md:pt-4'
>
<div className='flex items-center gap-[34px]'>
<div className='flex items-center gap-3'>
@@ -40,7 +40,7 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
className='size-6 rounded-md object-cover'
/>
)}
<h2 className='font-medium text-[var(--landing-text)] text-lg'>
<h2 className='font-medium text-[var(--text-primary)] text-lg'>
{chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
</h2>
</div>
@@ -52,7 +52,7 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
href='https://github.com/simstudioai/sim'
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-2 text-[var(--landing-text-muted)] text-md transition-colors hover:text-[var(--landing-text)]'
className='flex items-center gap-2 text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
aria-label={`GitHub repository - ${starCount} stars`}
>
<GithubIcon className='size-[16px]' aria-hidden='true' />
@@ -65,15 +65,9 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
target='_blank'
rel='noopener noreferrer'
aria-label='Sim home'
className='flex items-center'
>
<Image
src='/logo/sim-landing.svg'
alt='Sim'
width={71}
height={22}
className='h-[22px] w-auto'
priority
/>
<SimWordmark />
</Link>
</div>
)}
@@ -2,7 +2,7 @@
import type React from 'react'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
import { Badge, Button, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { ArrowUp, Mic, Paperclip, X } from 'lucide-react'
@@ -168,7 +168,7 @@ export const ChatInput: React.FC<{
return (
<Tooltip.Provider>
<div className='fixed right-0 bottom-0 left-0 flex w-full items-center justify-center bg-gradient-to-t from-[var(--landing-bg)] to-transparent px-4 pb-4 md:px-0 md:pb-4'>
<div className='fixed right-0 bottom-0 left-0 flex w-full items-center justify-center bg-gradient-to-t from-[var(--bg)] to-transparent px-4 pb-4 md:px-0 md:pb-4'>
<div className='w-full max-w-3xl md:max-w-[748px]'>
{/* Error Messages */}
{uploadErrors.length > 0 && (
@@ -191,7 +191,7 @@ export const ChatInput: React.FC<{
handleKeyboardActivation(event, focusTextarea)
}}
className={cn(
'relative z-10 cursor-text rounded-2xl border border-[var(--border-1)] bg-[var(--landing-bg-elevated)] px-2.5 py-2',
'relative z-10 cursor-text rounded-2xl border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2',
isDragOver && 'border-purple-500'
)}
onDragEnter={(e) => {
@@ -222,7 +222,7 @@ export const ChatInput: React.FC<{
{attachedFiles.map((file) => (
<Tooltip.Root key={file.id}>
<Tooltip.Trigger asChild>
<div className='group relative size-[56px] flex-shrink-0 cursor-pointer overflow-hidden rounded-[8px] border border-[var(--border-1)] bg-[var(--landing-bg)]'>
<div className='group relative size-[56px] flex-shrink-0 cursor-pointer overflow-hidden rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-3)]'>
{file.dataUrl ? (
<img
src={file.dataUrl}
@@ -230,23 +230,23 @@ export const ChatInput: React.FC<{
className='h-full w-full object-cover'
/>
) : (
<div className='flex h-full w-full flex-col items-center justify-center gap-0.5 text-[var(--landing-text-muted)]'>
<div className='flex h-full w-full flex-col items-center justify-center gap-0.5 text-[var(--text-muted)]'>
<Paperclip className='size-[18px]' />
<span className='max-w-[48px] truncate px-[2px] text-[9px]'>
{file.name.split('.').pop()}
</span>
</div>
)}
<button
type='button'
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleRemoveFile(file.id)
}}
className='absolute top-[2px] right-[2px] flex size-[16px] items-center justify-center rounded-full bg-black/60 opacity-0 group-hover:opacity-100'
className='absolute top-[2px] right-[2px] size-[16px] rounded-full bg-black/60 p-0 text-white opacity-0 hover-hover:text-white group-hover:opacity-100'
>
<X className='size-[10px] text-white' />
</button>
<X className='size-[10px]' />
</Button>
</div>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
@@ -265,7 +265,7 @@ export const ChatInput: React.FC<{
onKeyDown={handleKeyDown}
placeholder={isDragOver ? 'Drop files here...' : 'Enter a message...'}
rows={1}
className='m-0 h-auto min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent p-1 text-[15px] text-[var(--landing-text)] leading-[24px] caret-[var(--landing-text)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--landing-text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden'
className='m-0 h-auto min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent p-1 text-[15px] text-[var(--text-primary)] leading-[24px] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden'
/>
{/* Bottom row */}
@@ -274,14 +274,14 @@ export const ChatInput: React.FC<{
<div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
<Button
variant='quiet'
onClick={() => fileInputRef.current?.click()}
disabled={isStreaming || attachedFiles.length >= 15}
className='flex size-[28px] items-center justify-center rounded-full text-[var(--landing-text-muted)] transition-colors hover:bg-[#F7F7F7] disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-[#303030]'
className='size-[28px] rounded-full p-0'
>
<Paperclip className='size-[16px]' strokeWidth={2} />
</button>
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Attach files</p>
@@ -307,14 +307,14 @@ export const ChatInput: React.FC<{
{sttAvailable && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
<Button
variant='quiet'
onClick={onVoiceStart}
disabled={isStreaming}
className='flex size-[28px] items-center justify-center rounded-full text-[var(--landing-text-muted)] transition-colors hover:bg-[#F7F7F7] disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-[#303030]'
className='size-[28px] rounded-full p-0'
>
<Mic className='size-[16px]' strokeWidth={2} />
</button>
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Start voice conversation</p>
@@ -323,37 +323,29 @@ export const ChatInput: React.FC<{
)}
{isStreaming ? (
<button
type='button'
<Button
variant='primary'
onClick={onStopStreaming}
className='flex size-[28px] items-center justify-center rounded-full border-0 bg-[#383838] p-0 transition-colors hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
className='size-[28px] rounded-full p-0'
title='Stop generation'
>
<svg
className='block size-[14px] fill-white dark:fill-black'
className='block size-[14px] fill-current'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
</svg>
</button>
</Button>
) : (
<button
type='button'
<Button
variant='primary'
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
'flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors',
canSubmit
? 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
: 'bg-[#808080] dark:bg-[#808080]'
)}
className='size-[28px] rounded-full p-0'
>
<ArrowUp
className='block size-[16px] text-white dark:text-black'
strokeWidth={2.25}
/>
</button>
<ArrowUp className='block size-[16px]' strokeWidth={2.25} />
</Button>
)}
</div>
</div>
@@ -2,7 +2,7 @@ import { Skeleton } from '@sim/emcn'
export function ChatLoadingState() {
return (
<div className='dark fixed inset-0 z-[100] flex flex-col bg-[var(--landing-bg)]'>
<div className='light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)]'>
<div className='flex flex-1 items-center justify-center px-4'>
<div className='w-full max-w-[410px]'>
<div className='flex flex-col items-center justify-center'>
@@ -1,8 +1,8 @@
'use client'
import { memo, type RefObject } from 'react'
import { Button } from '@sim/emcn'
import { ArrowDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
type ChatMessage,
ClientChatMessage,
@@ -42,10 +42,10 @@ export const ChatMessageContainer = memo(function ChatMessageContainer({
{messages.length === 0 ? (
<div className='flex flex-col items-center justify-center py-10'>
<div className='space-y-2 text-center'>
<h3 className='font-medium text-[var(--landing-text)] text-lg'>
<h3 className='font-medium text-[var(--text-primary)] text-lg'>
How can I help you today?
</h3>
<p className='text-[var(--landing-text-muted)] text-sm'>
<p className='text-[var(--text-muted)] text-sm'>
{chatConfig?.description || 'Ask me anything.'}
</p>
</div>
@@ -61,7 +61,7 @@ export const ChatMessageContainer = memo(function ChatMessageContainer({
<div className='flex'>
<div className='max-w-[80%]'>
<div className='flex h-6 items-center'>
<div className='loading-dot size-3 rounded-full bg-[var(--landing-text)]' />
<div className='loading-dot size-3 rounded-full bg-[var(--text-primary)]' />
</div>
</div>
</div>
@@ -80,8 +80,7 @@ export const ChatMessageContainer = memo(function ChatMessageContainer({
<Button
onClick={scrollToBottom}
size='sm'
variant='outline'
className='flex items-center gap-1 rounded-full border border-[var(--border-1)] bg-[var(--landing-bg-elevated)] px-3 py-1 shadow-lg transition-all hover:opacity-80'
className='gap-1 rounded-full px-3 py-1 shadow-lg'
>
<ArrowDown className='size-3.5' />
<span className='sr-only'>Scroll to bottom</span>
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Button, Download, Loader } from '@sim/emcn'
import { Button, cn, Download, Loader } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import { Music } from 'lucide-react'
@@ -114,14 +114,14 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) {
const renderIcon = () => {
if (isAudioFile(file.type, file.name)) {
return <Music className='h-4 w-4 text-purple-500' />
return <Music className='size-4 text-purple-500' />
}
if (isImageFile(file.type)) {
const ImageIcon = DefaultFileIcon
return <ImageIcon className='h-5 w-5' />
return <ImageIcon className='size-5' />
}
const DocumentIcon = getDocumentIcon(file.type, file.name)
return <DocumentIcon className='h-5 w-5' />
return <DocumentIcon className='size-5' />
}
return (
@@ -133,19 +133,17 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) {
disabled={isDownloading}
className='flex h-auto w-[200px] items-center gap-2 rounded-lg px-3 py-2'
>
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center'>{renderIcon()}</div>
<div className='flex size-8 flex-shrink-0 items-center justify-center'>{renderIcon()}</div>
<div className='min-w-0 flex-1 text-left'>
<div className='w-[100px] truncate text-xs'>{file.name}</div>
<div className='text-[var(--landing-text-muted)] text-micro'>
{formatFileSize(file.size)}
</div>
<div className='text-[var(--text-muted)] text-micro'>{formatFileSize(file.size)}</div>
</div>
<div className='flex-shrink-0'>
{isDownloading ? (
<Loader className='h-3.5 w-3.5' animate />
<Loader className='size-3.5' animate />
) : (
<Download
className={`h-3.5 w-3.5 transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
className={cn('size-3.5 transition-opacity', isHovered ? 'opacity-100' : 'opacity-0')}
/>
)}
</div>
@@ -186,16 +184,17 @@ export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) {
}
return (
<button
<Button
variant='ghost-secondary'
onClick={handleDownloadAll}
disabled={isDownloading}
className='text-[var(--landing-text-muted)] transition-colors hover:bg-[var(--landing-bg-elevated)] disabled:opacity-50'
className='p-0'
>
{isDownloading ? (
<Loader className='h-3 w-3' animate />
<Loader className='size-3' animate />
) : (
<Download className='h-3 w-3' strokeWidth={2} />
<Download className='size-3' strokeWidth={2} />
)}
</button>
</Button>
)
}
@@ -10,7 +10,7 @@ function LinkWithPreview({ href, children }: { href: string; children: React.Rea
<Tooltip.Trigger asChild>
<a
href={href}
className='text-blue-600 hover:underline dark:text-blue-400'
className='text-[var(--text-primary)] underline decoration-dashed underline-offset-4'
target='_blank'
rel='noopener noreferrer'
>
@@ -26,35 +26,35 @@ function LinkWithPreview({ href, children }: { href: string; children: React.Rea
const COMPONENTS = {
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className='mb-1 font-sans text-base text-gray-800 leading-relaxed last:mb-0 dark:text-gray-200'>
<p className='mb-1 font-sans text-[var(--text-primary)] text-base leading-relaxed last:mb-0'>
{children}
</p>
),
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-10 mb-5 font-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
<h1 className='mt-10 mb-5 font-sans font-semibold text-2xl text-[var(--text-primary)]'>
{children}
</h1>
),
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className='mt-8 mb-4 font-sans font-semibold text-gray-900 text-xl dark:text-gray-100'>
<h2 className='mt-8 mb-4 font-sans font-semibold text-[var(--text-primary)] text-xl'>
{children}
</h2>
),
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className='mt-7 mb-3 font-sans font-semibold text-gray-900 text-lg dark:text-gray-100'>
<h3 className='mt-7 mb-3 font-sans font-semibold text-[var(--text-primary)] text-lg'>
{children}
</h3>
),
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4 className='mt-5 mb-2 font-sans font-semibold text-base text-gray-900 dark:text-gray-100'>
<h4 className='mt-5 mb-2 font-sans font-semibold text-[var(--text-primary)] text-base'>
{children}
</h4>
),
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-gray-800 dark:text-gray-200'
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-[var(--text-primary)]'
style={{ listStyleType: 'disc' }}
>
{children}
@@ -62,14 +62,14 @@ const COMPONENTS = {
),
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
<ol
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-gray-800 dark:text-gray-200'
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-[var(--text-primary)]'
style={{ listStyleType: 'decimal' }}
>
{children}
</ol>
),
li: ({ children }: React.LiHTMLAttributes<HTMLLIElement>) => (
<li className='font-sans text-gray-800 dark:text-gray-200' style={{ display: 'list-item' }}>
<li className='font-sans text-[var(--text-primary)]' style={{ display: 'list-item' }}>
{children}
</li>
),
@@ -91,17 +91,17 @@ const COMPONENTS = {
}
return (
<div className='my-6 rounded-md bg-gray-900 text-sm dark:bg-black'>
<div className='flex items-center justify-between border-gray-700 border-b px-4 py-1.5 dark:border-gray-800'>
<span className='font-sans text-gray-400 text-xs'>
<div className='my-6 overflow-hidden rounded-lg border border-[var(--divider)] text-sm'>
<div className='flex items-center justify-between border-[var(--divider)] border-b bg-[var(--surface-4)] px-4 py-1.5'>
<span className='font-sans text-[var(--text-tertiary)] text-xs'>
{codeProps.className?.replace('language-', '') || 'code'}
</span>
<CopyCodeButton
code={extractTextContent(codeContent)}
className='text-gray-400 hover-hover:bg-gray-700 hover-hover:text-gray-200'
className='text-[var(--text-tertiary)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-secondary)]'
/>
</div>
<pre className='overflow-x-auto p-4 font-mono text-gray-200 dark:text-gray-100'>
<pre className='overflow-x-auto bg-[var(--surface-5)] p-4 font-mono text-[var(--text-primary)]'>
{codeContent}
</pre>
</div>
@@ -109,7 +109,7 @@ const COMPONENTS = {
},
inlineCode: ({ children }: { children?: React.ReactNode }) => (
<code className='rounded bg-gray-200 px-1 py-0.5 font-mono text-gray-800 text-inherit dark:bg-gray-700 dark:text-gray-200'>
<code className='rounded bg-[var(--surface-5)] px-1 py-0.5 font-mono text-[var(--text-primary)] text-inherit'>
{children}
</code>
),
@@ -120,7 +120,7 @@ const COMPONENTS = {
</blockquote>
),
hr: () => <hr className='my-8 border-gray-500/[.07] border-t dark:border-gray-400/[.07]' />,
hr: () => <hr className='my-8 border-[var(--divider)] border-t' />,
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<LinkWithPreview href={href || '#'} {...props}>
@@ -130,31 +130,29 @@ const COMPONENTS = {
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-4 w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-gray-300 font-sans text-sm dark:border-gray-700'>
<table className='min-w-full table-auto border border-[var(--border)] font-sans text-sm'>
{children}
</table>
</div>
),
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className='bg-gray-100 text-left dark:bg-gray-800'>{children}</thead>
<thead className='bg-[var(--surface-3)] text-left'>{children}</thead>
),
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<tbody className='divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900'>
{children}
</tbody>
<tbody className='divide-y divide-[var(--divider)] bg-[var(--surface-2)]'>{children}</tbody>
),
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr className='border-gray-200 border-b transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800/60'>
<tr className='border-[var(--divider)] border-b transition-colors hover:bg-[var(--surface-hover)]'>
{children}
</tr>
),
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th className='border-gray-300 border-r px-4 py-2 font-medium text-gray-700 last:border-r-0 dark:border-gray-700 dark:text-gray-300'>
<th className='border-[var(--border)] border-r px-4 py-2 font-medium text-[var(--text-secondary)] last:border-r-0'>
{children}
</th>
),
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td className='break-words border-gray-300 border-r px-4 py-2 text-gray-800 last:border-r-0 dark:border-gray-700 dark:text-gray-200'>
<td className='break-words border-[var(--border)] border-r px-4 py-2 text-[var(--text-body)] last:border-r-0'>
{children}
</td>
),
@@ -166,7 +164,7 @@ const COMPONENTS = {
const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) {
return (
<div className='space-y-4 break-words font-sans text-[var(--landing-text)] text-base leading-relaxed'>
<div className='space-y-4 break-words font-sans text-[var(--text-primary)] text-base leading-relaxed'>
<Streamdown mode='static' components={COMPONENTS}>
{content.trim()}
</Streamdown>
@@ -1,7 +1,7 @@
'use client'
import { memo, useState } from 'react'
import { Duplicate, Tooltip } from '@sim/emcn'
import { Button, cn, Duplicate, Tooltip } from '@sim/emcn'
import { Check, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
import {
ChatFileDownload,
@@ -103,20 +103,12 @@ export const ClientChatMessage = memo(
const isImage = attachment.type.startsWith('image/')
const getFileIcon = (type: string) => {
if (type.includes('pdf'))
return (
<FileText className='h-5 w-5 text-[var(--landing-text-muted)] md:h-6 md:w-6' />
)
return <FileText className='size-5 text-[var(--text-muted)] md:size-6' />
if (type.startsWith('image/'))
return (
<ImageIcon className='h-5 w-5 text-[var(--landing-text-muted)] md:h-6 md:w-6' />
)
return <ImageIcon className='size-5 text-[var(--text-muted)] md:size-6' />
if (type.includes('text') || type.includes('json'))
return (
<FileText className='h-5 w-5 text-[var(--landing-text-muted)] md:h-6 md:w-6' />
)
return (
<FileIcon className='h-5 w-5 text-[var(--landing-text-muted)] md:h-6 md:w-6' />
)
return <FileText className='size-5 text-[var(--text-muted)] md:size-6' />
return <FileIcon className='size-5 text-[var(--text-muted)] md:size-6' />
}
const formatFileSize = (bytes?: number) => {
if (!bytes || bytes === 0) return ''
@@ -141,13 +133,13 @@ export const ClientChatMessage = memo(
role={isInteractive ? 'button' : undefined}
aria-disabled={!isInteractive}
tabIndex={isInteractive ? 0 : undefined}
className={`relative overflow-hidden rounded-2xl border border-[var(--border-1)] bg-[var(--landing-bg-elevated)] ${
isInteractive ? 'cursor-pointer' : ''
} ${
className={cn(
'relative overflow-hidden rounded-2xl border border-[var(--border-1)] bg-[var(--surface-2)]',
isInteractive && 'cursor-pointer',
isImage
? 'h-16 w-16 md:h-20 md:w-20'
? 'size-16 md:size-20'
: 'flex h-16 min-w-[140px] max-w-[220px] items-center gap-2 px-3 md:h-20 md:min-w-[160px] md:max-w-[240px]'
}`}
)}
onClick={(e) => {
if (!isInteractive) return
e.preventDefault()
@@ -169,19 +161,19 @@ export const ClientChatMessage = memo(
<img
src={attachment.dataUrl}
alt={attachment.name}
className='h-full w-full object-cover'
className='size-full object-cover'
/>
) : (
<>
<div className='flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-[var(--landing-bg)] md:h-12 md:w-12'>
<div className='flex size-10 flex-shrink-0 items-center justify-center rounded bg-[var(--surface-3)] md:size-12'>
{getFileIcon(attachment.type)}
</div>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-[var(--landing-text)] text-xs md:text-sm'>
<div className='truncate font-medium text-[var(--text-primary)] text-xs md:text-sm'>
{attachment.name}
</div>
{attachment.size && (
<div className='text-[var(--landing-text-muted)] text-micro md:text-xs'>
<div className='text-[var(--text-muted)] text-micro md:text-xs'>
{formatFileSize(attachment.size)}
</div>
)}
@@ -198,8 +190,8 @@ export const ClientChatMessage = memo(
{/* Only render message bubble if there's actual text content (not just file count message) */}
{message.content && !String(message.content).startsWith('Sent') && (
<div className='flex justify-end'>
<div className='max-w-[80%] rounded-3xl bg-[var(--landing-bg-elevated)] px-4 py-3'>
<div className='whitespace-pre-wrap break-words text-[var(--landing-text)] text-base leading-relaxed'>
<div className='max-w-[80%] rounded-3xl bg-[var(--surface-3)] px-4 py-3'>
<div className='whitespace-pre-wrap break-words text-[var(--text-primary)] text-base leading-relaxed'>
{isJsonObject ? (
<pre>{JSON.stringify(message.content, null, 2)}</pre>
) : (
@@ -219,7 +211,7 @@ export const ClientChatMessage = memo(
<div>
<div className='break-words text-base'>
{isJsonObject ? (
<pre className='text-[var(--landing-text)]'>
<pre className='text-[var(--text-primary)]'>
{JSON.stringify(cleanTextContent, null, 2)}
</pre>
) : (
@@ -240,8 +232,9 @@ export const ClientChatMessage = memo(
{!message.isStreaming && (
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild>
<button
className='text-[var(--landing-text-muted)] transition-colors hover:bg-[var(--landing-bg-elevated)]'
<Button
variant='ghost-secondary'
className='p-0'
onClick={() => {
const contentToCopy =
typeof cleanTextContent === 'string'
@@ -253,11 +246,11 @@ export const ClientChatMessage = memo(
}}
>
{isCopied ? (
<Check className='h-3 w-3' strokeWidth={2} />
<Check className='size-3' strokeWidth={2} />
) : (
<Duplicate className='h-3 w-3' />
<Duplicate className='size-3' />
)}
</button>
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' sideOffset={5}>
{isCopied ? 'Copied!' : 'Copy to clipboard'}
@@ -1,11 +1,10 @@
'use client'
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { Button, cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { Mic, MicOff, Phone } from 'lucide-react'
import dynamic from 'next/dynamic'
import { Button } from '@/components/ui/button'
import { requestJson } from '@/lib/api/client/request'
import { speechTokenContract } from '@/lib/api/contracts/media/speech'
import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio'
@@ -525,7 +524,7 @@ export function VoiceInterface({
return (
<div
className={cn(
'dark fixed inset-0 z-[100] flex flex-col bg-[var(--landing-bg)] text-[var(--landing-text)]',
'light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]',
className
)}
>
@@ -544,40 +543,32 @@ export function VoiceInterface({
<div className='mb-16 flex h-24 items-center justify-center'>
{currentTranscript && (
<div className='max-w-2xl px-8'>
<p className='overflow-hidden text-center text-[var(--landing-text)] text-xl leading-relaxed'>
<p className='overflow-hidden text-center text-[var(--text-primary)] text-xl leading-relaxed'>
{currentTranscript}
</p>
</div>
)}
</div>
<p className='mb-8 text-center text-[var(--landing-text-muted)] text-lg'>
<p className='mb-8 text-center text-[var(--text-muted)] text-lg'>
{getStatusText()}
{isMuted && (
<span className='ml-2 text-[var(--landing-text-muted)] text-sm'>(Muted)</span>
)}
{isMuted && <span className='ml-2 text-[var(--text-muted)] text-sm'>(Muted)</span>}
</p>
</div>
<div className='px-8 pb-12'>
<div className='flex items-center justify-center gap-x-12'>
<Button
onClick={handleCallEnd}
variant='outline'
size='icon'
className='size-14 rounded-full border-[var(--border-1)] hover:bg-[var(--landing-bg-elevated)]'
>
<Button onClick={handleCallEnd} variant='outline' className='size-14 rounded-full p-0'>
<Phone className='size-6 rotate-[135deg]' />
</Button>
<Button
onClick={toggleMute}
variant='outline'
size='icon'
disabled={!isInitialized}
className={cn(
'h-14 w-14 rounded-full border-[var(--border-1)] bg-transparent hover:bg-[var(--landing-bg-elevated)]',
isMuted ? 'text-[var(--landing-text-muted)]' : 'text-[var(--landing-text)]'
'size-14 rounded-full p-0',
isMuted ? 'text-[var(--text-muted)]' : 'text-[var(--text-primary)]'
)}
>
{getButtonContent()}
@@ -4,8 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
ChipInput,
ChipSelect,
ChipTextarea,
Code,
Input,
cn,
Label,
Table,
TableBody,
@@ -13,21 +16,11 @@ import {
TableHead,
TableHeader,
TableRow,
Textarea,
Tooltip,
} from '@sim/emcn'
import { useQueryClient } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { useRouter } from 'next/navigation'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Navbar } from '@/app/(landing)/components/navbar/navbar'
import { useBrandConfig } from '@/ee/whitelabeling'
import {
type PauseContextDetail,
type PausedExecutionDetail,
@@ -112,12 +105,12 @@ function getBlockNameFromSnapshot(
function renderStructuredValuePreview(value: unknown) {
if (value === null || value === undefined) {
return <span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>—</span>
return <span className='text-[12px] text-[var(--text-muted)]'>—</span>
}
if (typeof value === 'object') {
return (
<div style={{ minWidth: '220px' }}>
<div className='min-w-[220px]'>
<Code.Viewer
code={JSON.stringify(value, null, 2)}
language='json'
@@ -143,7 +136,6 @@ export default function ResumeExecutionPage({
}: ResumeExecutionPageProps) {
const { workflowId, executionId } = params
const router = useRouter()
const brandConfig = useBrandConfig()
const queryClient = useQueryClient()
const {
@@ -333,63 +325,64 @@ export default function ResumeExecutionPage({
case 'boolean': {
const selectValue = value === 'true' || value === 'false' ? value : '__unset__'
return (
<Select
<ChipSelect
value={selectValue}
onValueChange={(val) => handleFormFieldChange(field.name, val)}
>
<SelectTrigger>
<SelectValue placeholder={field.required ? 'Select true or false' : 'Select...'} />
</SelectTrigger>
<SelectContent>
{!field.required && <SelectItem value='__unset__'>Not set</SelectItem>}
<SelectItem value='true'>True</SelectItem>
<SelectItem value='false'>False</SelectItem>
</SelectContent>
</Select>
onChange={(val) => handleFormFieldChange(field.name, val)}
placeholder={field.required ? 'Select true or false' : 'Select...'}
options={[
...(field.required ? [] : [{ label: 'Not set', value: '__unset__' }]),
{ label: 'True', value: 'true' },
{ label: 'False', value: 'false' },
]}
/>
)
}
case 'number':
return (
<Input
<ChipInput
type='number'
value={value}
onChange={(e) => handleFormFieldChange(field.name, e.target.value)}
placeholder={field.placeholder ?? 'Enter a number...'}
error={Boolean(formErrors[field.name])}
/>
)
case 'array':
case 'object':
case 'files':
return (
<Textarea
<ChipTextarea
value={value}
onChange={(e) => handleFormFieldChange(field.name, e.target.value)}
placeholder={field.placeholder ?? (field.type === 'array' ? '[...]' : '{...}')}
rows={5}
error={Boolean(formErrors[field.name])}
/>
)
default: {
if (field.rows !== undefined && field.rows > 3) {
return (
<Textarea
<ChipTextarea
value={value}
onChange={(e) => handleFormFieldChange(field.name, e.target.value)}
placeholder={field.placeholder ?? 'Enter value...'}
rows={5}
error={Boolean(formErrors[field.name])}
/>
)
}
return (
<Input
<ChipInput
value={value}
onChange={(e) => handleFormFieldChange(field.name, e.target.value)}
placeholder={field.placeholder ?? 'Enter value...'}
error={Boolean(formErrors[field.name])}
/>
)
}
}
},
[formValues, handleFormFieldChange]
[formValues, formErrors, handleFormFieldChange]
)
const renderDisabledFieldInput = useCallback(
@@ -404,19 +397,19 @@ export default function ResumeExecutionPage({
switch (field.type) {
case 'boolean': {
const displayValue = rawValue === true ? 'True' : rawValue === false ? 'False' : 'Not set'
return <Input value={displayValue} disabled />
return <ChipInput value={displayValue} disabled />
}
case 'number':
return <Input type='number' value={value} disabled />
return <ChipInput type='number' value={value} disabled />
case 'array':
case 'object':
case 'files':
return <Textarea value={value} disabled rows={5} />
return <ChipTextarea value={value} viewOnly rows={5} />
default: {
if (field.rows !== undefined && field.rows > 3) {
return <Textarea value={value} disabled rows={5} />
return <ChipTextarea value={value} viewOnly rows={5} />
}
return <Input value={value} disabled />
return <ChipInput value={value} disabled />
}
}
},
@@ -676,37 +669,17 @@ export default function ResumeExecutionPage({
if (!executionDetail) {
return (
<Tooltip.Provider>
<div className='font-season' style={{ minHeight: '100vh', background: 'var(--bg)' }}>
<header>
<Navbar />
</header>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 'calc(100vh - 80px)',
padding: '24px',
}}
>
<div style={{ textAlign: 'center', maxWidth: '400px' }}>
<h1
style={{
fontSize: '20px',
fontWeight: 500,
color: 'var(--text-primary)',
marginBottom: '8px',
}}
>
Execution Not Found
</h1>
<p style={{ fontSize: '14px', color: 'var(--text-secondary)', marginBottom: '24px' }}>
This execution could not be located or has already completed.
</p>
<Button variant='outline' onClick={() => router.push('/')}>
Return Home
</Button>
</div>
<div className='flex flex-1 items-center justify-center p-6'>
<div className='max-w-[400px] text-center'>
<h1 className='mb-2 font-medium text-[20px] text-[var(--text-primary)]'>
Execution Not Found
</h1>
<p className='mb-6 text-[14px] text-[var(--text-secondary)]'>
This execution could not be located or has already completed.
</p>
<Button variant='outline' onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
</Tooltip.Provider>
@@ -715,438 +688,263 @@ export default function ResumeExecutionPage({
return (
<Tooltip.Provider>
<div className='font-season' style={{ minHeight: '100vh', background: 'var(--bg)' }}>
<header>
<Navbar />
</header>
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '32px 24px' }}>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '32px',
}}
>
<div>
<h1 style={{ fontSize: '20px', fontWeight: 500, color: 'var(--text-primary)' }}>
Paused Execution
</h1>
<p style={{ fontSize: '14px', color: 'var(--text-secondary)', marginTop: '4px' }}>
Select a pause point to review and resume
</p>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='outline'
size='sm'
onClick={handleRefreshExecution}
disabled={refreshingExecution}
className='gap-1.5 px-2.5'
aria-label='Refresh execution details'
>
<RefreshCw
style={{
width: '14px',
height: '14px',
animation: refreshingExecution ? 'spin 1s linear infinite' : undefined,
}}
/>
Refresh
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Refresh</Tooltip.Content>
</Tooltip.Root>
<div className='mx-auto w-full max-w-[1200px] px-6 py-8'>
{/* Header */}
<div className='mb-8 flex items-center justify-between'>
<div>
<h1 className='font-medium text-[20px] text-[var(--text-primary)]'>Paused Execution</h1>
<p className='mt-1 text-[14px] text-[var(--text-secondary)]'>
Select a pause point to review and resume
</p>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='outline'
size='sm'
onClick={handleRefreshExecution}
disabled={refreshingExecution}
className='gap-1.5 px-2.5'
aria-label='Refresh execution details'
>
<RefreshCw className={cn('size-[14px]', refreshingExecution && 'animate-spin')} />
Refresh
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Refresh</Tooltip.Content>
</Tooltip.Root>
</div>
{/* Main Layout */}
<div style={{ display: 'grid', gridTemplateColumns: '280px 1fr', gap: '24px' }}>
{/* Pause Points List */}
<div
style={{
background: 'var(--surface-1)',
border: '1px solid var(--border)',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
<Label>Pause Points</Label>
</div>
<div>
{pausePoints.length === 0 ? (
<div
style={{
padding: '32px 16px',
textAlign: 'center',
color: 'var(--text-secondary)',
fontSize: '13px',
}}
>
No pause points
</div>
) : (
pausePoints.map((pause) => (
<Button
key={pause.contextId}
variant={pause.contextId === selectedContextId ? 'active' : 'ghost'}
onClick={() => {
setSelectedContextId(pause.contextId)
setError(null)
setMessage(null)
}}
style={{
width: '100%',
justifyContent: 'space-between',
borderRadius: 0,
padding: '12px 16px',
}}
>
<span style={{ fontSize: '13px' }}>{getBlockName(pause)}</span>
<StatusBadge status={pause.resumeStatus} />
</Button>
))
)}
</div>
{/* Main Layout */}
<div className='grid grid-cols-[280px_1fr] gap-6'>
{/* Pause Points List */}
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
<div className='border-[var(--border)] border-b px-4 py-3'>
<Label>Pause Points</Label>
</div>
{/* Detail Panel */}
<div>
{loadingDetail && !selectedDetail ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '200px',
background: 'var(--surface-1)',
border: '1px solid var(--border)',
borderRadius: '8px',
}}
>
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>Loading…</span>
</div>
) : !selectedContextId ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '200px',
background: 'var(--surface-1)',
border: '1px solid var(--border)',
borderRadius: '8px',
}}
>
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>
Select a pause point
</span>
</div>
) : !selectedDetail ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '200px',
background: 'var(--surface-1)',
border: '1px solid var(--border)',
borderRadius: '8px',
}}
>
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>
Could not load details
</span>
{pausePoints.length === 0 ? (
<div className='px-4 py-8 text-center text-[13px] text-[var(--text-secondary)]'>
No pause points
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Status Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--surface-1)',
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '12px 16px',
pausePoints.map((pause) => (
<Button
key={pause.contextId}
variant={pause.contextId === selectedContextId ? 'active' : 'ghost'}
onClick={() => {
setSelectedContextId(pause.contextId)
setError(null)
setMessage(null)
}}
className='w-full justify-between rounded-none px-4 py-3'
>
<div>
<Label>{getBlockName(selectedDetail.pausePoint)}</Label>
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>
Paused at {formatDate(selectedDetail.pausePoint.registeredAt)}
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<StatusBadge status={selectedStatus} />
{queuePosition && queuePosition > 0 && (
<Badge variant='gray' size='sm'>
Queue #{queuePosition}
</Badge>
)}
</div>
</div>
{/* Already resolved - show form fields with submitted values */}
{selectedStatus === 'resumed' || selectedStatus === 'failed' ? (
<div
style={{
background: 'var(--surface-1)',
border: '1px solid var(--border)',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<div
style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}
>
<Label>Resume Form</Label>
</div>
<div
style={{
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
{selectedStatus === 'failed' &&
selectedDetail.pausePoint.latestResumeEntry?.failureReason && (
<Badge variant='red' size='sm'>
{selectedDetail.pausePoint.latestResumeEntry.failureReason}
</Badge>
)}
{inputFormatFields.length > 0 &&
selectedDetail.pausePoint.latestResumeEntry?.resumeInput ? (
inputFormatFields.map((field) => (
<div
key={field.id}
style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
>
<Label>{field.label}</Label>
{field.description && (
<p style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
{field.description}
</p>
)}
{renderDisabledFieldInput(
field,
selectedDetail.pausePoint.latestResumeEntry?.resumeInput
?.submission ??
selectedDetail.pausePoint.latestResumeEntry?.resumeInput ??
{}
)}
</div>
))
) : selectedDetail.pausePoint.latestResumeEntry?.resumeInput ? (
<Textarea
value={JSON.stringify(
selectedDetail.pausePoint.latestResumeEntry.resumeInput,
null,
2
)}
disabled
rows={6}
/>
) : (
<p style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
No input data provided
</p>
)}
</div>
</div>
) : (
<>
{/* Display Data */}
{responseStructureRows.length > 0 ? (
<div
style={{
background: 'var(--surface-1)',
border: '1px solid var(--border)',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<div
style={{
padding: '12px 16px',
borderBottom: '1px solid var(--border)',
}}
>
<Label>Display Data</Label>
</div>
<div style={{ padding: '16px' }}>
<Table>
<TableHeader>
<TableRow>
<TableHead>Field</TableHead>
<TableHead>Type</TableHead>
<TableHead>Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{responseStructureRows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.name}</TableCell>
<TableCell>{row.type}</TableCell>
<TableCell>{renderStructuredValuePreview(row.value)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : (
<div
style={{
background: 'var(--surface-1)',
border: '1px solid var(--border)',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<div
style={{
padding: '12px 16px',
borderBottom: '1px solid var(--border)',
}}
>
<Label>Display Data</Label>
</div>
<div style={{ padding: '16px' }}>
<p style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
No display data configured
</p>
</div>
</div>
)}
{/* Resume Form */}
{isHumanMode && hasInputFormat ? (
<div
style={{
background: 'var(--surface-1)',
border: '1px solid var(--border)',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<div
style={{
padding: '12px 16px',
borderBottom: '1px solid var(--border)',
}}
>
<Label>Resume Form</Label>
</div>
<div
style={{
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
{inputFormatFields.map((field) => (
<div
key={field.id}
style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
>
<Label>
{field.label}
{field.required && (
<span style={{ color: 'var(--text-error)', marginLeft: '4px' }}>
*
</span>
)}
</Label>
{field.description && (
<p style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
{field.description}
</p>
)}
{renderFieldInput(field)}
{formErrors[field.name] && (
<Badge variant='red' size='sm'>
{formErrors[field.name]}
</Badge>
)}
</div>
))}
</div>
</div>
) : (
<div
style={{
background: 'var(--surface-1)',
border: '1px solid var(--border)',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<div
style={{
padding: '12px 16px',
borderBottom: '1px solid var(--border)',
}}
>
<Label>Resume Input (JSON)</Label>
</div>
<div style={{ padding: '16px' }}>
<Textarea
value={resumeInput}
onChange={(e) => {
setResumeInput(e.target.value)
if (selectedContextId) {
resumeInputsRef.current = {
...resumeInputsRef.current,
[selectedContextId]: e.target.value,
}
}
}}
placeholder='{"example": "value"}'
rows={6}
spellCheck={false}
className='min-h-[180px] border-[var(--border-1)] bg-[var(--surface-3)] font-mono text-[12px] leading-5'
/>
</div>
</div>
)}
{/* Messages */}
{error && <Badge variant='red'>{error}</Badge>}
{message && <Badge variant='green'>{message}</Badge>}
{/* Action */}
<Button variant='primary' onClick={handleResume} disabled={resumeDisabled}>
{loadingAction ? 'Resuming...' : 'Resume Execution'}
</Button>
</>
)}
</div>
<span className='text-[13px]'>{getBlockName(pause)}</span>
<StatusBadge status={pause.resumeStatus} />
</Button>
))
)}
</div>
</div>
</div>
{/* Footer */}
<div
style={{
maxWidth: '1200px',
margin: '24px auto 0',
padding: '0 24px 24px',
textAlign: 'center',
fontSize: '13px',
color: 'var(--text-muted)',
}}
>
Need help?{' '}
<a href={`mailto:${brandConfig.supportEmail}`} style={{ color: 'var(--text-secondary)' }}>
Contact support
</a>
{/* Detail Panel */}
<div>
{loadingDetail && !selectedDetail ? (
<div className='flex h-[200px] items-center justify-center rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
<span className='text-[13px] text-[var(--text-secondary)]'>Loading…</span>
</div>
) : !selectedContextId ? (
<div className='flex h-[200px] items-center justify-center rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
<span className='text-[13px] text-[var(--text-secondary)]'>
Select a pause point
</span>
</div>
) : !selectedDetail ? (
<div className='flex h-[200px] items-center justify-center rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
<span className='text-[13px] text-[var(--text-secondary)]'>
Could not load details
</span>
</div>
) : (
<div className='flex flex-col gap-4'>
{/* Status Header */}
<div className='flex items-center justify-between rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] px-4 py-3'>
<div>
<Label>{getBlockName(selectedDetail.pausePoint)}</Label>
<p className='mt-[2px] text-[12px] text-[var(--text-muted)]'>
Paused at {formatDate(selectedDetail.pausePoint.registeredAt)}
</p>
</div>
<div className='flex items-center gap-2'>
<StatusBadge status={selectedStatus} />
{queuePosition && queuePosition > 0 && (
<Badge variant='gray' size='sm'>
Queue #{queuePosition}
</Badge>
)}
</div>
</div>
{/* Already resolved - show form fields with submitted values */}
{selectedStatus === 'resumed' || selectedStatus === 'failed' ? (
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
<div className='border-[var(--border)] border-b px-4 py-3'>
<Label>Resume Form</Label>
</div>
<div className='flex flex-col gap-4 p-4'>
{selectedStatus === 'failed' &&
selectedDetail.pausePoint.latestResumeEntry?.failureReason && (
<Badge variant='red' size='sm'>
{selectedDetail.pausePoint.latestResumeEntry.failureReason}
</Badge>
)}
{inputFormatFields.length > 0 &&
selectedDetail.pausePoint.latestResumeEntry?.resumeInput ? (
inputFormatFields.map((field) => (
<div key={field.id} className='flex flex-col gap-[9px]'>
<Label>{field.label}</Label>
{field.description && (
<p className='text-[12px] text-[var(--text-muted)]'>
{field.description}
</p>
)}
{renderDisabledFieldInput(
field,
selectedDetail.pausePoint.latestResumeEntry?.resumeInput
?.submission ??
selectedDetail.pausePoint.latestResumeEntry?.resumeInput ??
{}
)}
</div>
))
) : selectedDetail.pausePoint.latestResumeEntry?.resumeInput ? (
<ChipTextarea
value={JSON.stringify(
selectedDetail.pausePoint.latestResumeEntry.resumeInput,
null,
2
)}
viewOnly
rows={6}
/>
) : (
<p className='text-[13px] text-[var(--text-muted)]'>
No input data provided
</p>
)}
</div>
</div>
) : (
<>
{/* Display Data */}
{responseStructureRows.length > 0 ? (
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
<div className='border-[var(--border)] border-b px-4 py-3'>
<Label>Display Data</Label>
</div>
<div className='p-4'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Field</TableHead>
<TableHead>Type</TableHead>
<TableHead>Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{responseStructureRows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.name}</TableCell>
<TableCell>{row.type}</TableCell>
<TableCell>{renderStructuredValuePreview(row.value)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : (
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
<div className='border-[var(--border)] border-b px-4 py-3'>
<Label>Display Data</Label>
</div>
<div className='p-4'>
<p className='text-[13px] text-[var(--text-muted)]'>
No display data configured
</p>
</div>
</div>
)}
{/* Resume Form */}
{isHumanMode && hasInputFormat ? (
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
<div className='border-[var(--border)] border-b px-4 py-3'>
<Label>Resume Form</Label>
</div>
<div className='flex flex-col gap-4 p-4'>
{inputFormatFields.map((field) => (
<div key={field.id} className='flex flex-col gap-[9px]'>
<Label>
{field.label}
{field.required && (
<span className='ml-1 text-[var(--text-error)]'>*</span>
)}
</Label>
{field.description && (
<p className='text-[12px] text-[var(--text-muted)]'>
{field.description}
</p>
)}
{renderFieldInput(field)}
{formErrors[field.name] && (
<Badge variant='red' size='sm'>
{formErrors[field.name]}
</Badge>
)}
</div>
))}
</div>
</div>
) : (
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
<div className='border-[var(--border)] border-b px-4 py-3'>
<Label>Resume Input (JSON)</Label>
</div>
<div className='p-4'>
<ChipTextarea
value={resumeInput}
onChange={(e) => {
setResumeInput(e.target.value)
if (selectedContextId) {
resumeInputsRef.current = {
...resumeInputsRef.current,
[selectedContextId]: e.target.value,
}
}
}}
placeholder='{"example": "value"}'
rows={6}
spellCheck={false}
className='min-h-[180px] font-mono'
/>
</div>
</div>
)}
{/* Messages */}
{error && <Badge variant='red'>{error}</Badge>}
{message && <Badge variant='green'>{message}</Badge>}
{/* Action */}
<Button variant='primary' onClick={handleResume} disabled={resumeDisabled}>
{loadingAction ? 'Resuming...' : 'Resume Execution'}
</Button>
</>
)}
</div>
)}
</div>
</div>
</div>
</Tooltip.Provider>
@@ -22,7 +22,9 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/chat') ||
pathname.startsWith('/blog') ||
pathname.startsWith('/resume') ||
pathname.startsWith('/oauth')
pathname.startsWith('/oauth') ||
pathname.startsWith('/f/') ||
pathname.startsWith('/unsubscribe')
const isDarkModePage = pathname.startsWith('/academy')
@@ -1,7 +1,6 @@
import type { ReactNode } from 'react'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import { Navbar } from '@/app/(landing)/components/navbar/navbar'
import { LogoShell } from '@/app/(landing)/components'
interface PublicFileAuthShellProps {
title: string
@@ -10,34 +9,24 @@ interface PublicFileAuthShellProps {
}
/**
* Landing-chrome shell shared by the public file-share auth gates (password,
* Light, logo-only shell shared by the public file-share auth gates (password,
* email OTP, SSO), matching the deployed-chat auth screens. Renders no file
* metadata — the name/provenance are withheld until the visitor authenticates.
*/
export function PublicFileAuthShell({ title, subtitle, children }: PublicFileAuthShellProps) {
return (
<AuthBackground className='dark font-[430] font-season'>
<main className='relative flex min-h-full flex-col text-[var(--landing-text)]'>
<header className='shrink-0 bg-[var(--landing-bg)]'>
<Navbar logoOnly />
</header>
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-lg px-4'>
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className='text-balance font-[430] font-season text-[40px] text-[var(--landing-text)] leading-[110%] tracking-[-0.02em]'>
{title}
</h1>
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
{subtitle}
</p>
</div>
<div className='mt-8 w-full max-w-[410px]'>{children}</div>
</div>
</div>
<LogoShell center footer={<SupportFooter position='absolute' />}>
<div className='flex w-full max-w-lg flex-col items-center justify-center px-4'>
<div className='space-y-1 text-center'>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
{title}
</h1>
<p className='text-[color-mix(in_srgb,var(--text-muted)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
{subtitle}
</p>
</div>
<SupportFooter position='absolute' />
</main>
</AuthBackground>
<div className='mt-8 w-full max-w-[410px]'>{children}</div>
</div>
</LogoShell>
)
}
+9 -16
View File
@@ -1,11 +1,11 @@
'use client'
import { useState } from 'react'
import { cn, Input, Label, Loader } from '@sim/emcn'
import { cn, Input, Label } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { Eye, EyeOff } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { AuthSubmitButton } from '@/app/(auth)/components'
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
import { usePublicFileAuth } from '@/hooks/queries/public-shares'
@@ -72,7 +72,7 @@ export function PublicFileAuth({ token }: PublicFileAuthProps) {
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--landing-text-muted)] hover:text-[var(--landing-text)]'
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@@ -81,20 +81,13 @@ export function PublicFileAuth({ token }: PublicFileAuthProps) {
{error ? <p className='text-[var(--text-error)] text-xs'>{error}</p> : null}
</div>
<button
type='submit'
disabled={!password.trim() || authenticate.isPending}
className={AUTH_SUBMIT_BTN}
<AuthSubmitButton
disabled={!password.trim()}
loading={authenticate.isPending}
loadingLabel='Authenticating…'
>
{authenticate.isPending ? (
<span className='flex items-center gap-2'>
<Loader className='size-4' animate />
Authenticating…
</span>
) : (
'Continue'
)}
</button>
Continue
</AuthSubmitButton>
</form>
</PublicFileAuthShell>
)
@@ -1,11 +1,12 @@
'use client'
import { useEffect, useState } from 'react'
import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Loader } from '@sim/emcn'
import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { AUTH_SUBMIT_BTN, AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes'
import { AuthSubmitButton } from '@/app/(auth)/components'
import { AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes'
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
import { usePublicFileOtpRequest, usePublicFileOtpVerify } from '@/hooks/queries/public-shares'
@@ -107,20 +108,13 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
{error ? <p className='text-[var(--text-error)] text-xs'>{error}</p> : null}
</div>
<button
type='submit'
disabled={!email.trim() || requestOtp.isPending}
className={AUTH_SUBMIT_BTN}
<AuthSubmitButton
disabled={!email.trim()}
loading={requestOtp.isPending}
loadingLabel='Sending Code…'
>
{requestOtp.isPending ? (
<span className='flex items-center gap-2'>
<Loader className='size-4' animate />
Sending Code…
</span>
) : (
'Continue'
)}
</button>
Continue
</AuthSubmitButton>
</form>
</PublicFileAuthShell>
)
@@ -132,7 +126,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
subtitle={`A verification code has been sent to ${email}`}
>
<div className='space-y-6'>
<p className='text-center text-[var(--landing-text-muted)] text-sm'>
<p className='text-center text-[var(--text-muted)] text-sm'>
Enter the 6-digit code to verify your access. If you don't see it in your inbox, check
your spam folder.
</p>
@@ -163,28 +157,23 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
{error ? <p className='text-center text-[var(--text-error)] text-xs'>{error}</p> : null}
<button
<AuthSubmitButton
type='button'
onClick={() => verifyCode(otp)}
disabled={otp.length !== 6 || verifyOtp.isPending}
className={AUTH_SUBMIT_BTN}
disabled={otp.length !== 6}
loading={verifyOtp.isPending}
loadingLabel='Verifying…'
>
{verifyOtp.isPending ? (
<span className='flex items-center gap-2'>
<Loader className='size-4' animate />
Verifying…
</span>
) : (
'Verify Email'
)}
</button>
Verify Email
</AuthSubmitButton>
<div className='text-center'>
<p className='text-[var(--landing-text-muted)] text-sm'>
<p className='text-[var(--text-muted)] text-sm'>
Didn't receive a code?{' '}
{countdown > 0 ? (
<span>
Resend in{' '}
<span className='font-medium text-[var(--landing-text)]'>{countdown}s</span>
<span className='font-medium text-[var(--text-primary)]'>{countdown}s</span>
</span>
) : (
<button
@@ -1,13 +1,13 @@
'use client'
import { useState } from 'react'
import { cn, Input, Label, Loader } from '@sim/emcn'
import { cn, Input, Label } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { publicFileSSOContract } from '@/lib/api/contracts/public-shares'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { AuthSubmitButton } from '@/app/(auth)/components'
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
interface PublicFileSSOAuthProps {
@@ -86,16 +86,13 @@ export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) {
{error ? <p className='text-[var(--text-error)] text-xs'>{error}</p> : null}
</div>
<button type='submit' disabled={!email.trim() || isLoading} className={AUTH_SUBMIT_BTN}>
{isLoading ? (
<span className='flex items-center gap-2'>
<Loader className='size-4' animate />
Redirecting to SSO…
</span>
) : (
'Continue with SSO'
)}
</button>
<AuthSubmitButton
disabled={!email.trim()}
loading={isLoading}
loadingLabel='Redirecting to SSO…'
>
Continue with SSO
</AuthSubmitButton>
</form>
</PublicFileAuthShell>
)
+4 -19
View File
@@ -3,9 +3,9 @@
import { useMemo } from 'react'
import { Chip } from '@sim/emcn'
import { Download } from '@sim/emcn/icons'
import Image from 'next/image'
import Link from 'next/link'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { SimWordmark } from '@/app/(landing)/components/navbar/components'
import { buildProvenance } from '@/app/f/[token]/utils'
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { useBrandConfig } from '@/ee/whitelabeling'
@@ -65,7 +65,7 @@ export function PublicFileView({
)
return (
<div className='flex min-h-screen flex-col bg-[var(--bg)]'>
<div className='light flex min-h-screen flex-col bg-[var(--bg)]'>
<header className='sticky top-0 z-10 flex items-center justify-between gap-4 border-[var(--border)] border-b bg-[var(--bg)] px-4 py-3'>
<div className='flex min-w-0 items-center gap-3'>
{!brand.logoUrl && (
@@ -75,24 +75,9 @@ export function PublicFileView({
target='_blank'
rel='noopener noreferrer'
aria-label='Sim home'
className='shrink-0'
className='flex shrink-0 items-center'
>
<Image
src='/logo/wordmark-dark.svg'
alt='Sim'
width={71}
height={22}
className='h-[22px] w-auto dark:hidden'
priority
/>
<Image
src='/logo/sim-landing.svg'
alt='Sim'
width={71}
height={22}
className='hidden h-[22px] w-auto dark:block'
priority
/>
<SimWordmark />
</Link>
<div className='h-5 w-px shrink-0 bg-[var(--border)]' />
</>
+42 -30
View File
@@ -1,7 +1,8 @@
'use client'
import { cn, Loader } from '@sim/emcn'
import { Chip, cn, Loader } from '@sim/emcn'
import { useRouter } from 'next/navigation'
import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes'
import { AuthSubmitButton } from '@/app/(auth)/components'
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
interface InviteStatusCardProps {
type: 'login' | 'loading' | 'error' | 'success' | 'invitation' | 'warning'
@@ -33,13 +34,13 @@ export function InviteStatusCard({
return (
<>
<div className='space-y-1 text-center'>
<h1 className='font-[500] text-[32px] text-[var(--landing-text)] tracking-tight'>
<h1 className='font-[500] text-[32px] text-[var(--text-primary)] tracking-tight'>
Loading
</h1>
<p className='font-[380] text-[var(--landing-text-muted)] text-md'>{description}</p>
<p className='font-[380] text-[var(--text-muted)]'>{description}</p>
</div>
<div className='mt-8 flex w-full items-center justify-center py-8'>
<Loader className='size-8 text-[var(--landing-text-muted)]' animate />
<Loader className='size-8 text-[var(--text-muted)]' animate />
</div>
</>
)
@@ -48,40 +49,51 @@ export function InviteStatusCard({
return (
<>
<div className='space-y-1 text-center'>
<h1 className='font-[500] text-[32px] text-[var(--landing-text)] tracking-tight'>
<h1 className='font-[500] text-[32px] text-[var(--text-primary)] tracking-tight'>
{title}
</h1>
<p className='font-[380] text-[var(--landing-text-muted)] text-md'>{description}</p>
<p className='font-[380] text-[var(--text-muted)]'>{description}</p>
</div>
<div className='mt-8 w-full max-w-[410px] space-y-3'>
{isExpiredError && (
<button onClick={() => router.push('/')} className={`${AUTH_PRIMARY_CTA_BASE} w-full`}>
<AuthSubmitButton type='button' onClick={() => router.push('/')} loadingLabel=''>
Request New Invitation
</button>
</AuthSubmitButton>
)}
{actions.map((action, index) => (
<button
key={action.label}
onClick={action.onClick}
disabled={action.disabled || action.loading}
className={cn(
`${AUTH_PRIMARY_CTA_BASE} w-full`,
index !== 0 &&
'border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
)}
>
{action.loading ? (
<span className='flex items-center gap-2'>
<Loader className='size-4' animate />
{action.label}...
</span>
) : (
action.label
)}
</button>
))}
{actions.map((action, index) =>
index === 0 ? (
<AuthSubmitButton
key={action.label}
type='button'
onClick={action.onClick}
disabled={action.disabled}
loading={action.loading}
loadingLabel={`${action.label}...`}
>
{action.label}
</AuthSubmitButton>
) : (
<Chip
key={action.label}
fullWidth
flush
onClick={action.onClick}
disabled={action.disabled || action.loading}
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
>
{action.loading ? (
<span className='flex items-center gap-2'>
<Loader className='size-4' animate />
{action.label}...
</span>
) : (
action.label
)}
</Chip>
)
)}
</div>
</>
)
+50 -48
View File
@@ -1,11 +1,12 @@
'use client'
import { Suspense } from 'react'
import { Loader } from '@sim/emcn'
import { Chip, cn, Loader } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useSearchParams } from 'next/navigation'
import type { UnsubscribeType } from '@/lib/api/contracts/user'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { AuthSubmitButton } from '@/app/(auth)/components'
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
import { InviteLayout } from '@/app/invite/components'
import { useUnsubscribe, useUnsubscribeMutation } from '@/hooks/queries/unsubscribe'
@@ -39,15 +40,15 @@ function UnsubscribeContent() {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={'font-medium text-[32px] text-[var(--landing-text)] tracking-tight'}>
<h1 className={'font-medium text-[32px] text-[var(--text-primary)] tracking-tight'}>
Loading
</h1>
<p className={'font-[380] text-[var(--landing-text-muted)] text-md'}>
<p className={'font-[380] text-[var(--text-muted)] text-md'}>
Validating your unsubscribe link…
</p>
</div>
<div className={'mt-8 flex w-full items-center justify-center py-8'}>
<Loader className='size-8 text-[var(--landing-text-muted)]' animate />
<Loader className='size-8 text-[var(--text-muted)]' animate />
</div>
</InviteLayout>
)
@@ -57,16 +58,16 @@ function UnsubscribeContent() {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={'font-medium text-[32px] text-[var(--landing-text)] tracking-tight'}>
<h1 className={'font-medium text-[32px] text-[var(--text-primary)] tracking-tight'}>
Invalid Unsubscribe Link
</h1>
<p className={'font-[380] text-[var(--landing-text-muted)] text-md'}>{error}</p>
<p className={'font-[380] text-[var(--text-muted)] text-md'}>{error}</p>
</div>
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
<button onClick={() => window.history.back()} className={AUTH_SUBMIT_BTN}>
<AuthSubmitButton type='button' onClick={() => window.history.back()} loadingLabel=''>
Go Back
</button>
</AuthSubmitButton>
</div>
</InviteLayout>
)
@@ -76,19 +77,19 @@ function UnsubscribeContent() {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={'font-medium text-[32px] text-[var(--landing-text)] tracking-tight'}>
<h1 className={'font-medium text-[32px] text-[var(--text-primary)] tracking-tight'}>
Important Account Emails
</h1>
<p className={'font-[380] text-[var(--landing-text-muted)] text-md'}>
<p className={'font-[380] text-[var(--text-muted)] text-md'}>
Transactional emails like password resets, account confirmations, and security alerts
cannot be unsubscribed from as they contain essential information for your account.
</p>
</div>
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
<button onClick={() => window.close()} className={AUTH_SUBMIT_BTN}>
<AuthSubmitButton type='button' onClick={() => window.close()} loadingLabel=''>
Close
</button>
</AuthSubmitButton>
</div>
</InviteLayout>
)
@@ -98,19 +99,19 @@ function UnsubscribeContent() {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={'font-medium text-[32px] text-[var(--landing-text)] tracking-tight'}>
<h1 className={'font-medium text-[32px] text-[var(--text-primary)] tracking-tight'}>
Successfully Unsubscribed
</h1>
<p className={'font-[380] text-[var(--landing-text-muted)] text-md'}>
<p className={'font-[380] text-[var(--text-muted)] text-md'}>
You have been unsubscribed from our emails. You will stop receiving emails within 48
hours.
</p>
</div>
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
<button onClick={() => window.close()} className={AUTH_SUBMIT_BTN}>
<AuthSubmitButton type='button' onClick={() => window.close()} loadingLabel=''>
Close
</button>
</AuthSubmitButton>
</div>
</InviteLayout>
)
@@ -121,84 +122,85 @@ function UnsubscribeContent() {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={'font-medium text-[32px] text-[var(--landing-text)] tracking-tight'}>
<h1 className={'font-medium text-[32px] text-[var(--text-primary)] tracking-tight'}>
Email Preferences
</h1>
<p className={'font-[380] text-[var(--landing-text-muted)] text-md'}>
<p className={'font-[380] text-[var(--text-muted)] text-md'}>
Choose which emails you'd like to stop receiving.
</p>
<p className={'mt-2 font-[380] text-[var(--landing-text-muted)] text-sm'}>{data?.email}</p>
<p className={'mt-2 font-[380] text-[var(--text-muted)] text-sm'}>{data?.email}</p>
</div>
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
<button
<AuthSubmitButton
type='button'
onClick={() => handleUnsubscribe('all')}
disabled={processing || isAlreadyUnsubscribedFromAll}
className={AUTH_SUBMIT_BTN}
disabled={isAlreadyUnsubscribedFromAll}
loading={processing}
loadingLabel='Unsubscribing…'
>
{processing ? (
<span className='flex items-center gap-2'>
<Loader className='size-4' animate />
Unsubscribing…
</span>
) : isAlreadyUnsubscribedFromAll ? (
'Unsubscribed from All Emails'
) : (
'Unsubscribe from All Marketing Emails'
)}
</button>
{isAlreadyUnsubscribedFromAll
? 'Unsubscribed from All Emails'
: 'Unsubscribe from All Marketing Emails'}
</AuthSubmitButton>
<div className='py-2 text-center'>
<span className={'font-[380] text-[var(--landing-text-muted)] text-sm'}>
<span className={'font-[380] text-[var(--text-muted)] text-sm'}>
or choose specific types
</span>
</div>
<button
<Chip
fullWidth
flush
onClick={() => handleUnsubscribe('marketing')}
disabled={
processing ||
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeMarketing
}
className={AUTH_SUBMIT_BTN}
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
>
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
: 'Unsubscribe from Marketing Emails'}
</button>
</Chip>
<button
<Chip
fullWidth
flush
onClick={() => handleUnsubscribe('updates')}
disabled={
processing ||
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeUpdates
}
className={AUTH_SUBMIT_BTN}
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
>
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
: 'Unsubscribe from Product Updates'}
</button>
</Chip>
<button
<Chip
fullWidth
flush
onClick={() => handleUnsubscribe('notifications')}
disabled={
processing ||
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeNotifications
}
className={AUTH_SUBMIT_BTN}
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
>
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'
: 'Unsubscribe from Notifications'}
</button>
</Chip>
</div>
<div className={'mt-6 max-w-[410px] text-center'}>
<p className='font-[380] text-[var(--landing-text-muted)] text-small'>
<p className='font-[380] text-[var(--text-muted)] text-small'>
You'll continue receiving important account emails like password resets and security
alerts.
</p>
@@ -213,15 +215,15 @@ export default function Unsubscribe() {
fallback={
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={'font-medium text-[32px] text-[var(--landing-text)] tracking-tight'}>
<h1 className={'font-medium text-[32px] text-[var(--text-primary)] tracking-tight'}>
Loading
</h1>
<p className={'font-[380] text-[var(--landing-text-muted)] text-md'}>
<p className={'font-[380] text-[var(--text-muted)] text-md'}>
Validating your unsubscribe link…
</p>
</div>
<div className={'mt-8 flex w-full items-center justify-center py-8'}>
<Loader className='size-8 text-[var(--landing-text-muted)]' animate />
<Loader className='size-8 text-[var(--text-muted)]' animate />
</div>
</InviteLayout>
}
+5 -12
View File
@@ -1,14 +1,14 @@
'use client'
import { type KeyboardEvent, useState } from 'react'
import { cn, Input, Label, Loader } from '@sim/emcn'
import { cn, Input, Label } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { chatSSOContract } from '@/lib/api/contracts/chats'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { AuthSubmitButton } from '@/app/(auth)/components'
const logger = createLogger('SSOAuth')
@@ -145,16 +145,9 @@ export default function SSOAuth({ identifier }: SSOAuthProps) {
)}
</div>
<button type='submit' disabled={isLoading} className={AUTH_SUBMIT_BTN}>
{isLoading ? (
<span className='flex items-center gap-2'>
<Loader className='size-4' animate />
Redirecting to SSO…
</span>
) : (
'Continue with SSO'
)}
</button>
<AuthSubmitButton loading={isLoading} loadingLabel='Redirecting to SSO…'>
Continue with SSO
</AuthSubmitButton>
</form>
</div>
</div>
+5 -12
View File
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { Button, cn, Input, Label, Loader } from '@sim/emcn'
import { Button, cn, Input, Label } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
@@ -9,7 +9,7 @@ import { client } from '@/lib/auth/auth-client'
import { env, isFalsy } from '@/lib/core/config/env'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { AuthSubmitButton } from '@/app/(auth)/components'
const logger = createLogger('SSOForm')
@@ -179,16 +179,9 @@ export default function SSOForm() {
</div>
</div>
<button type='submit' disabled={isLoading} className={AUTH_SUBMIT_BTN}>
{isLoading ? (
<span className='flex items-center gap-2'>
<Loader className='size-4' animate />
Redirecting to SSO provider…
</span>
) : (
'Continue with SSO'
)}
</button>
<AuthSubmitButton loading={isLoading} loadingLabel='Redirecting to SSO provider…'>
Continue with SSO
</AuthSubmitButton>
</form>
{/* Only show divider and email signin button if email/password is enabled */}