All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m46s
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.
333 lines
10 KiB
TypeScript
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 } |