Files
office_translator/frontend/src/components/ui/notification.tsx
sepehr 111f3cb69d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m46s
fix(ui): wave 2 from critique re-run — AA contrast, unified PageHeader, checkout confirm
Nav: teams/settings/services back out of the nav until product-ready (user
decision); teams page was already half-finished (UUID member display is a
backend limitation).

Payment: explicit confirmation dialog (plan, amount, billing period, Stripe
note) before any redirect; ?plan= URL now pre-opens the dialog instead of
triggering a silent checkout.

Contrast AA sweep (71 replacements): functional micro-labels raised to
>=60-65% opacity and >=10px across sidebar, header, pricing, landing;
decorative all-caps 'interface' label removed.

Design unification: new PageHeader component (accent pill + serif
base/accent title) applied to settings, services, reviews, teams; pricing
header returned to the editorial voice (serif + accent pill); dead
GlossaryCard deleted.

i18n residuals: suggestion chips, 'Standard' provider label, notification
close, model-combobox strings extracted (+broken bg-surface/border-border-
subtle tokens fixed); ~45 new keys EN+FR; 13 dead keys removed; zero
missing keys verified.

Reviews: icon-only row actions now carry visible text; 'Approve all' is
two-step armed-confirm.

Accelerators: Ctrl/Cmd+Enter submits; arrow-key navigation in the language
combobox; recent-jobs history cap 8->20 with per-job download; source=target
config rejected; empty 'Master Quality' badge removed.

Cookie consent: reopenable via footer link, emoji replaced with drawn icon.

Verified: build exit 0, vitest 9/9, eslint 64 errors = previous level,
0 missing i18n keys.
2026-08-30 22:02:35 +02:00

333 lines
10 KiB
TypeScript

"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { useI18n } from "@/lib/i18n"
const notificationVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pe-8 shadow-lg transition-all duration-300 ease-out",
{
variants: {
variant: {
default: "border-border bg-card text-foreground",
destructive: "border-destructive bg-destructive text-destructive-foreground",
success: "border-success bg-success text-success-foreground",
warning: "border-warning bg-warning text-warning-foreground",
info: "border-primary bg-primary text-primary-foreground",
glass: "glass text-foreground border-border/20",
},
size: {
default: "max-w-md",
sm: "max-w-sm",
lg: "max-w-lg",
xl: "max-w-xl",
full: "max-w-full",
},
position: {
"top-right": "fixed top-4 right-4 z-50",
"top-left": "fixed top-4 left-4 z-50",
"bottom-right": "fixed bottom-4 right-4 z-50",
"bottom-left": "fixed bottom-4 left-4 z-50",
"top-center": "fixed top-4 left-1/2 transform -translate-x-1/2 z-50",
"bottom-center": "fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50",
"center": "fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50",
},
},
defaultVariants: {
variant: "default",
size: "default",
position: "top-right",
},
}
)
export interface NotificationProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof notificationVariants> {
title?: string
description?: string
action?: React.ReactNode
icon?: React.ReactNode
loading?: boolean
closable?: boolean
autoClose?: boolean
duration?: number
onClose?: () => void
}
const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
({
className,
variant,
size,
position,
title,
description,
action,
icon,
loading = false,
closable = true,
autoClose = true,
duration = 5000,
onClose,
children,
...props
}, ref) => {
const { t } = useI18n()
const [visible, setVisible] = React.useState(true)
const [progress, setProgress] = React.useState(100)
React.useEffect(() => {
if (autoClose && !loading) {
const startTime = Date.now()
const interval = setInterval(() => {
const elapsed = Date.now() - startTime
const remaining = Math.max(0, 100 - (elapsed / duration) * 100)
setProgress(remaining)
if (remaining === 0) {
clearInterval(interval)
setVisible(false)
onClose?.()
}
}, 50)
return () => clearInterval(interval)
}
}, [autoClose, loading, duration, onClose])
const handleClose = () => {
setVisible(false)
onClose?.()
}
const defaultIcons = {
default: <Info className="h-5 w-5" />,
destructive: <AlertCircle className="h-5 w-5" />,
success: <CheckCircle className="h-5 w-5" />,
warning: <AlertTriangle className="h-5 w-5" />,
info: <Info className="h-5 w-5" />,
glass: <Info className="h-5 w-5" />,
}
const displayIcon = icon || defaultIcons[variant as keyof typeof defaultIcons] || defaultIcons.default
if (!visible) return null
return (
<div
ref={ref}
role="status"
aria-live="polite"
className={cn(notificationVariants({ variant, size, position }), className)}
{...props}
>
{/* Progress Bar for Auto-close */}
{autoClose && !loading && (
<div className="absolute top-0 left-0 h-1 bg-white/20">
<div
className="h-full bg-white/40 transition-all duration-100 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
)}
<div className="grid gap-3">
<div className="flex items-start gap-3">
{/* Icon */}
<div className={cn(
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
variant === "success" && "bg-success/20 text-success",
variant === "destructive" && "bg-destructive/20 text-destructive",
variant === "warning" && "bg-warning/20 text-warning",
variant === "info" && "bg-primary/20 text-primary",
variant === "default" && "bg-muted text-muted-foreground",
variant === "glass" && "bg-surface/50 text-foreground"
)}>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
displayIcon
)}
</div>
{/* Content */}
<div className="grid gap-1 flex-1 min-w-0">
{title && (
<div className="text-sm font-semibold leading-none">
{title}
</div>
)}
{description && (
<div className="text-sm opacity-90 leading-relaxed">
{description}
</div>
)}
{children}
</div>
</div>
{/* Action */}
{action && (
<div className="flex-shrink-0">
{action}
</div>
)}
</div>
{/* Close Button */}
{closable && (
<button
type="button"
onClick={handleClose}
className="absolute right-2 top-2 flex-shrink-0 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={t("common.closeNotification")}
>
<X className="h-4 w-4" />
</button>
)}
</div>
)
}
)
Notification.displayName = "Notification"
// Notification Context
type NotificationItem = {
id: string
title?: string
description?: string
variant?: VariantProps<typeof notificationVariants>["variant"]
duration?: number
action?: React.ReactNode
icon?: React.ReactNode
closable?: boolean
autoClose?: boolean
}
interface NotificationContextType {
notifications: NotificationItem[]
notify: (notification: Omit<NotificationItem, "id">) => void
success: (notification: Omit<NotificationItem, "id" | "variant">) => void
error: (notification: Omit<NotificationItem, "id" | "variant">) => void
warning: (notification: Omit<NotificationItem, "id" | "variant">) => void
info: (notification: Omit<NotificationItem, "id" | "variant">) => void
dismiss: (id: string) => void
dismissAll: () => void
}
const NotificationContext = React.createContext<NotificationContextType | undefined>(undefined)
export function NotificationProvider({ children }: { children: React.ReactNode }) {
const [notifications, setNotifications] = React.useState<NotificationItem[]>([])
const notify = React.useCallback(
(notification: Omit<NotificationItem, "id">) => {
const id = Math.random().toString(36).substr(2, 9)
setNotifications(prev => [...prev, { ...notification, id }])
},
[]
)
const success = React.useCallback(
(notification: Omit<NotificationItem, "id" | "variant">) =>
notify({ ...notification, variant: "success" }),
[notify]
)
const error = React.useCallback(
(notification: Omit<NotificationItem, "id" | "variant">) =>
notify({ ...notification, variant: "destructive" }),
[notify]
)
const warning = React.useCallback(
(notification: Omit<NotificationItem, "id" | "variant">) =>
notify({ ...notification, variant: "warning" }),
[notify]
)
const info = React.useCallback(
(notification: Omit<NotificationItem, "id" | "variant">) =>
notify({ ...notification, variant: "info" }),
[notify]
)
const dismiss = React.useCallback((id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id))
}, [])
const dismissAll = React.useCallback(() => {
setNotifications([])
}, [])
const value = React.useMemo(() => ({
notifications,
notify,
success,
error,
warning,
info,
dismiss,
dismissAll,
}), [notifications, notify, success, error, warning, info, dismiss, dismissAll])
return (
<NotificationContext.Provider value={value}>
{children}
<NotificationContainer />
</NotificationContext.Provider>
)
}
export function useNotification() {
const context = React.useContext(NotificationContext)
if (context === undefined) {
throw new Error("useNotification must be used within a NotificationProvider")
}
return context
}
// Notification Container
function NotificationContainer() {
const { notifications, dismiss } = useNotification()
return (
<div className="fixed top-0 right-0 z-50 flex flex-col-reverse p-4 space-y-2 pointer-events-none">
{notifications.map((notification) => (
<Notification
key={notification.id}
position="top-right"
variant={notification.variant}
title={notification.title}
description={notification.description}
action={notification.action}
icon={notification.icon}
closable={notification.closable}
autoClose={notification.autoClose}
duration={notification.duration}
onClose={() => dismiss(notification.id)}
className="pointer-events-auto animate-in slide-in-from-top-2"
/>
))}
</div>
)
}
// Skeleton Notification for Loading States
export const NotificationSkeleton = () => (
<div className="w-full max-w-md rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-border skeleton" />
<div className="flex-1 space-y-2">
<div className="h-4 w-3/4 rounded bg-border skeleton" />
<div className="h-3 w-1/2 rounded bg-border skeleton" />
</div>
<div className="w-6 h-6 rounded bg-border skeleton" />
</div>
</div>
)
export { Notification, notificationVariants }