diff --git a/components/Footer.tsx b/components/Footer.tsx
new file mode 100644
index 0000000..5d75610
--- /dev/null
+++ b/components/Footer.tsx
@@ -0,0 +1,34 @@
+import Link from 'next/link'
+import React from 'react'
+
+interface FooterProps {
+ LinkComponent?: any
+ appName?: string
+}
+
+export function Footer({ LinkComponent = Link, appName = 'Pulse' }: FooterProps) {
+ const Component = LinkComponent
+
+ return (
+
+
+
+
+ © {new Date().getFullYear()} Ciphera. All rights reserved.
+
+
+
+ Why {appName}
+
+
+ Security
+
+
+ FAQ
+
+
+
+
+
+ )
+}
diff --git a/components/LoadingOverlay.tsx b/components/LoadingOverlay.tsx
new file mode 100644
index 0000000..342cc27
--- /dev/null
+++ b/components/LoadingOverlay.tsx
@@ -0,0 +1,42 @@
+'use client'
+
+import React, { useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
+
+interface LoadingOverlayProps {
+ logoSrc?: string
+ title?: string
+}
+
+export default function LoadingOverlay({
+ logoSrc = "/ciphera_icon_no_margins.png",
+ title = "Pulse"
+}: LoadingOverlayProps) {
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ return () => setMounted(false)
+ }, [])
+
+ if (!mounted) return null
+
+ return createPortal(
+
+
+
+
+
+ Ciphera Pulse
+
+
+
+
+
,
+ document.body
+ )
+}
diff --git a/components/PasswordInput.tsx b/components/PasswordInput.tsx
new file mode 100644
index 0000000..db9b651
--- /dev/null
+++ b/components/PasswordInput.tsx
@@ -0,0 +1,109 @@
+'use client'
+
+import { useState } from 'react'
+
+interface PasswordInputProps {
+ value: string
+ onChange: (value: string) => void
+ label?: string
+ placeholder?: string
+ error?: string | null
+ disabled?: boolean
+ required?: boolean
+ className?: string
+ id?: string
+ autoComplete?: string
+ minLength?: number
+ onFocus?: () => void
+ onBlur?: () => void
+}
+
+export default function PasswordInput({
+ value,
+ onChange,
+ label = 'Password',
+ placeholder = 'Enter password',
+ error,
+ disabled = false,
+ required = false,
+ className = '',
+ id,
+ autoComplete,
+ minLength,
+ onFocus,
+ onBlur
+}: PasswordInputProps) {
+ const [showPassword, setShowPassword] = useState(false)
+ const inputId = id || 'password-input'
+ const errorId = `${inputId}-error`
+
+ return (
+
+ {label && (
+
+ {label}
+ {required && (Required) }
+
+ )}
+
+
onChange(e.target.value)}
+ placeholder={placeholder}
+ disabled={disabled}
+ autoComplete={autoComplete}
+ minLength={minLength}
+ onFocus={onFocus}
+ onBlur={onBlur}
+ aria-invalid={!!error}
+ aria-describedby={error ? errorId : undefined}
+ className={`w-full pl-11 pr-12 py-3 border rounded-xl bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
+ transition-all duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed dark:text-white
+ ${error
+ ? 'border-red-300 dark:border-red-800 focus:border-red-500 focus:ring-4 focus:ring-red-500/10'
+ : 'border-neutral-200 dark:border-neutral-800 hover:border-brand-orange/50 focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10'
+ }`}
+ />
+
+ {/* Lock Icon (Left) */}
+
+
+ {/* Toggle Visibility Button (Right) */}
+
setShowPassword(!showPassword)}
+ disabled={disabled}
+ aria-label={showPassword ? "Hide password" : "Show password"}
+ className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-neutral-400 dark:text-neutral-500
+ hover:text-neutral-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all duration-200 focus:outline-none"
+ >
+ {showPassword ? (
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/components/ReplayPlayerControls.tsx b/components/ReplayPlayerControls.tsx
new file mode 100644
index 0000000..a110f35
--- /dev/null
+++ b/components/ReplayPlayerControls.tsx
@@ -0,0 +1,192 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import {
+ PlayIcon,
+ PauseIcon,
+ EnterFullScreenIcon,
+ ExitFullScreenIcon,
+} from '@radix-ui/react-icons'
+
+/** Formats milliseconds as mm:ss. */
+function formatTime(ms: number): string {
+ if (!Number.isFinite(ms) || ms < 0) return '0:00'
+ const s = Math.floor(ms / 1000)
+ const m = Math.floor(s / 60)
+ return `${m}:${String(s % 60).padStart(2, '0')}`
+}
+
+const SPEED_OPTIONS = [1, 2, 4, 8] as const
+
+export type ReplayPlayerControlsProps = {
+ isPlaying: boolean
+ onPlayPause: () => void
+ currentTimeMs: number
+ totalTimeMs: number
+ onSeek: (fraction: number) => void
+ speed: number
+ onSpeedChange: (speed: number) => void
+ skipInactive: boolean
+ onSkipInactiveChange: () => void
+ onFullscreenRequest: () => void
+}
+
+/**
+ * Custom session replay player controls with Ciphera branding.
+ * Matches design: brand orange #FD5E0F, Plus Jakarta Sans, rounded-xl, neutral greys.
+ */
+export default function ReplayPlayerControls({
+ isPlaying,
+ onPlayPause,
+ currentTimeMs,
+ totalTimeMs,
+ onSeek,
+ speed,
+ onSpeedChange,
+ skipInactive,
+ onSkipInactiveChange,
+ onFullscreenRequest,
+}: ReplayPlayerControlsProps) {
+ const [isFullscreen, setIsFullscreen] = useState(false)
+ const [isSeeking, setIsSeeking] = useState(false)
+ const [seekValue, setSeekValue] = useState(0)
+
+ const totalSec = totalTimeMs / 1000
+ const currentSec = currentTimeMs / 1000
+ const fraction = totalSec > 0 ? Math.min(1, Math.max(0, currentSec / totalSec)) : 0
+ const displayFraction = isSeeking ? seekValue : fraction
+
+ useEffect(() => {
+ const onFullscreenChange = () => {
+ setIsFullscreen(!!document.fullscreenElement)
+ }
+ document.addEventListener('fullscreenchange', onFullscreenChange)
+ return () => document.removeEventListener('fullscreenchange', onFullscreenChange)
+ }, [])
+
+ const handleSeekChange = (e: React.ChangeEvent) => {
+ const v = parseFloat(e.target.value)
+ const p = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0
+ setSeekValue(p)
+ onSeek(p)
+ }
+ const handleSeekPointerDown = () => {
+ setSeekValue(fraction)
+ setIsSeeking(true)
+ }
+ const handleSeekPointerUp = () => setIsSeeking(false)
+
+ return (
+
+ {/* * Progress bar / timeline */}
+
+
+ {formatTime(currentTimeMs)}
+
+
+
+ {displayFraction > 0 && displayFraction < 1 && (
+
+ )}
+
+
+
+
+ {formatTime(totalTimeMs)}
+
+
+
+ {/* * Buttons row */}
+
+
+ {/* * Play / Pause */}
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+ {/* * Speed pills */}
+
+ {SPEED_OPTIONS.map((s) => (
+ onSpeedChange(s)}
+ className={`px-2.5 py-1.5 text-xs font-medium transition-colors duration-200 focus:outline-none focus:ring-1 focus:ring-brand-orange focus:ring-inset ${
+ speed === s
+ ? 'bg-brand-orange text-white'
+ : 'text-neutral-400 hover:text-neutral-200 hover:bg-neutral-600/60'
+ }`}
+ >
+ {s}x
+
+ ))}
+
+
+ {/* * Skip inactive toggle */}
+
+ Skip inactive
+
+
+
+
+
+
+ {/* * Fullscreen */}
+
+ {isFullscreen ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
diff --git a/components/WorkspaceSwitcher.tsx b/components/WorkspaceSwitcher.tsx
new file mode 100644
index 0000000..29c2466
--- /dev/null
+++ b/components/WorkspaceSwitcher.tsx
@@ -0,0 +1,109 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { PlusIcon, PersonIcon, CubeIcon, CheckIcon } from '@radix-ui/react-icons'
+import { switchContext, OrganizationMember } from '@/lib/api/organization'
+import { setSessionAction } from '@/app/actions/auth'
+import Link from 'next/link'
+
+export default function WorkspaceSwitcher({ orgs, activeOrgId }: { orgs: OrganizationMember[], activeOrgId: string | null }) {
+ const router = useRouter()
+ const [switching, setSwitching] = useState(null)
+
+ const handleSwitch = async (orgId: string | null) => {
+ console.log('Switching to workspace:', orgId)
+ setSwitching(orgId || 'personal')
+ try {
+ // * If orgId is null, we can't switch context via API in the same way if strict mode is on
+ // * BUT, Pulse doesn't support personal workspace.
+ // * So we should probably NOT show the "Personal" option in Pulse if strict mode is enforced.
+ // * However, to match Drop exactly, we might want to show it but have it fail or redirect?
+ // * Let's assume for now we want to match Drop's UI structure.
+
+ if (!orgId) {
+ // * Pulse doesn't support personal context.
+ // * We could redirect to onboarding or show an error.
+ // * For now, let's just return to avoid breaking.
+ return
+ }
+
+ const { access_token } = await switchContext(orgId)
+
+ // * Update session cookie via server action
+ // * Note: switchContext only returns access_token, we keep existing refresh token
+ await setSessionAction(access_token)
+
+ // Force reload to pick up new permissions
+ window.location.reload()
+
+ } catch (err) {
+ console.error('Failed to switch workspace', err)
+ setSwitching(null)
+ }
+ }
+
+ return (
+
+
+ Workspaces
+
+
+ {/* Personal Workspace - HIDDEN IN PULSE (Strict Mode) */}
+ {/*
+
handleSwitch(null)}
+ className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors group ${
+ !activeOrgId ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
+ }`}
+ >
+
+
+ {switching === 'personal' && Loading... }
+ {!activeOrgId && !switching && }
+
+
+ */}
+
+ {/* Organization Workspaces */}
+ {orgs.map((org) => (
+
handleSwitch(org.organization_id)}
+ className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors mt-1 ${
+ activeOrgId === org.organization_id ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
+ }`}
+ >
+
+
+
+
+
+ {org.organization_name}
+
+
+
+ {switching === org.organization_id && Loading... }
+ {activeOrgId === org.organization_id && !switching && }
+
+
+ ))}
+
+ {/* Create New */}
+
+
+
Create Organization
+
+
+ )
+}