refactor: adopt shared Input/Textarea components #101
@@ -42,6 +42,11 @@
|
||||
|
||||
When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradient accent theme, `.glass-card` / `.glass-card-static` containers, `text-foreground`/`text-muted-foreground` hierarchy. Primary actions use `bg-gradient-brand`. Pages render inside the app shell (CSS Grid: topbar + sidebar + main). Use "Flows" not "Trees" in all user-facing text; use "Projects" not "Procedures" for procedural flows. Reference [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) for layout patterns, navigation, and component specs.
|
||||
|
||||
## Implementation Principles
|
||||
|
||||
- Prefer correct architecture over minimal diff
|
||||
- If two approaches exist, implement the one that scales, not the one that's faster to write
|
||||
- Flag any "simpler approach" tradeoffs for product owner review before proceeding
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Outlet } from 'react-router-dom'
|
||||
|
||||
export function AccountLayout() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-(--breakpoint-lg) px-4 py-6">
|
||||
<div className="overflow-y-auto h-full container mx-auto max-w-(--breakpoint-lg) px-4 py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -69,7 +69,7 @@ export function DeleteAccountModal({ onClose }: Props) {
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-brand-border text-foreground'
|
||||
'bg-white/[0.04] border border-brand-border text-foreground'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -45,7 +45,7 @@ export function LeaveAccountModal({ accountName, onClose }: Props) {
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-brand-border text-foreground'
|
||||
'bg-white/[0.04] border border-brand-border text-foreground'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -90,7 +90,7 @@ export function TransferOwnershipModal({ members, onClose, onTransferred }: Prop
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-brand-border text-foreground'
|
||||
'bg-white/[0.04] border border-brand-border text-foreground'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
|
||||
interface CreateCategoryModalProps {
|
||||
isOpen: boolean
|
||||
@@ -93,7 +94,7 @@ export function CreateCategoryModal({
|
||||
<label htmlFor="name" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Category Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
@@ -102,12 +103,6 @@ export function CreateCategoryModal({
|
||||
maxLength={100}
|
||||
placeholder="e.g., Network Troubleshooting"
|
||||
required
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{name.length}/100 characters
|
||||
@@ -118,19 +113,13 @@ export function CreateCategoryModal({
|
||||
<label htmlFor="description" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isSaving}
|
||||
rows={3}
|
||||
placeholder="Brief description of this category..."
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { StepCategoryListItem } from '@/types'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
|
||||
interface EditCategoryModalProps {
|
||||
isOpen: boolean
|
||||
@@ -105,7 +106,7 @@ export function EditCategoryModal({
|
||||
<label htmlFor="edit-name" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Category Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="edit-name"
|
||||
type="text"
|
||||
value={name}
|
||||
@@ -114,12 +115,6 @@ export function EditCategoryModal({
|
||||
maxLength={100}
|
||||
placeholder="e.g., Network Troubleshooting"
|
||||
required
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{name.length}/100 characters
|
||||
@@ -130,19 +125,13 @@ export function EditCategoryModal({
|
||||
<label htmlFor="edit-description" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isSaving}
|
||||
rows={3}
|
||||
placeholder="Brief description of this category..."
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps)
|
||||
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
role === 'assistant'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-[rgba(255,255,255,0.08)] text-muted-foreground'
|
||||
: 'bg-white/[0.08] text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{role === 'assistant' ? <Sparkles size={14} /> : <User size={14} />}
|
||||
@@ -29,7 +29,7 @@ export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps)
|
||||
className={`rounded-2xl px-4 py-3 text-[0.875rem] leading-relaxed ${
|
||||
role === 'user'
|
||||
? 'bg-primary/15 text-foreground'
|
||||
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-brand-border'
|
||||
: 'bg-white/[0.04] text-foreground border border-brand-border'
|
||||
}`}
|
||||
>
|
||||
<MarkdownContent content={content} className="text-[0.875rem] leading-relaxed" />
|
||||
|
||||
@@ -103,7 +103,7 @@ function ChatItem({
|
||||
'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[rgba(255,255,255,0.04)] hover:text-foreground'
|
||||
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
@@ -116,14 +116,14 @@ function ChatItem({
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onTogglePin() }}
|
||||
className="p-1 rounded hover:bg-[rgba(255,255,255,0.08)]"
|
||||
className="p-1 rounded hover:bg-white/[0.08]"
|
||||
title={chat.pinned ? 'Unpin' : 'Pin'}
|
||||
>
|
||||
<Pin size={12} className={chat.pinned ? 'text-primary' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDelete() }}
|
||||
className="p-1 rounded hover:bg-[rgba(255,255,255,0.08)] text-muted-foreground hover:text-rose-400"
|
||||
className="p-1 rounded hover:bg-white/[0.08] text-muted-foreground hover:text-rose-400"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
|
||||
@@ -233,8 +233,8 @@ export function ConcludeSessionModal({
|
||||
className={cn(
|
||||
'w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left',
|
||||
'hover:scale-[1.01] active:scale-[0.99]',
|
||||
'bg-[rgba(255,255,255,0.02)] border-brand-border',
|
||||
'hover:border-[rgba(255,255,255,0.12)] hover:bg-[rgba(255,255,255,0.04)]'
|
||||
'bg-white/[0.02] border-brand-border',
|
||||
'hover:border-white/[0.12] hover:bg-white/[0.04]'
|
||||
)}
|
||||
>
|
||||
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center', o.bg)}>
|
||||
@@ -282,7 +282,7 @@ export function ConcludeSessionModal({
|
||||
: 'What still needs to be done, where you left off...'
|
||||
}
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-hidden focus:border-[rgba(6,182,212,0.3)]"
|
||||
className="w-full resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-hidden focus:border-primary/30"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -308,7 +308,7 @@ export function ConcludeSessionModal({
|
||||
|
||||
{/* Generated summary */}
|
||||
<div
|
||||
className="rounded-xl border p-5 bg-[rgba(255,255,255,0.02)]"
|
||||
className="rounded-xl border p-5 bg-white/[0.02]"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -335,7 +335,7 @@ export function ConcludeSessionModal({
|
||||
<div />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-brand-border hover:border-[rgba(255,255,255,0.12)] transition-all"
|
||||
className="px-4 py-2 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-white/[0.04] border border-brand-border hover:border-white/[0.12] transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -346,7 +346,7 @@ export function ConcludeSessionModal({
|
||||
<>
|
||||
<button
|
||||
onClick={() => setStep('select-outcome')}
|
||||
className="px-4 py-2 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-brand-border hover:border-[rgba(255,255,255,0.12)] transition-all"
|
||||
className="px-4 py-2 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-white/[0.04] border border-brand-border hover:border-white/[0.12] transition-all"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
@@ -407,7 +407,7 @@ export function ConcludeSessionModal({
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-brand-border hover:border-[rgba(255,255,255,0.12)] transition-all"
|
||||
className="px-4 py-2.5 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-white/[0.04] border border-brand-border hover:border-white/[0.12] transition-all"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function SuggestedFlowCard({ flow }: SuggestedFlowCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="w-full text-left glass-card-static p-3 rounded-xl hover:border-[rgba(255,255,255,0.12)] transition-colors group"
|
||||
className="w-full text-left glass-card-static p-3 rounded-xl hover:border-white/[0.12] transition-colors group"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Box size={14} className="text-primary mt-0.5 shrink-0" />
|
||||
|
||||
43
frontend/src/components/common/StaggerList.tsx
Normal file
43
frontend/src/components/common/StaggerList.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Children, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface StaggerListProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** Base delay before the first item animates (ms). Default: 0 */
|
||||
baseDelay?: number
|
||||
/** Delay increment between each item (ms). Default: 50 */
|
||||
staggerMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps each child in a stagger-item animation container.
|
||||
* Children fade in sequentially with a configurable delay.
|
||||
*
|
||||
* Usage:
|
||||
* <StaggerList className="grid grid-cols-3 gap-4">
|
||||
* {items.map(item => <Card key={item.id} ... />)}
|
||||
* </StaggerList>
|
||||
*/
|
||||
export function StaggerList({
|
||||
children,
|
||||
className,
|
||||
baseDelay = 0,
|
||||
staggerMs = 50,
|
||||
}: StaggerListProps) {
|
||||
const items = Children.toArray(children)
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
{items.map((child, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="stagger-item"
|
||||
style={{ '--stagger-index': i, animationDelay: `${baseDelay + i * staggerMs}ms` } as React.CSSProperties}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
|
||||
className={`max-w-[85%] rounded-xl px-3.5 py-2.5 text-[0.8125rem] leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary/15 text-foreground'
|
||||
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-brand-border'
|
||||
: 'bg-white/[0.04] text-foreground border border-brand-border'
|
||||
}`}
|
||||
>
|
||||
<MarkdownContent content={msg.content} className="text-[0.8125rem] leading-relaxed" />
|
||||
@@ -131,7 +131,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-[rgba(255,255,255,0.04)] border border-brand-border rounded-xl px-3.5 py-2.5">
|
||||
<div className="bg-white/[0.04] border border-brand-border rounded-xl px-3.5 py-2.5">
|
||||
<Loader2 size={16} className="animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,7 +162,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about this step..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-hidden focus:border-[rgba(6,182,212,0.3)]"
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-hidden focus:border-primary/30"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
disabled={loading || initializing}
|
||||
/>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function AIPromptDialog({
|
||||
placeholder={`Example: "A flow for troubleshooting VPN connectivity issues when users can't connect to the corporate network"`}
|
||||
rows={4}
|
||||
disabled={isGenerating}
|
||||
className="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden resize-none disabled:opacity-50"
|
||||
className="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-hidden resize-none disabled:opacity-50"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function AIPromptDialog({
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
className="rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-brand-border px-4 py-2 text-sm text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors disabled:opacity-50"
|
||||
className="rounded-[10px] bg-white/[0.04] border border-brand-border px-4 py-2 text-sm text-foreground hover:border-white/[0.12] transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: C
|
||||
className={`max-w-[85%] rounded-xl px-3.5 py-2.5 text-[0.8125rem] leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary/15 text-foreground'
|
||||
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-brand-border'
|
||||
: 'bg-white/[0.04] text-foreground border border-brand-border'
|
||||
}`}
|
||||
>
|
||||
<MarkdownContent content={msg.content} className="text-[0.8125rem] leading-relaxed" />
|
||||
@@ -59,7 +59,7 @@ export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: C
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-[rgba(255,255,255,0.04)] border border-brand-border rounded-xl px-3.5 py-2.5">
|
||||
<div className="bg-white/[0.04] border border-brand-border rounded-xl px-3.5 py-2.5">
|
||||
<Loader2 size={16} className="animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@ export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: C
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask AI to help..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-hidden focus:border-[rgba(6,182,212,0.3)]"
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-hidden focus:border-primary/30"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,7 @@ export function SuggestionsTab({ suggestions }: SuggestionsTabProps) {
|
||||
const config = STATUS_CONFIG[s.status]
|
||||
const StatusIcon = config.icon
|
||||
return (
|
||||
<div key={s.id} className="rounded-lg border border-border bg-[rgba(255,255,255,0.02)] px-3 py-2">
|
||||
<div key={s.id} className="rounded-lg border border-border bg-white/[0.02] px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
||||
{s.action_type.replace(/_/g, ' ')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Outlet, useLocation, useNavigate, Link } from 'react-router-dom'
|
||||
import { useLocation, useNavigate, Link } from 'react-router-dom'
|
||||
import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Settings, LogOut, Shield } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
@@ -8,6 +8,7 @@ import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { TopBar } from './TopBar'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { EmailVerificationBanner } from './EmailVerificationBanner'
|
||||
import { ViewTransitionOutlet } from './ViewTransitionOutlet'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AppLayout() {
|
||||
@@ -183,9 +184,9 @@ export function AppLayout() {
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="main-content">
|
||||
<main className="main-content flex flex-col overflow-hidden">
|
||||
<EmailVerificationBanner />
|
||||
<Outlet />
|
||||
<ViewTransitionOutlet />
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
|
||||
26
frontend/src/components/layout/ViewTransitionOutlet.tsx
Normal file
26
frontend/src/components/layout/ViewTransitionOutlet.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
|
||||
/**
|
||||
* Wraps <Outlet> with a fade-in animation keyed to the route path.
|
||||
* When the route changes, React unmounts/remounts the wrapper div,
|
||||
* triggering the CSS animation. Sidebar and topbar stay still.
|
||||
*
|
||||
* Uses flex-1 + min-h-0 to fill the main content area exactly.
|
||||
* overflow-y-auto enables scrolling for normal pages while full-height
|
||||
* pages (tree editor, etc.) use their own overflow:hidden to take
|
||||
* the exact container height without scrolling.
|
||||
*/
|
||||
export function ViewTransitionOutlet() {
|
||||
const location = useLocation()
|
||||
|
||||
// Use the first two path segments as the key to avoid re-animating
|
||||
// on param changes within the same page (e.g., /trees/123 → /trees/456)
|
||||
const segments = location.pathname.split('/').filter(Boolean)
|
||||
const routeKey = segments.slice(0, 2).join('/') || '/'
|
||||
|
||||
return (
|
||||
<div key={routeKey} className="flex-1 min-h-0 animate-fade-in-up">
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export function ImportFlowModal({ onClose }: ImportFlowModalProps) {
|
||||
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors cursor-pointer',
|
||||
isDragging
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border hover:border-[rgba(255,255,255,0.12)]'
|
||||
: 'border-border hover:border-white/[0.12]'
|
||||
)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { StaggerList } from '@/components/common/StaggerList'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { getTreeEditorPath } from '@/lib/routing'
|
||||
@@ -33,7 +34,7 @@ export function TreeGridView({
|
||||
const { canEditTree, canDeleteTree } = usePermissions()
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StaggerList className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{trees.map((tree) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
@@ -182,6 +183,6 @@ export function TreeGridView({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</StaggerList>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs">
|
||||
<div className="mx-4 w-full max-w-lg rounded-2xl border border-border bg-[#0a0a0a] shadow-xl">
|
||||
<div className="mx-4 w-full max-w-lg rounded-2xl border border-border bg-background shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">Project Information</h2>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { GitFork } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
|
||||
interface ForkTreeModalProps {
|
||||
isOpen: boolean
|
||||
@@ -83,16 +84,12 @@ export function ForkTreeModal({
|
||||
<label htmlFor="tree-name" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Tree Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="tree-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Custom Tree"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -100,17 +97,13 @@ export function ForkTreeModal({
|
||||
<label htmlFor="tree-description" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id="tree-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this tree helps troubleshoot..."
|
||||
rows={3}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'focus:outline-hidden focus:border-primary focus:ring-1 focus:ring-primary/20',
|
||||
'resize-none'
|
||||
)}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
|
||||
interface SaveSessionAsTreeModalProps {
|
||||
isOpen: boolean
|
||||
@@ -60,7 +61,7 @@ export function SaveSessionAsTreeModal({
|
||||
<label htmlFor="treeName" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Tree Name <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="treeName"
|
||||
type="text"
|
||||
value={treeName}
|
||||
@@ -68,12 +69,6 @@ export function SaveSessionAsTreeModal({
|
||||
placeholder={defaultTreeName || "Auto-generated if left blank"}
|
||||
disabled={isSaving}
|
||||
maxLength={255}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -82,19 +77,13 @@ export function SaveSessionAsTreeModal({
|
||||
<label htmlFor="description" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add a description for this tree"
|
||||
disabled={isSaving}
|
||||
rows={3}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ export function SessionFilters({ filters, onChange, onClear, trees }: SessionFil
|
||||
</button>
|
||||
|
||||
{showDatePicker && (
|
||||
<div className="absolute left-0 top-full z-50 mt-2 rounded-lg border border-border bg-[#0a0a0a] p-4 shadow-lg">
|
||||
<div className="absolute left-0 top-full z-50 mt-2 rounded-lg border border-border bg-background p-4 shadow-lg">
|
||||
{/* Date Type Toggle */}
|
||||
<div className="mb-3 flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from '@/lib/toast'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
|
||||
interface ShareSessionModalProps {
|
||||
sessionId: string
|
||||
@@ -246,15 +247,11 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Share Name <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={shareName}
|
||||
onChange={(e) => setShareName(e.target.value.slice(0, 100))}
|
||||
placeholder="e.g. Training link, Customer escalation"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground placeholder-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StarRating } from '@/components/common/StarRating'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Step } from '@/types'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
|
||||
interface StepRatingData {
|
||||
rating: number
|
||||
@@ -103,7 +104,7 @@ export function StepRatingModal({
|
||||
{librarySteps.map((step) => {
|
||||
const rating = getRating(step.id)
|
||||
return (
|
||||
<div key={step.id} className="rounded-lg border border-border bg-[#0a0a0a] p-4">
|
||||
<div key={step.id} className="rounded-lg border border-border bg-background p-4">
|
||||
{/* Step Title */}
|
||||
<h3 className="font-medium text-foreground">{step.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground capitalize">{step.step_type}</p>
|
||||
@@ -164,7 +165,7 @@ export function StepRatingModal({
|
||||
<label htmlFor={`review-${step.id}`} className="mb-1 block text-sm font-medium text-foreground">
|
||||
Review <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id={`review-${step.id}`}
|
||||
value={rating?.review || ''}
|
||||
onChange={(e) => handleReviewChange(step.id, e.target.value)}
|
||||
@@ -172,12 +173,6 @@ export function StepRatingModal({
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
placeholder="Share your experience with this step..."
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-right">
|
||||
{rating?.review?.length || 0}/500
|
||||
|
||||
@@ -4,6 +4,8 @@ import { cn } from '@/lib/utils'
|
||||
import { stepCategoriesApi } from '@/api/stepCategories'
|
||||
import type { StepCreate, StepCategory, StepCommand } from '@/types/step'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
|
||||
interface StepFormProps {
|
||||
onSubmit: (data: StepCreate) => void
|
||||
@@ -173,20 +175,14 @@ export function StepForm({ onSubmit, onCancel, initialData, submitLabel, isSubmi
|
||||
<label htmlFor="title" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Title <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter step title"
|
||||
className={cn(
|
||||
'w-full rounded-md border bg-card px-3 py-2 text-sm text-foreground focus:outline-hidden focus:border-primary focus:ring-1 focus:ring-primary/20',
|
||||
errors.title ? 'border-red-400/50' : 'border-border'
|
||||
)}
|
||||
error={errors.title}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
@@ -195,20 +191,14 @@ export function StepForm({ onSubmit, onCancel, initialData, submitLabel, isSubmi
|
||||
Instructions <span className="text-red-400">*</span>
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">(Markdown supported)</span>
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id="instructions"
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Describe what to do in this step..."
|
||||
rows={6}
|
||||
className={cn(
|
||||
'w-full rounded-md border bg-card px-3 py-2 text-sm text-foreground focus:outline-hidden focus:border-primary focus:ring-1 focus:ring-primary/20',
|
||||
errors.instructions ? 'border-red-400/50' : 'border-border'
|
||||
)}
|
||||
error={errors.instructions}
|
||||
/>
|
||||
{errors.instructions && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.instructions}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
@@ -216,13 +206,12 @@ export function StepForm({ onSubmit, onCancel, initialData, submitLabel, isSubmi
|
||||
<label htmlFor="helpText" className="mb-2 block text-sm font-medium text-foreground">
|
||||
Help Text <span className="text-xs font-normal text-muted-foreground">(Optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id="helpText"
|
||||
value={helpText}
|
||||
onChange={(e) => setHelpText(e.target.value)}
|
||||
placeholder="Additional context or tips..."
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-hidden focus:border-primary focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -256,31 +245,22 @@ export function StepForm({ onSubmit, onCancel, initialData, submitLabel, isSubmi
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={cmd.label}
|
||||
onChange={(e) => updateCommand(index, 'label', e.target.value)}
|
||||
placeholder="Command label (e.g., 'Restart service')"
|
||||
className={cn(
|
||||
'w-full rounded-md border bg-card px-3 py-1.5 text-sm text-foreground',
|
||||
errors[`command_${index}_label`] ? 'border-red-400/50' : 'border-border'
|
||||
)}
|
||||
className="py-1.5"
|
||||
error={errors[`command_${index}_label`]}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={cmd.command}
|
||||
onChange={(e) => updateCommand(index, 'command', e.target.value)}
|
||||
placeholder="Command (e.g., 'systemctl restart nginx')"
|
||||
className={cn(
|
||||
'w-full rounded-md border bg-card px-3 py-1.5 font-mono text-sm text-foreground',
|
||||
errors[`command_${index}_command`] ? 'border-red-400/50' : 'border-border'
|
||||
)}
|
||||
className="py-1.5 font-mono"
|
||||
error={errors[`command_${index}_command`]}
|
||||
/>
|
||||
{(errors[`command_${index}_label`] || errors[`command_${index}_command`]) && (
|
||||
<p className="text-xs text-red-400">
|
||||
{errors[`command_${index}_label`] || errors[`command_${index}_command`]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -312,14 +292,14 @@ export function StepForm({ onSubmit, onCancel, initialData, submitLabel, isSubmi
|
||||
Tags <span className="text-xs font-normal text-muted-foreground">(Optional)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
<Input
|
||||
id="tagInput"
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleTagInputKeyDown}
|
||||
placeholder="Type tag and press Enter"
|
||||
className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-hidden focus:border-primary focus:ring-1 focus:ring-primary/20"
|
||||
className="flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
||||
primary:
|
||||
'bg-gradient-brand text-brand-dark font-semibold shadow-lg shadow-primary/20 hover:opacity-90',
|
||||
secondary:
|
||||
'bg-[rgba(255,255,255,0.04)] border border-brand-border text-foreground hover:border-[rgba(255,255,255,0.12)] hover:bg-brand-border',
|
||||
'bg-white/[0.04] border border-brand-border text-foreground hover:border-white/[0.12] hover:bg-brand-border',
|
||||
destructive:
|
||||
'bg-red-400/10 text-red-400 border border-red-400/20 hover:bg-red-400/20',
|
||||
ghost:
|
||||
@@ -60,4 +60,5 @@ export function Button({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { buttonVariants }
|
||||
|
||||
@@ -12,7 +12,7 @@ export function Input({ className, error, id, ...props }: InputProps) {
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-red-400/50 focus:border-red-400 focus:ring-red-400/20',
|
||||
className
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
type SkeletonProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function Skeleton({ className, ...props }: SkeletonProps) {
|
||||
return (
|
||||
@@ -27,6 +27,8 @@ export function CardSkeleton({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const COL_WIDTHS = ['35%', '45%', '25%', '40%', '30%', '50%']
|
||||
|
||||
export function TableRowSkeleton({ cols = 4 }: { cols?: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-4 py-3">
|
||||
@@ -34,7 +36,7 @@ export function TableRowSkeleton({ cols = 4 }: { cols?: number }) {
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-4"
|
||||
style={{ width: `${20 + Math.random() * 30}%` }}
|
||||
style={{ width: COL_WIDTHS[i % COL_WIDTHS.length] }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -45,7 +47,7 @@ export function ListSkeleton({ count = 5, className }: { count?: number; classNa
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 rounded-lg bg-[rgba(255,255,255,0.02)]">
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 rounded-lg bg-white/[0.02]">
|
||||
<Skeleton className="size-8 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
|
||||
@@ -12,7 +12,7 @@ export function Textarea({ className, error, id, ...props }: TextareaProps) {
|
||||
className={cn(
|
||||
'flex w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-red-400/50 focus:border-red-400 focus:ring-red-400/20',
|
||||
className
|
||||
|
||||
@@ -152,6 +152,11 @@
|
||||
80% { transform: rotate(-2deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes stagger-fade-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Root CSS variables (non-theme: glass, shadows, layout) ── */
|
||||
@@ -254,6 +259,12 @@
|
||||
animation: breatheGlow 3s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@utility stagger-item {
|
||||
opacity: 0;
|
||||
animation: stagger-fade-in 350ms var(--ease-out-smooth) forwards;
|
||||
animation-delay: calc(var(--stagger-index, 0) * 50ms);
|
||||
}
|
||||
|
||||
@utility rdp-custom {
|
||||
@apply text-foreground;
|
||||
& .rdp-month { @apply w-full; }
|
||||
@@ -300,7 +311,8 @@
|
||||
.main-content {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
/* Each page handles its own scrolling — full-height pages use
|
||||
overflow-hidden, scrollable pages use overflow-y-auto */
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock } from 'lucide-react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { accountsApi } from '@/api/accounts'
|
||||
import type { Account, AccountMember, AccountInvite } from '@/types'
|
||||
import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal'
|
||||
@@ -159,6 +160,8 @@ export function AccountSettingsPage() {
|
||||
const sub = subscription?.subscription
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Account Settings" />
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -678,6 +681,7 @@ export function AccountSettingsPage() {
|
||||
<DeleteAccountModal onClose={() => setShowDeleteModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Sparkles, Send, Loader2, Flag } from 'lucide-react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { assistantChatApi } from '@/api/assistantChat'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
|
||||
@@ -179,6 +180,8 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="AI Assistant" />
|
||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||
{/* Sidebar */}
|
||||
<ChatSidebar
|
||||
@@ -223,7 +226,7 @@ export default function AssistantChatPage() {
|
||||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-primary" />
|
||||
</div>
|
||||
<div className="bg-[rgba(255,255,255,0.04)] border border-brand-border rounded-2xl px-4 py-3">
|
||||
<div className="bg-white/[0.04] border border-brand-border rounded-2xl px-4 py-3">
|
||||
<Loader2 size={16} className="animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,7 +245,7 @@ export default function AssistantChatPage() {
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about IT, networking, troubleshooting..."
|
||||
rows={3}
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-hidden focus:border-[rgba(6,182,212,0.3)]"
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-hidden focus:border-primary/30"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -303,5 +306,6 @@ export default function AssistantChatPage() {
|
||||
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function BatchStatusPage() {
|
||||
const progressPercent = total > 0 ? Math.round((completed / total) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl space-y-6 px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="overflow-y-auto h-full container mx-auto max-w-3xl space-y-6 px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { MessageSquareText, Send, CheckCircle2, ChevronDown } from 'lucide-react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { feedbackApi } from '@/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -130,6 +131,8 @@ export function FeedbackPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full">
|
||||
<PageMeta title="Feedback" />
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Page header */}
|
||||
<div className="mb-8">
|
||||
@@ -273,6 +276,7 @@ export function FeedbackPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
@@ -25,6 +26,8 @@ export function ForgotPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Forgot Password" description="Reset your ResolutionFlow password" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-card px-4">
|
||||
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
|
||||
|
||||
@@ -109,6 +112,7 @@ export function ForgotPasswordPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function GuideDetailPage() {
|
||||
|
||||
if (!guide) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-6">
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-6 overflow-y-auto">
|
||||
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">Guide Not Found</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">The guide you're looking for doesn't exist.</p>
|
||||
<Link
|
||||
@@ -25,7 +25,7 @@ export default function GuideDetailPage() {
|
||||
const Icon = guide.icon
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<div className="overflow-y-auto h-full p-6 max-w-3xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1.5 text-xs text-muted-foreground mb-6">
|
||||
<Link to="/guides" className="hover:text-primary transition-colors">
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { BookOpen } from 'lucide-react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { guides } from '@/data/guides'
|
||||
import { GuideCard } from '@/components/guides/GuideCard'
|
||||
|
||||
export default function GuidesHubPage() {
|
||||
return (
|
||||
<div className="overflow-y-auto h-full">
|
||||
<PageMeta title="Guides" />
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
@@ -25,5 +28,6 @@ export default function GuidesHubPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { PasswordInput } from '@/components/common/PasswordInput'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function LoginPage() {
|
||||
@@ -40,6 +41,8 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Sign In" description="Sign in to your ResolutionFlow account" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
{/* Atmosphere orbs */}
|
||||
<div
|
||||
@@ -106,7 +109,7 @@ export function LoginPage() {
|
||||
className={cn(
|
||||
'block w-full rounded-[10px] border border-border bg-card px-3 py-2.5',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'transition-colors'
|
||||
)}
|
||||
placeholder="you@example.com"
|
||||
@@ -127,7 +130,7 @@ export function LoginPage() {
|
||||
className={cn(
|
||||
'block w-full rounded-[10px] border border-border bg-card px-3 py-2.5',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
'transition-colors'
|
||||
)}
|
||||
placeholder="••••••••••"
|
||||
@@ -164,6 +167,7 @@ export function LoginPage() {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function MaintenanceFlowDetailPage() {
|
||||
const hasActiveBatch = recentSessions.some(s => s.started_at && !s.completed_at && s.batch_id)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl space-y-6 px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="overflow-y-auto h-full container mx-auto max-w-4xl space-y-6 px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Active batch banner */}
|
||||
{hasActiveBatch && !bannerDismissed && (
|
||||
<ActiveBatchBanner
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Globe, Users, Copy, Check, Link2, ExternalLink, Trash2, ArrowLeft } from 'lucide-react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
@@ -121,6 +122,8 @@ export default function MySharesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full">
|
||||
<PageMeta title="My Shares" />
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
@@ -241,5 +244,6 @@ export default function MySharesPage() {
|
||||
confirmLabel="Revoke"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench } from 'lucide-react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { StaggerList } from '@/components/common/StaggerList'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
@@ -116,6 +118,8 @@ export function MyTreesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full">
|
||||
<PageMeta title="My Flows" />
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-6 flex items-center justify-between sm:mb-8">
|
||||
<div>
|
||||
@@ -214,7 +218,7 @@ export function MyTreesPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StaggerList className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{trees.map((tree) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
@@ -349,7 +353,7 @@ export function MyTreesPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</StaggerList>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
@@ -389,6 +393,7 @@ export function MyTreesPage() {
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Search, Loader2, Star, ChevronLeft, ChevronRight, GitBranch } from 'lucide-react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem, TreeFilters } from '@/types'
|
||||
@@ -277,6 +278,8 @@ export function QuickStartPage() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full">
|
||||
<PageMeta title="Dashboard" />
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Greeting */}
|
||||
<div className="fade-in" style={{ animationDelay: '100ms' }}>
|
||||
@@ -646,6 +649,7 @@ export function QuickStartPage() {
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuthStore } from '@/store/authStore'
|
||||
import { inviteApi } from '@/api/invite'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { PasswordInput } from '@/components/common/PasswordInput'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function RegisterPage() {
|
||||
@@ -76,6 +77,8 @@ export function RegisterPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Create Account" description="Create your ResolutionFlow account to start building guided troubleshooting flows" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-black px-4">
|
||||
{/* Subtle radial overlay */}
|
||||
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
|
||||
@@ -251,6 +254,7 @@ export function RegisterPage() {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { authApi } from '@/api/auth'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { PasswordInput } from '@/components/common/PasswordInput'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
@@ -72,6 +73,8 @@ export function ResetPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Reset Password" description="Set a new password for your ResolutionFlow account" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-black px-4">
|
||||
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
|
||||
|
||||
@@ -180,6 +183,7 @@ export function ResetPasswordPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -340,7 +340,7 @@ export function SessionDetailPage() {
|
||||
const outcomeConfig = session.outcome ? OUTCOME_CONFIG[session.outcome] : null
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="overflow-y-auto h-full container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Back nav */}
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { StaggerList } from '@/components/common/StaggerList'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import type { Session, TreeListItem } from '@/types'
|
||||
@@ -154,6 +156,8 @@ export function SessionHistoryPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full">
|
||||
<PageMeta title="Session History" />
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Session History</h1>
|
||||
@@ -210,7 +214,8 @@ export function SessionHistoryPage() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<>
|
||||
<StaggerList className="space-y-4">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
@@ -298,18 +303,20 @@ export function SessionHistoryPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasMore ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
Showing the 50 most recent sessions
|
||||
</p>
|
||||
) : sessions.length > 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
Showing all {sessions.length} sessions
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</StaggerList>
|
||||
{hasMore ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
Showing the 50 most recent sessions
|
||||
</p>
|
||||
) : sessions.length > 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
Showing all {sessions.length} sessions
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Bookmark, Trash2 } from 'lucide-react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
@@ -87,6 +88,8 @@ export default function StepLibraryPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Step Library" />
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
@@ -167,5 +170,6 @@ export default function StepLibraryPage() {
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -274,13 +274,13 @@ export default function SurveyPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute -top-32 right-0 h-[500px] w-[500px] rounded-full opacity-[0.03]" style={{ background: 'radial-gradient(circle, #06b6d4, transparent 70%)' }} />
|
||||
<div className="absolute -top-32 right-0 h-[500px] w-[500px] rounded-full opacity-[0.03]" style={{ background: 'radial-gradient(circle, var(--color-primary), transparent 70%)' }} />
|
||||
<div className="absolute -bottom-32 left-0 h-[400px] w-[400px] rounded-full opacity-[0.02]" style={{ background: 'radial-gradient(circle, #a855f7, transparent 70%)' }} />
|
||||
</div>
|
||||
<div className="relative z-10 mx-auto max-w-[680px] px-4 sm:px-5">
|
||||
<div className="text-center pt-20 sm:pt-32 animate-fade-in-up">
|
||||
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(6, 182, 212, 0.1)' }}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</div>
|
||||
<h2 className="font-heading text-2xl font-bold mb-2.5">Already Submitted</h2>
|
||||
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto leading-relaxed mb-3">
|
||||
@@ -341,7 +341,7 @@ export default function SurveyPage() {
|
||||
{/* Hero — visible only on first slide */}
|
||||
{currentSlide === 0 && !isComplete && (
|
||||
<div className="text-center pt-10 pb-8 sm:pt-[72px] sm:pb-10 animate-fade-in-up">
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] sm:text-[11px] font-semibold font-label uppercase tracking-widest mb-4 sm:mb-5" style={{ background: 'rgba(6, 182, 212, 0.1)', border: '1px solid rgba(6, 182, 212, 0.15)', color: '#06b6d4' }}>
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] sm:text-[11px] font-semibold font-label uppercase tracking-widest mb-4 sm:mb-5" style={{ background: 'rgba(6, 182, 212, 0.1)', border: '1px solid rgba(6, 182, 212, 0.15)', color: 'var(--color-primary)' }}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
FlowPilot Research
|
||||
</div>
|
||||
@@ -353,15 +353,15 @@ export default function SurveyPage() {
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4 sm:gap-7 mt-4 sm:mt-5 text-[11px] sm:text-[12px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
~5 minutes
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
Confidential
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
16 questions
|
||||
</span>
|
||||
</div>
|
||||
@@ -376,7 +376,7 @@ export default function SurveyPage() {
|
||||
key={i}
|
||||
className="flex-1 h-1 sm:h-[3px] rounded-full transition-colors duration-300"
|
||||
style={{
|
||||
background: i < currentSlide ? '#34d399' : i === currentSlide ? 'linear-gradient(90deg, #06b6d4, #22d3ee)' : 'var(--color-border)',
|
||||
background: i < currentSlide ? 'oklch(0.76 0.15 163)' : i === currentSlide ? 'linear-gradient(90deg, var(--color-primary), var(--color-brand-gradient-to))' : 'var(--color-border)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -424,7 +424,7 @@ export default function SurveyPage() {
|
||||
{isComplete && (
|
||||
<div className="text-center pt-10 sm:pt-16 animate-fade-in-up">
|
||||
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(52, 211, 153, 0.1)' }}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="oklch(0.76 0.15 163)" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</div>
|
||||
<h2 className="font-heading text-2xl font-bold mb-2.5">Response Submitted!</h2>
|
||||
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-6 sm:mb-8 leading-relaxed">
|
||||
@@ -445,7 +445,7 @@ export default function SurveyPage() {
|
||||
placeholder="your@email.com"
|
||||
className="flex-1 rounded-[9px] px-3.5 py-2.5 text-sm text-foreground placeholder:text-brand-text-muted focus:outline-hidden"
|
||||
style={{ background: 'rgba(16, 17, 20, 0.6)', border: '1px solid var(--glass-border)' }}
|
||||
onFocus={e => { e.currentTarget.style.borderColor = 'rgba(6, 182, 212, 0.4)' }}
|
||||
onFocus={e => { e.currentTarget.style.borderColor = 'var(--color-primary)' }}
|
||||
onBlur={e => { e.currentTarget.style.borderColor = 'var(--glass-border)' }}
|
||||
disabled={emailSending}
|
||||
/>
|
||||
@@ -519,8 +519,8 @@ export default function SurveyPage() {
|
||||
|
||||
function ScenarioBox({ scenario }: { scenario: { title: string; symptom: string; details: string } }) {
|
||||
return (
|
||||
<div className="rounded-[10px] p-3.5 px-4 sm:p-4 sm:px-5 mb-4 text-[13px]" style={{ background: 'linear-gradient(135deg, rgba(6, 182, 212, 0.06), rgba(139, 92, 246, 0.03))', border: '1px solid rgba(6, 182, 212, 0.12)' }}>
|
||||
<div className="font-label text-[10px] uppercase tracking-widest mb-2 font-semibold" style={{ color: '#06b6d4' }}>{scenario.title}</div>
|
||||
<div className="rounded-[10px] p-3.5 px-4 sm:p-4 sm:px-5 mb-4 text-[13px]" style={{ background: 'linear-gradient(135deg, rgba(6, 182, 212, 0.06), rgba(139, 92, 246, 0.03))', border: '1px solid color-mix(in srgb, var(--color-primary) 12%, transparent)' }}>
|
||||
<div className="font-label text-[10px] uppercase tracking-widest mb-2 font-semibold" style={{ color: 'var(--color-primary)' }}>{scenario.title}</div>
|
||||
<div className="sm:flex gap-2 mb-1">
|
||||
<span className="text-muted-foreground font-medium whitespace-nowrap">Symptom:</span>
|
||||
<span className="text-muted-foreground/80">{scenario.symptom}</span>
|
||||
@@ -535,8 +535,8 @@ function ScenarioBox({ scenario }: { scenario: { title: string; symptom: string;
|
||||
|
||||
function QuestionCard({ question: q, answer, setAnswer }: { question: SurveyQuestion; answer?: string | string[]; setAnswer: (id: string, val: string | string[]) => void }) {
|
||||
return (
|
||||
<div className="glass-card-static p-4 sm:p-7 mb-3 sm:mb-4 transition-[border-color] duration-200 focus-within:border-[rgba(6,182,212,0.25)]!">
|
||||
<div className="font-label text-[11px] mb-1.5 font-medium" style={{ color: '#06b6d4' }}>Q{q.num}</div>
|
||||
<div className="glass-card-static p-4 sm:p-7 mb-3 sm:mb-4 transition-[border-color] duration-200 focus-within:border-primary/25!">
|
||||
<div className="font-label text-[11px] mb-1.5 font-medium" style={{ color: 'var(--color-primary)' }}>Q{q.num}</div>
|
||||
<div className="font-heading text-[14px] sm:text-[15px] font-semibold text-foreground leading-snug mb-1">{q.text}</div>
|
||||
{q.hint && <div className="text-[12px] text-muted-foreground mb-3 sm:mb-4 leading-snug">{q.hint}</div>}
|
||||
{!q.hint && <div className="mb-3 sm:mb-4" />}
|
||||
@@ -550,12 +550,12 @@ function QuestionCard({ question: q, answer, setAnswer }: { question: SurveyQues
|
||||
className="flex items-start gap-3 px-3.5 py-3 sm:px-4 rounded-[9px] text-left text-[13px] sm:text-sm transition-all duration-150 select-none"
|
||||
style={{
|
||||
background: answer === opt ? 'rgba(6, 182, 212, 0.1)' : 'rgba(16, 17, 20, 0.6)',
|
||||
border: `1px solid ${answer === opt ? '#06b6d4' : 'var(--glass-border)'}`,
|
||||
border: `1px solid ${answer === opt ? 'var(--color-primary)' : 'var(--glass-border)'}`,
|
||||
color: answer === opt ? 'var(--color-foreground)' : 'var(--color-muted-foreground)',
|
||||
}}
|
||||
>
|
||||
<div className="w-[18px] h-[18px] rounded-full shrink-0 flex items-center justify-center transition-all duration-150 mt-0.5" style={{ border: `2px solid ${answer === opt ? '#06b6d4' : 'var(--glass-border)'}` }}>
|
||||
{answer === opt && <div className="w-2 h-2 rounded-full" style={{ background: '#06b6d4' }} />}
|
||||
<div className="w-[18px] h-[18px] rounded-full shrink-0 flex items-center justify-center transition-all duration-150 mt-0.5" style={{ border: `2px solid ${answer === opt ? 'var(--color-primary)' : 'var(--glass-border)'}` }}>
|
||||
{answer === opt && <div className="w-2 h-2 rounded-full" style={{ background: 'var(--color-primary)' }} />}
|
||||
</div>
|
||||
<span className="leading-snug">{opt}</span>
|
||||
</button>
|
||||
@@ -577,11 +577,11 @@ function QuestionCard({ question: q, answer, setAnswer }: { question: SurveyQues
|
||||
className="flex items-start gap-3 px-3.5 py-3 sm:px-4 rounded-[9px] text-left text-[13px] sm:text-sm transition-all duration-150 select-none"
|
||||
style={{
|
||||
background: selected ? 'rgba(6, 182, 212, 0.1)' : 'rgba(16, 17, 20, 0.6)',
|
||||
border: `1px solid ${selected ? '#06b6d4' : 'var(--glass-border)'}`,
|
||||
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--glass-border)'}`,
|
||||
color: selected ? 'var(--color-foreground)' : 'var(--color-muted-foreground)',
|
||||
}}
|
||||
>
|
||||
<div className="w-[18px] h-[18px] rounded-[5px] shrink-0 flex items-center justify-center text-[11px] transition-all duration-150 mt-0.5" style={{ border: `2px solid ${selected ? '#06b6d4' : 'var(--glass-border)'}`, background: selected ? '#06b6d4' : 'transparent', color: selected ? 'white' : 'transparent' }}>
|
||||
<div className="w-[18px] h-[18px] rounded-[5px] shrink-0 flex items-center justify-center text-[11px] transition-all duration-150 mt-0.5" style={{ border: `2px solid ${selected ? 'var(--color-primary)' : 'var(--glass-border)'}`, background: selected ? 'var(--color-primary)' : 'transparent', color: selected ? 'white' : 'transparent' }}>
|
||||
{'\u2713'}
|
||||
</div>
|
||||
<span className="leading-snug">{opt}</span>
|
||||
@@ -605,7 +605,7 @@ function QuestionCard({ question: q, answer, setAnswer }: { question: SurveyQues
|
||||
background: 'rgba(16, 17, 20, 0.6)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
}}
|
||||
onFocus={e => { e.currentTarget.style.borderColor = 'rgba(6, 182, 212, 0.4)'; e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6, 182, 212, 0.1)' }}
|
||||
onFocus={e => { e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6, 182, 212, 0.1)' }}
|
||||
onBlur={e => { e.currentTarget.style.borderColor = 'var(--glass-border)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
/>
|
||||
)}
|
||||
@@ -621,7 +621,7 @@ function RangeInput({ question: q, value, onChange }: { question: SurveyQuestion
|
||||
const numVal = value ? parseInt(value) : q.min || 0
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="text-center font-label text-2xl font-semibold mb-3" style={{ color: '#06b6d4' }}>
|
||||
<div className="text-center font-label text-2xl font-semibold mb-3" style={{ color: 'var(--color-primary)' }}>
|
||||
{numVal}{q.suffix || ''}
|
||||
</div>
|
||||
<input
|
||||
@@ -633,7 +633,7 @@ function RangeInput({ question: q, value, onChange }: { question: SurveyQuestion
|
||||
onChange={e => onChange(e.target.value + (q.suffix || ''))}
|
||||
className="w-full h-2 sm:h-1 rounded-full appearance-none cursor-pointer touch-none"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #06b6d4 0%, #06b6d4 ${((numVal - (q.min || 0)) / ((q.max || 10) - (q.min || 0))) * 100}%, var(--color-border) ${((numVal - (q.min || 0)) / ((q.max || 10) - (q.min || 0))) * 100}%, var(--color-border) 100%)`,
|
||||
background: `linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) ${((numVal - (q.min || 0)) / ((q.max || 10) - (q.min || 0))) * 100}%, var(--color-border) ${((numVal - (q.min || 0)) / ((q.max || 10) - (q.min || 0))) * 100}%, var(--color-border) 100%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground mt-2.5">
|
||||
@@ -730,7 +730,7 @@ function DragRank({ items, onChange }: { items: string[]; onChange: (items: stri
|
||||
className="flex items-center gap-2.5 sm:gap-3 px-3 py-3 sm:px-4 sm:py-2.5 rounded-[9px] text-[13px] sm:text-sm transition-all duration-150 select-none"
|
||||
style={{
|
||||
background: overIdx === idx ? 'rgba(6, 182, 212, 0.1)' : 'rgba(16, 17, 20, 0.6)',
|
||||
border: `1px solid ${overIdx === idx || draggingIdx === idx ? '#06b6d4' : 'var(--glass-border)'}`,
|
||||
border: `1px solid ${overIdx === idx || draggingIdx === idx ? 'var(--color-primary)' : 'var(--glass-border)'}`,
|
||||
opacity: draggingIdx === idx ? 0.5 : 1,
|
||||
cursor: 'grab',
|
||||
color: 'var(--color-muted-foreground)',
|
||||
@@ -739,7 +739,7 @@ function DragRank({ items, onChange }: { items: string[]; onChange: (items: stri
|
||||
<div className="shrink-0 text-brand-text-muted">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/></svg>
|
||||
</div>
|
||||
<div className="font-label text-[11px] font-semibold w-5 text-center shrink-0" style={{ color: '#06b6d4' }}>{idx + 1}</div>
|
||||
<div className="font-label text-[11px] font-semibold w-5 text-center shrink-0" style={{ color: 'var(--color-primary)' }}>{idx + 1}</div>
|
||||
<div className="flex-1 leading-snug">{item}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
|
||||
export default function SurveyThankYouPage() {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Thank You" description="Thank you for your feedback on ResolutionFlow" />
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* Atmosphere orbs */}
|
||||
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden="true">
|
||||
@@ -29,7 +32,7 @@ export default function SurveyThankYouPage() {
|
||||
<div className="text-center pt-[120px] animate-fade-in-up">
|
||||
{/* Success icon */}
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full flex items-center justify-center" style={{ background: 'rgba(52, 211, 153, 0.1)', border: '1px solid rgba(52, 211, 153, 0.15)' }}>
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="oklch(0.76 0.15 163)" strokeWidth="2">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -52,7 +55,7 @@ export default function SurveyThankYouPage() {
|
||||
className="glass-card-static p-6 text-center max-w-[480px] mx-auto"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-widest text-primary font-semibold">
|
||||
@@ -72,5 +75,6 @@ export default function SurveyThankYouPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { X, RotateCcw, Play, FileUp } from 'lucide-react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
@@ -265,6 +266,8 @@ export function TreeLibraryPage() {
|
||||
selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full">
|
||||
<PageMeta title="Flow Library" />
|
||||
<div className="min-h-full">
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -596,6 +599,7 @@ export function TreeLibraryPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, Link } from 'react-router-dom'
|
||||
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function VerifyEmailPage() {
|
||||
@@ -23,6 +24,8 @@ export function VerifyEmailPage() {
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Verify Email" description="Verify your ResolutionFlow email address" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="glass-card-static w-full max-w-md p-8 text-center">
|
||||
{status === 'loading' && (
|
||||
@@ -55,8 +58,8 @@ export function VerifyEmailPage() {
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
'mt-6 inline-flex items-center rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-brand-border px-6 py-2 text-sm font-medium text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)]'
|
||||
'mt-6 inline-flex items-center rounded-[10px] bg-white/[0.04] border border-brand-border px-6 py-2 text-sm font-medium text-foreground',
|
||||
'hover:border-white/[0.12]'
|
||||
)}
|
||||
>
|
||||
Go to Dashboard
|
||||
@@ -65,6 +68,7 @@ export function VerifyEmailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function ChatRetentionSettingsPage() {
|
||||
onChange={e => setRetentionDays(e.target.value)}
|
||||
min={1}
|
||||
max={365}
|
||||
className="w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5 focus:outline-hidden focus:border-[rgba(6,182,212,0.3)]"
|
||||
className="w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5 focus:outline-hidden focus:border-primary/30"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
@@ -91,7 +91,7 @@ export default function ChatRetentionSettingsPage() {
|
||||
onChange={e => setMaxCount(e.target.value)}
|
||||
min={10}
|
||||
max={10000}
|
||||
className="w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5 focus:outline-hidden focus:border-[rgba(6,182,212,0.3)]"
|
||||
className="w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5 focus:outline-hidden focus:border-primary/30"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { UserUpdate } from '@/types'
|
||||
const inputClass = cn(
|
||||
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)
|
||||
|
||||
export function ProfileSettingsPage() {
|
||||
@@ -145,8 +145,8 @@ export function ProfileSettingsPage() {
|
||||
to="/change-password"
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-brand-border text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)]'
|
||||
'bg-white/[0.04] border border-brand-border text-foreground',
|
||||
'hover:border-white/[0.12]'
|
||||
)}
|
||||
>
|
||||
Change Password
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Trash2, Pencil, FolderTree } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { PageHeader } from '@/components/common/PageHeader'
|
||||
@@ -79,8 +79,6 @@ export function TeamCategoriesPage() {
|
||||
setForm({ name: cat.name, slug: cat.slug, description: cat.description || '' })
|
||||
}
|
||||
|
||||
const inputCn = cn('w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground', 'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -141,15 +139,15 @@ export function TeamCategoriesPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<input type="text" value={form.name} onChange={(e) => { const name = e.target.value; setForm(f => ({ ...f, name, slug: generateSlug(name) })) }} placeholder="e.g. Networking" className={inputCn} />
|
||||
<Input type="text" value={form.name} onChange={(e) => { const name = e.target.value; setForm(f => ({ ...f, name, slug: generateSlug(name) })) }} placeholder="e.g. Networking" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
|
||||
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className={inputCn} />
|
||||
<Input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optional" className={inputCn} />
|
||||
<Input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -166,15 +164,15 @@ export function TeamCategoriesPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputCn} />
|
||||
<Input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
|
||||
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className={inputCn} />
|
||||
<Input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optional" className={inputCn} />
|
||||
<Input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Trash2, ToggleLeft } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
|
||||
import type { Column } from '@/components/admin'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
@@ -146,7 +147,7 @@ export function FeatureFlagsPage() {
|
||||
},
|
||||
]
|
||||
|
||||
const inputCn = cn('w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground', 'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20')
|
||||
const selectClass = cn('w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground', 'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20')
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -197,15 +198,15 @@ export function FeatureFlagsPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Flag Key</label>
|
||||
<input type="text" value={createForm.flag_key} onChange={(e) => setCreateForm({ ...createForm, flag_key: e.target.value })} placeholder="e.g. custom_branding" className={inputCn} />
|
||||
<Input type="text" value={createForm.flag_key} onChange={(e) => setCreateForm({ ...createForm, flag_key: e.target.value })} placeholder="e.g. custom_branding" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Display Name</label>
|
||||
<input type="text" value={createForm.display_name} onChange={(e) => setCreateForm({ ...createForm, display_name: e.target.value })} placeholder="e.g. Custom Branding" className={inputCn} />
|
||||
<Input type="text" value={createForm.display_name} onChange={(e) => setCreateForm({ ...createForm, display_name: e.target.value })} placeholder="e.g. Custom Branding" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={createForm.description ?? ''} onChange={(e) => setCreateForm({ ...createForm, description: e.target.value || null })} placeholder="Optional description" className={inputCn} />
|
||||
<Input type="text" value={createForm.description ?? ''} onChange={(e) => setCreateForm({ ...createForm, description: e.target.value || null })} placeholder="Optional description" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -222,11 +223,11 @@ export function FeatureFlagsPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<input type="text" value={overrideForm.account_display_code} onChange={(e) => setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" className={inputCn} />
|
||||
<Input type="text" value={overrideForm.account_display_code} onChange={(e) => setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Feature Flag</label>
|
||||
<select value={overrideForm.flag_id} onChange={(e) => setOverrideForm({ ...overrideForm, flag_id: e.target.value })} className={inputCn}>
|
||||
<select value={overrideForm.flag_id} onChange={(e) => setOverrideForm({ ...overrideForm, flag_id: e.target.value })} className={selectClass}>
|
||||
<option value="">Select a flag...</option>
|
||||
{flags.map(f => <option key={f.id} value={f.id}>{f.display_name}</option>)}
|
||||
</select>
|
||||
@@ -237,7 +238,7 @@ export function FeatureFlagsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Note</label>
|
||||
<input type="text" value={overrideForm.note ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason" className={inputCn} />
|
||||
<Input type="text" value={overrideForm.note ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Trash2, Pencil, FolderTree } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { DataTable, PageHeader, ActionMenu, EmptyState } from '@/components/admin'
|
||||
import type { Column } from '@/components/admin'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AdminCategory, GlobalCategoryCreate } from '@/types/admin'
|
||||
|
||||
export function GlobalCategoriesPage() {
|
||||
@@ -88,8 +88,6 @@ export function GlobalCategoriesPage() {
|
||||
},
|
||||
]
|
||||
|
||||
const inputCn = cn('w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground', 'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -127,15 +125,15 @@ export function GlobalCategoriesPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<input type="text" value={form.name} onChange={(e) => { const name = e.target.value; setForm(f => ({ ...f, name, slug: generateSlug(name) })) }} placeholder="e.g. Networking" className={inputCn} />
|
||||
<Input type="text" value={form.name} onChange={(e) => { const name = e.target.value; setForm(f => ({ ...f, name, slug: generateSlug(name) })) }} placeholder="e.g. Networking" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
|
||||
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="e.g. networking" className={inputCn} />
|
||||
<Input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="e.g. networking" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={form.description ?? ''} onChange={(e) => setForm({ ...form, description: e.target.value || null })} placeholder="Optional description" className={inputCn} />
|
||||
<Input type="text" value={form.description ?? ''} onChange={(e) => setForm({ ...form, description: e.target.value || null })} placeholder="Optional description" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -156,15 +154,15 @@ export function GlobalCategoriesPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Networking" className={inputCn} />
|
||||
<Input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Networking" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
|
||||
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="e.g. networking" className={inputCn} />
|
||||
<Input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="e.g. networking" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input type="text" value={form.description ?? ''} onChange={(e) => setForm({ ...form, description: e.target.value || null })} placeholder="Optional description" className={inputCn} />
|
||||
<Input type="text" value={form.description ?? ''} onChange={(e) => setForm({ ...form, description: e.target.value || null })} placeholder="Optional description" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Copy, Trash2, Ticket, Mail, MailCheck, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
|
||||
import type { Column } from '@/components/admin'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
@@ -108,7 +109,7 @@ export function InviteCodesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = cn(
|
||||
const selectClass = cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)
|
||||
@@ -254,12 +255,11 @@ export function InviteCodesPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Recipient Email</label>
|
||||
<input
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Optional — will send invite email"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -273,7 +273,7 @@ export function InviteCodesPage() {
|
||||
setAssignedPlan(plan)
|
||||
if (plan === 'free') setTrialDays('')
|
||||
}}
|
||||
className={inputClass}
|
||||
className={selectClass}
|
||||
>
|
||||
{PLAN_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
@@ -284,14 +284,13 @@ export function InviteCodesPage() {
|
||||
{assignedPlan !== 'free' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Trial Duration (days)</label>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
value={trialDays}
|
||||
onChange={(e) => setTrialDays(e.target.value)}
|
||||
placeholder="e.g. 14 (1-90)"
|
||||
min={1}
|
||||
max={90}
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Leave empty for no trial — account gets full plan immediately.</p>
|
||||
</div>
|
||||
@@ -299,23 +298,21 @@ export function InviteCodesPage() {
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Expires in (days)</label>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
value={expiresInDays}
|
||||
onChange={(e) => setExpiresInDays(e.target.value)}
|
||||
placeholder="Leave empty for no expiry"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Note</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Optional note (e.g. who this is for)"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Trash2, Gauge } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { DataTable, PageHeader, ActionMenu, EmptyState } from '@/components/admin'
|
||||
import type { Column } from '@/components/admin'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PlanLimitConfig, AccountOverrideResponse, AccountOverrideCreate } from '@/types/admin'
|
||||
|
||||
export function PlanLimitsPage() {
|
||||
@@ -109,11 +109,6 @@ export function PlanLimitsPage() {
|
||||
},
|
||||
]
|
||||
|
||||
const inputCn = cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title="Plan Limits" description="Configure plan tier limits and account-specific overrides" />
|
||||
@@ -161,15 +156,15 @@ export function PlanLimitsPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Trees (empty = unlimited)</label>
|
||||
<input type="number" value={editPlan.max_trees ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_trees: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
<Input type="number" value={editPlan.max_trees ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_trees: e.target.value ? parseInt(e.target.value) : null })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Sessions/Month (empty = unlimited)</label>
|
||||
<input type="number" value={editPlan.max_sessions_per_month ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_sessions_per_month: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
<Input type="number" value={editPlan.max_sessions_per_month ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_sessions_per_month: e.target.value ? parseInt(e.target.value) : null })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Users (empty = unlimited)</label>
|
||||
<input type="number" value={editPlan.max_users ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_users: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
<Input type="number" value={editPlan.max_users ?? ''} onChange={(e) => setEditPlan({ ...editPlan, max_users: e.target.value ? parseInt(e.target.value) : null })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -191,23 +186,23 @@ export function PlanLimitsPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<input type="text" value={overrideForm.account_display_code} onChange={(e) => setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" className={inputCn} />
|
||||
<Input type="text" value={overrideForm.account_display_code} onChange={(e) => setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Trees Override</label>
|
||||
<input type="number" value={overrideForm.override_max_trees ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_trees: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
<Input type="number" value={overrideForm.override_max_trees ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_trees: e.target.value ? parseInt(e.target.value) : null })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Sessions/Month Override</label>
|
||||
<input type="number" value={overrideForm.override_max_sessions_per_month ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_sessions_per_month: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
<Input type="number" value={overrideForm.override_max_sessions_per_month ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_sessions_per_month: e.target.value ? parseInt(e.target.value) : null })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Max Users Override</label>
|
||||
<input type="number" value={overrideForm.override_max_users ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_users: e.target.value ? parseInt(e.target.value) : null })} className={inputCn} />
|
||||
<Input type="number" value={overrideForm.override_max_users ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, override_max_users: e.target.value ? parseInt(e.target.value) : null })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Note</label>
|
||||
<input type="text" value={overrideForm.note ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason for override" className={inputCn} />
|
||||
<Input type="text" value={overrideForm.note ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason for override" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { PageHeader } from '@/components/admin'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -93,15 +94,11 @@ export function SettingsPage() {
|
||||
{maintenanceMode && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Maintenance Message</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={maintenanceMessage}
|
||||
onChange={(e) => setSettings({ ...settings, maintenance_message: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="We're performing scheduled maintenance. Please check back later."
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function SurveyInvitesPage() {
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="John Smith"
|
||||
className="w-full rounded-[10px] border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden"
|
||||
className="w-full rounded-[10px] border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
@@ -94,7 +94,7 @@ export default function SurveyInvitesPage() {
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="john@example.com"
|
||||
className="w-full rounded-[10px] border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden"
|
||||
className="w-full rounded-[10px] border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -109,7 +109,7 @@ export default function SurveyInvitesPage() {
|
||||
<button
|
||||
onClick={() => handleCreate(true)}
|
||||
disabled={creating || !name.trim() || !email.trim()}
|
||||
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-brand-border px-4 py-2 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] active:scale-[0.97] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
className="inline-flex items-center gap-2 rounded-[10px] bg-white/[0.04] border border-brand-border px-4 py-2 text-sm font-medium text-foreground hover:border-white/[0.12] active:scale-[0.97] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
Send Email
|
||||
@@ -122,7 +122,7 @@ export default function SurveyInvitesPage() {
|
||||
)}
|
||||
|
||||
{lastCreated && (
|
||||
<div className="mt-4 rounded-[10px] border border-[rgba(6,182,212,0.15)] bg-[rgba(6,182,212,0.04)] p-4">
|
||||
<div className="mt-4 rounded-[10px] border border-primary/[0.15] bg-primary/[0.04] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
@@ -163,7 +163,7 @@ export default function SurveyInvitesPage() {
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-sm text-muted-foreground">No invites yet</td></tr>
|
||||
) : (
|
||||
invites.map(invite => (
|
||||
<tr key={invite.id} className="border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
<tr key={invite.id} className="border-b border-border/50 hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-foreground">{invite.recipient_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{invite.recipient_email || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -152,7 +152,7 @@ function ResponseRow({
|
||||
className={cn(
|
||||
'border-b border-border/50 transition-colors cursor-pointer',
|
||||
!response.is_read && 'bg-primary/3',
|
||||
'hover:bg-[rgba(255,255,255,0.02)]'
|
||||
'hover:bg-white/[0.02]'
|
||||
)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
@@ -227,14 +227,14 @@ function ResponseRow({
|
||||
>
|
||||
<button
|
||||
onClick={() => { onMarkRead(); setShowMenu(false) }}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-white/[0.04] transition-colors"
|
||||
>
|
||||
{response.is_read ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
{response.is_read ? 'Mark Unread' : 'Mark Read'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onArchive(); setShowMenu(false) }}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-white/[0.04] transition-colors"
|
||||
>
|
||||
{response.archived_at ? <ArchiveRestore className="h-3.5 w-3.5" /> : <Archive className="h-3.5 w-3.5" />}
|
||||
{response.archived_at ? 'Unarchive' : 'Archive'}
|
||||
@@ -437,7 +437,7 @@ export default function SurveyResponsesPage() {
|
||||
'inline-flex items-center gap-2 rounded-[10px] px-3 py-2 text-xs font-medium transition-colors border',
|
||||
showArchived
|
||||
? 'bg-primary/10 text-primary border-primary/20'
|
||||
: 'bg-[rgba(255,255,255,0.04)] text-muted-foreground border-brand-border hover:border-[rgba(255,255,255,0.12)]'
|
||||
: 'bg-white/[0.04] text-muted-foreground border-brand-border hover:border-white/[0.12]'
|
||||
)}
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
@@ -446,7 +446,7 @@ export default function SurveyResponsesPage() {
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || responses.length === 0}
|
||||
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-brand-border px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-[rgba(255,255,255,0.12)] disabled:opacity-50"
|
||||
className="inline-flex items-center gap-2 rounded-[10px] bg-white/[0.04] border border-brand-border px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-white/[0.12] disabled:opacity-50"
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Shield, Crown, UserCheck, UserX, Clock, Ticket, KeyRound, Copy, Check, Archive, ArchiveRestore, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { StatusBadge } from '@/components/admin'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
@@ -187,7 +188,7 @@ export function UserDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = cn(
|
||||
const selectClass = cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)
|
||||
@@ -534,7 +535,7 @@ export function UserDetailPage() {
|
||||
aria-label="Subscription plan"
|
||||
value={selectedPlan}
|
||||
onChange={(e) => setSelectedPlan(e.target.value)}
|
||||
className={inputClass}
|
||||
className={selectClass}
|
||||
>
|
||||
{PLAN_OPTIONS.map(p => (
|
||||
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
|
||||
@@ -646,13 +647,12 @@ export function UserDetailPage() {
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Days to add</label>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
value={trialDays}
|
||||
onChange={(e) => setTrialDays(e.target.value)}
|
||||
min={1}
|
||||
max={90}
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">1-90 days. Will convert to trialing status if not already.</p>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { UserCheck, UserX, Shield, ArrowRightLeft, ExternalLink, UserPlus, Copy, Check, Mail } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin'
|
||||
import type { Column } from '@/components/admin'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
@@ -361,15 +362,11 @@ export function UsersPage() {
|
||||
</p>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={displayCode}
|
||||
onChange={(e) => setDisplayCode(e.target.value)}
|
||||
placeholder="e.g. ABC-1234"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,28 +390,20 @@ export function UsersPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Full name"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<input
|
||||
<Input
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -435,15 +424,11 @@ export function UsersPage() {
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={createForm.account_display_code}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, account_display_code: e.target.value }))}
|
||||
placeholder="e.g. ABC12345"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -532,28 +517,20 @@ export function UsersPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<input
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteForm.email}
|
||||
onChange={(e) => setInviteForm(f => ({ ...f, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={inviteForm.account_display_code}
|
||||
onChange={(e) => setInviteForm(f => ({ ...f, account_display_code: e.target.value }))}
|
||||
placeholder="e.g. ABC12345"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user