Files
resolutionflow/frontend/src/pages/MySharesPage.tsx
chihlasm dfdc6cae9c feat: roll out illustrative empty states across 8 pages with 2 new guide entries
- TreeLibraryPage: split empty state into no-flows (illustration + CTA) vs no-filter-results
- MyAnalyticsPage/TeamAnalyticsPage: add zero-sessions empty state with illustration
- SessionHistoryPage: split into no-sessions (illustration) vs no-filter-results
- StepLibraryBrowser: illustrative empty state when no steps exist
- ScriptTemplateList: replace plain empty state with ScriptIllustration
- MySharesPage: replace icon-based empty state with ShareIllustration
- IntegrationsPage: add IntegrationIllustration above setup form
- Add script-templates and psa-setup guides to guides data
- Add EmptyState vitest tests (7 tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:21:11 -04:00

250 lines
9.1 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Globe, Users, Copy, Check, 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'
import { ShareIllustration } from '@/components/common/EmptyStateIllustrations'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { sessionsApi } from '@/api/sessions'
import { buildSessionShareUrl } from '@/lib/sessionShare'
import type { SessionShare } from '@/types'
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMinutes < 1) return 'just now'
if (diffHours < 1) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`
if (diffDays < 1) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`
const diffMonths = Math.floor(diffDays / 30)
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`
}
function formatExpiration(expiresAt: string | null): { text: string; isExpired: boolean } {
if (!expiresAt) return { text: 'No expiration', isExpired: false }
const expiry = new Date(expiresAt)
const now = new Date()
const diffMs = expiry.getTime() - now.getTime()
if (diffMs <= 0) return { text: 'Expired', isExpired: true }
const diffMinutes = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffHours < 1) return { text: `Expires in ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}`, isExpired: false }
if (diffDays < 1) return { text: `Expires in ${diffHours} hour${diffHours === 1 ? '' : 's'}`, isExpired: false }
return { text: `Expires in ${diffDays} day${diffDays === 1 ? '' : 's'}`, isExpired: false }
}
export default function MySharesPage() {
const navigate = useNavigate()
const [shares, setShares] = useState<SessionShare[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copiedId, setCopiedId] = useState<string | null>(null)
const [revokeTarget, setRevokeTarget] = useState<SessionShare | null>(null)
const fetchShares = useCallback(async () => {
try {
setLoading(true)
setError(null)
const data = await sessionsApi.listMyShares()
setShares(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load shares')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchShares()
}, [fetchShares])
const handleCopyLink = async (share: SessionShare) => {
try {
const url = buildSessionShareUrl(share)
await navigator.clipboard.writeText(url)
setCopiedId(share.id)
toast.success('Link copied')
setTimeout(() => setCopiedId(null), 2000)
} catch {
toast.error('Failed to copy link')
}
}
const handleRevoke = async () => {
if (!revokeTarget) return
try {
await sessionsApi.revokeShare(revokeTarget.id)
setShares((prev) => prev.filter((s) => s.id !== revokeTarget.id))
toast.success('Share link revoked')
} catch {
toast.error('Failed to revoke share link')
} finally {
setRevokeTarget(null)
}
}
// Loading state
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<Spinner />
</div>
)
}
// Error state
if (error) {
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="bg-card border border-red-400/20 rounded-xl p-6">
<div className="text-center">
<p className="text-red-400 text-sm mb-4">{error}</p>
<Button onClick={fetchShares}>
Try again
</Button>
</div>
</div>
</div>
)
}
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
to="/sessions"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mb-6"
>
<ArrowLeft className="h-4 w-4" />
Back to sessions
</Link>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-heading font-bold text-foreground">My Shared Sessions</h1>
<p className="text-muted-foreground mt-1">Manage your session share links</p>
</div>
{/* Empty state */}
{shares.length === 0 ? (
<EmptyState
illustration={<ShareIllustration />}
title="Share session results with your team"
description="Create shareable links to completed sessions for knowledge sharing and client communication."
action={
<Button onClick={() => navigate('/sessions')}>
View Sessions
</Button>
}
learnMoreLink="/guides/sharing-exports"
/>
) : (
<div className="space-y-4">
{shares.map((share) => {
const expiration = formatExpiration(share.expires_at)
const isCopied = copiedId === share.id
return (
<div key={share.id} className="bg-card border border-border rounded-xl p-5">
{/* Top row: badge + name */}
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex items-center gap-1.5 text-xs rounded-full px-2 py-0.5 bg-accent text-muted-foreground">
{share.visibility === 'public' ? (
<Globe className="h-3 w-3" />
) : (
<Users className="h-3 w-3" />
)}
{share.visibility === 'public' ? 'Public' : 'Account Only'}
</span>
<span className="text-sm font-medium text-foreground">
{share.share_name || 'Untitled share'}
</span>
</div>
{/* Session info */}
<p className="text-sm text-muted-foreground mb-2">
Session ID: {share.session_id.slice(0, 8)}...
</p>
{/* Meta row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground mb-4">
<span>Created {formatRelativeTime(share.created_at)}</span>
<span className="hidden sm:inline">·</span>
<span>
{share.view_count > 0
? `${share.view_count} view${share.view_count === 1 ? '' : 's'}`
: 'Not viewed yet'}
</span>
<span className="hidden sm:inline">·</span>
<span className={cn(expiration.isExpired && 'text-red-400')}>
{expiration.text}
</span>
</div>
{/* Actions */}
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => handleCopyLink(share)}
className={isCopied ? 'bg-emerald-400/10 text-emerald-400 shadow-none hover:opacity-100' : ''}
>
{isCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{isCopied ? 'Copied' : 'Copy Link'}
</Button>
<Link
to={`/sessions/${share.session_id}`}
className="inline-flex items-center gap-1.5 border border-border text-muted-foreground hover:bg-accent rounded-md px-3 py-1.5 text-sm transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
View Session
</Link>
<Button
variant="destructive"
size="sm"
onClick={() => setRevokeTarget(share)}
>
<Trash2 className="h-3.5 w-3.5" />
Revoke
</Button>
</div>
</div>
)
})}
</div>
)}
<ConfirmDialog
isOpen={!!revokeTarget}
onClose={() => setRevokeTarget(null)}
onConfirm={handleRevoke}
title="Revoke Share Link"
message="Revoke this share link? Anyone with the link will no longer be able to access the session."
confirmLabel="Revoke"
/>
</div>
</div>
)
}