Azure costs are estimated with persistent volumes. Actual billing depends on runtime.
+
+ Hourly
+ {estimate.cost.currency} {estimate.cost.hourly.toFixed(2)}
+
+
+ Daily
+ {estimate.cost.currency} {estimate.cost.daily.toFixed(2)}
+
+
+ Monthly
+ {estimate.cost.currency} {estimate.cost.monthly.toFixed(2)}
+
+
+
+ 💡 Estimated costs include persistent storage. Actual billing based on runtime.
+
+
Adjust options to see cost estimate.
)}
diff --git a/apps/web/components/ui/dialog.tsx b/apps/web/components/ui/dialog.tsx
new file mode 100644
index 0000000..e3960a3
--- /dev/null
+++ b/apps/web/components/ui/dialog.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+import { X } from "lucide-react";
+import { Button } from "./button";
+
+export interface DialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ children: React.ReactNode;
+}
+
+export function Dialog({ open, onOpenChange, children }: DialogProps) {
+ React.useEffect(() => {
+ if (!open) return;
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onOpenChange(false);
+ }
+ };
+
+ document.addEventListener("keydown", handleEscape);
+ return () => document.removeEventListener("keydown", handleEscape);
+ }, [open, onOpenChange]);
+
+ if (!open) return null;
+
+ return (
+
+ {/* Backdrop */}
+
onOpenChange(false)}
+ />
+
+ {/* Dialog */}
+
{children}
+
+ );
+}
+
+export interface DialogContentProps {
+ className?: string;
+ children: React.ReactNode;
+ showClose?: boolean;
+ onClose?: () => void;
+}
+
+export function DialogContent({
+ className,
+ children,
+ showClose = true,
+ onClose,
+}: DialogContentProps) {
+ return (
+
+ {showClose && (
+
+ )}
+ {children}
+
+ );
+}
+
+export interface DialogHeaderProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+export function DialogHeader({ className, children }: DialogHeaderProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export interface DialogTitleProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+export function DialogTitle({ className, children }: DialogTitleProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export interface DialogDescriptionProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+export function DialogDescription({
+ className,
+ children,
+}: DialogDescriptionProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export interface DialogFooterProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+export function DialogFooter({ className, children }: DialogFooterProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+// Confirmation Dialog Helper
+export interface ConfirmDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description: string;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ variant?: "default" | "destructive";
+ onConfirm: () => void;
+}
+
+export function ConfirmDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ confirmLabel = "Confirm",
+ cancelLabel = "Cancel",
+ variant = "default",
+ onConfirm,
+}: ConfirmDialogProps) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/ui/empty-state.tsx b/apps/web/components/ui/empty-state.tsx
new file mode 100644
index 0000000..86deeb9
--- /dev/null
+++ b/apps/web/components/ui/empty-state.tsx
@@ -0,0 +1,103 @@
+import { cn } from "@/lib/utils";
+import { Button } from "./button";
+
+export interface EmptyStateProps {
+ icon?: React.ReactNode;
+ title: string;
+ description?: string;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
+ className?: string;
+}
+
+export function EmptyState({
+ icon,
+ title,
+ description,
+ action,
+ className,
+}: EmptyStateProps) {
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+
{title}
+ {description && (
+
+ {description}
+
+ )}
+ {action && (
+
+ )}
+
+ );
+}
+
+// Preset empty states
+export function NoWorkspacesEmpty({ onCreate }: { onCreate: () => void }) {
+ return (
+
🚀}
+ title="No workspaces yet"
+ description="Create your first cloud workspace and start coding in seconds. Choose your preferred setup and we'll handle the rest."
+ action={{
+ label: "Create Workspace",
+ onClick: onCreate,
+ }}
+ />
+ );
+}
+
+export function NoResultsEmpty() {
+ return (
+ 🔍}
+ title="No results found"
+ description="We couldn't find anything matching your search. Try adjusting your filters or search terms."
+ />
+ );
+}
+
+export function ErrorStateEmpty({ onRetry }: { onRetry?: () => void }) {
+ return (
+ ❌}
+ title="Something went wrong"
+ description="We encountered an error while loading your data. Please try again or contact support if the problem persists."
+ action={
+ onRetry
+ ? {
+ label: "Try Again",
+ onClick: onRetry,
+ }
+ : undefined
+ }
+ />
+ );
+}
+
+export function ComingSoonEmpty() {
+ return (
+ 🚧}
+ title="Coming Soon"
+ description="We're working hard to bring you this feature. Stay tuned for updates!"
+ />
+ );
+}
diff --git a/apps/web/components/ui/progress.tsx b/apps/web/components/ui/progress.tsx
new file mode 100644
index 0000000..afec3ec
--- /dev/null
+++ b/apps/web/components/ui/progress.tsx
@@ -0,0 +1,126 @@
+import { cn } from "@/lib/utils";
+
+export interface ProgressProps {
+ value: number;
+ max?: number;
+ className?: string;
+ indicatorClassName?: string;
+ showLabel?: boolean;
+ variant?: "default" | "success" | "warning" | "error";
+ size?: "sm" | "md" | "lg";
+}
+
+const variantStyles = {
+ default: "bg-gradient-to-r from-primary to-secondary",
+ success: "bg-gradient-to-r from-accent to-accent/80",
+ warning: "bg-gradient-to-r from-warning to-warning/80",
+ error: "bg-gradient-to-r from-destructive to-destructive/80",
+};
+
+const sizeStyles = {
+ sm: "h-1.5",
+ md: "h-2.5",
+ lg: "h-4",
+};
+
+export function Progress({
+ value,
+ max = 100,
+ className,
+ indicatorClassName,
+ showLabel = false,
+ variant = "default",
+ size = "md",
+}: ProgressProps) {
+ const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
+
+ return (
+
+
+ {showLabel && (
+
+ {value} / {max}
+ {percentage.toFixed(0)}%
+
+ )}
+
+ );
+}
+
+export interface CircularProgressProps {
+ value: number;
+ max?: number;
+ size?: number;
+ strokeWidth?: number;
+ className?: string;
+ showLabel?: boolean;
+ variant?: "default" | "success" | "warning" | "error";
+}
+
+const circularVariantColors = {
+ default: "stroke-primary",
+ success: "stroke-accent",
+ warning: "stroke-warning",
+ error: "stroke-destructive",
+};
+
+export function CircularProgress({
+ value,
+ max = 100,
+ size = 120,
+ strokeWidth = 8,
+ className,
+ showLabel = true,
+ variant = "default",
+}: CircularProgressProps) {
+ const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
+ const radius = (size - strokeWidth) / 2;
+ const circumference = radius * 2 * Math.PI;
+ const offset = circumference - (percentage / 100) * circumference;
+
+ return (
+
+
+ {showLabel && (
+
+ {percentage.toFixed(0)}%
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/ui/skeleton.tsx b/apps/web/components/ui/skeleton.tsx
new file mode 100644
index 0000000..60734c0
--- /dev/null
+++ b/apps/web/components/ui/skeleton.tsx
@@ -0,0 +1,67 @@
+import { cn } from "@/lib/utils";
+
+export interface SkeletonProps {
+ className?: string;
+}
+
+export function Skeleton({ className }: SkeletonProps) {
+ return (
+
+ );
+}
+
+export function SkeletonCard() {
+ return (
+
+ );
+}
+
+export function SkeletonList({ count = 3 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+export function SkeletonTable({ rows = 5, cols = 4 }: { rows?: number; cols?: number }) {
+ return (
+
+
+ {Array.from({ length: cols }).map((_, i) => (
+
+ ))}
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ {Array.from({ length: cols }).map((_, j) => (
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/components/ui/toast.tsx b/apps/web/components/ui/toast.tsx
new file mode 100644
index 0000000..7def213
--- /dev/null
+++ b/apps/web/components/ui/toast.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+import { X } from "lucide-react";
+
+export interface ToastProps {
+ id: string;
+ title?: string;
+ description?: string;
+ variant?: "default" | "success" | "error" | "warning" | "info";
+ duration?: number;
+ onClose: (id: string) => void;
+}
+
+const variantStyles = {
+ default: "bg-card border-border",
+ success: "bg-card border-accent text-accent",
+ error: "bg-card border-destructive text-destructive",
+ warning: "bg-card border-warning text-warning",
+ info: "bg-card border-info text-info",
+};
+
+const variantIcons = {
+ default: "ℹ️",
+ success: "✅",
+ error: "❌",
+ warning: "⚠️",
+ info: "💡",
+};
+
+export function Toast({
+ id,
+ title,
+ description,
+ variant = "default",
+ onClose,
+}: ToastProps) {
+ return (
+
+
+
{variantIcons[variant]}
+
+ {title &&
{title}
}
+ {description && (
+
{description}
+ )}
+
+
+
+
+ );
+}
+
+export function ToastContainer({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// Toast manager hook
+type ToastType = Omit;
+
+export function useToast() {
+ const [toasts, setToasts] = React.useState([]);
+ const timeoutsRef = React.useRef