Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions ui-react/apps/console/src/components/common/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { ReactNode, useId } from "react";

export type EmptyStateAccent = "primary" | "yellow";

export interface EmptyStateFeature {
/** Sized but uncolored heroicon, e.g. `<LinkIcon className="w-5 h-5" />`. */
icon: ReactNode;
title: string;
description: string;
}

export interface EmptyStateProps {
/** Sized but uncolored heroicon, e.g. `<GlobeAltIcon className="w-8 h-8" />`. */
icon: ReactNode;
overline: string;
title: string;
description: string;
accent?: EmptyStateAccent;
features?: EmptyStateFeature[];
/** Small muted text rendered under the call-to-action. */
footnote?: ReactNode;
/** Call-to-action slot — button(s), links, RestrictedAction, etc. */
children?: ReactNode;
}

interface AccentStyles {
badge: string;
icon: string;
overline: string;
orbPrimary: string;
orbSecondary: string;
}

/**
* Accent styles. Full literal class strings (never interpolated fragments) so
* the Tailwind JIT keeps them. The hero icon inherits the badge's text color
* via `currentColor`; feature-card icons stay primary-accented in all variants.
* Typed as `Record<EmptyStateAccent, …>` so adding an accent without a matching
* entry is a compile error rather than a runtime `undefined`.
*/
const ACCENT = {
primary: {
badge: "bg-primary/10 border-primary/20 shadow-primary/5",
icon: "text-primary",
overline: "text-primary/80",
orbPrimary: "bg-primary/5",
orbSecondary: "bg-accent-blue/5",
},
yellow: {
badge: "bg-accent-yellow/10 border-accent-yellow/20 shadow-accent-yellow/5",
icon: "text-accent-yellow",
overline: "text-accent-yellow/80",
orbPrimary: "bg-accent-yellow/5",
orbSecondary: "bg-primary/5",
},
} satisfies Record<EmptyStateAccent, AccentStyles>;

/**
* Full-page onboarding / empty / gated-feature splash: a centered card over a
* full-bleed decorative background. Owns the full-bleed layout so call sites
* only declare content. Render it as the sole content of a page (inside the
* AppLayout/AdminLayout `<main>`).
*/
export default function EmptyState({
icon,
overline,
title,
description,
accent = "primary",
features,
footnote,
children,
}: EmptyStateProps) {
const headingId = useId();
const styles = ACCENT[accent];
const hasFeatures = !!features?.length;

return (
<section
aria-labelledby={headingId}
className="relative min-h-full flex items-center justify-center"
>
{/* Decorative background — bleeds past the main padding (p-8 pb-4) */}
<div
aria-hidden="true"
className="absolute inset-0 overflow-hidden pointer-events-none -mx-8 -mt-8 -mb-4"
>
<div
className={`absolute -top-32 left-1/3 w-[500px] h-[500px] rounded-full blur-[120px] animate-pulse-subtle ${styles.orbPrimary}`}
/>
<div
className={`absolute bottom-0 right-1/4 w-[400px] h-[400px] rounded-full blur-[100px] animate-pulse-subtle ${styles.orbSecondary}`}
style={{ animationDelay: "1s" }}
/>
<div className="absolute inset-0 grid-bg opacity-30" />
</div>

<div className="w-full max-w-3xl px-4 py-6 animate-fade-in">
{/* Header */}
<div className="text-center mb-10">
<div
aria-hidden="true"
className={`w-16 h-16 rounded-2xl border flex items-center justify-center mx-auto mb-6 shadow-lg ${styles.badge} ${styles.icon}`}
>
{icon}
</div>
<span
className={`inline-block text-2xs font-mono font-semibold uppercase tracking-wide mb-2 ${styles.overline}`}
>
{overline}
</span>
<h1
id={headingId}
className="text-3xl font-bold text-text-primary mb-3"
>
{title}
</h1>
<p className="text-sm text-text-muted max-w-md mx-auto leading-relaxed">
{description}
</p>
</div>

{/* Feature highlights */}
{features?.length ? (
<ul
role="list"
className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-10"
>
{features.map((feature, idx) => (
<li
key={feature.title}
className="bg-card/60 border border-border rounded-xl p-5 text-center animate-slide-up"
style={{ animationDelay: `${150 + idx * 100}ms` }}
>
<div
aria-hidden="true"
className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center mx-auto mb-3 text-primary"
>
{feature.icon}
</div>
<h2 className="text-sm font-semibold text-text-primary mb-1">
{feature.title}
</h2>
<p className="text-xs text-text-muted leading-relaxed text-balance">
{feature.description}
</p>
</li>
))}
</ul>
) : null}

{/* Call to action */}
<div
className="text-center animate-slide-up"
style={{ animationDelay: hasFeatures ? "450ms" : "200ms" }}
>
{children}
{footnote != null ? (
<p className="mt-4 text-2xs text-text-muted">{footnote}</p>
) : null}
</div>
</div>
</section>
);
}
104 changes: 23 additions & 81 deletions ui-react/apps/console/src/components/common/FeatureGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,15 @@ import {
ArrowTopRightOnSquareIcon,
} from "@heroicons/react/24/outline";
import { getConfig } from "@/env";

interface Highlight {
icon: ReactNode;
title: string;
description: string;
}
import EmptyState, {
type EmptyStateFeature,
} from "@/components/common/EmptyState";

interface FeatureGateProps {
children: ReactNode;
feature: string;
description: string;
highlights?: Highlight[];
highlights?: EmptyStateFeature[];
}

export default function FeatureGate({
Expand All @@ -29,79 +26,24 @@ export default function FeatureGate({
}

return (
<div className="relative -mx-8 -mt-8 min-h-[calc(100vh-3.5rem)] flex flex-col">
{/* Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-32 left-1/3 w-[500px] h-[500px] bg-accent-yellow/5 rounded-full blur-[120px] animate-pulse-subtle" />
<div
className="absolute bottom-0 right-1/4 w-[400px] h-[400px] bg-primary/5 rounded-full blur-[100px] animate-pulse-subtle"
style={{ animationDelay: "1s" }}
/>
<div className="absolute inset-0 grid-bg opacity-30" />
</div>

<div className="flex-1 flex items-center justify-center px-8 py-12">
<div className="w-full max-w-2xl animate-fade-in">
{/* Header */}
<div className="text-center mb-10">
<div className="w-16 h-16 rounded-2xl bg-accent-yellow/10 border border-accent-yellow/20 flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent-yellow/5">
<LockClosedIcon className="w-8 h-8 text-accent-yellow" />
</div>

<span className="inline-block text-2xs font-mono font-semibold uppercase tracking-wide text-accent-yellow/80 mb-2">
Premium Feature
</span>
<h1 className="text-3xl font-bold text-text-primary mb-3">
{feature}
</h1>
<p className="text-sm text-text-muted max-w-md mx-auto leading-relaxed">
{description}
</p>
</div>

{/* Highlights */}
{highlights && highlights.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-10">
{highlights.map((h, idx) => (
<div
key={h.title}
className="bg-card/60 border border-border rounded-xl p-5 text-center animate-slide-up"
style={{ animationDelay: `${150 + idx * 100}ms` }}
>
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center mx-auto mb-3 text-primary">
{h.icon}
</div>
<h3 className="text-sm font-semibold text-text-primary mb-1">
{h.title}
</h3>
<p className="text-xs text-text-muted leading-relaxed">
{h.description}
</p>
</div>
))}
</div>
)}

{/* CTA */}
<div
className="text-center animate-slide-up"
style={{ animationDelay: `${highlights ? 450 : 200}ms` }}
>
<a
href="https://www.shellhub.io/pricing"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-6 py-3 bg-primary hover:bg-primary-600 text-white rounded-lg text-sm font-semibold transition-all duration-200 shadow-lg shadow-primary/20"
>
Pricing
<ArrowTopRightOnSquareIcon className="w-4 h-4" strokeWidth={2} />
</a>
<p className="mt-4 text-2xs text-text-muted">
Available on ShellHub Cloud and Enterprise editions.
</p>
</div>
</div>
</div>
</div>
<EmptyState
accent="yellow"
icon={<LockClosedIcon className="w-8 h-8" />}
overline="Premium Feature"
title={feature}
description={description}
features={highlights}
footnote="Available on ShellHub Cloud and Enterprise editions."
>
<a
href="https://www.shellhub.io/pricing"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-6 py-3 bg-primary hover:bg-primary-600 text-white rounded-lg text-sm font-semibold transition-all duration-200 shadow-lg shadow-primary/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Pricing
<ArrowTopRightOnSquareIcon className="w-4 h-4" strokeWidth={2} />
</a>
</EmptyState>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default function PageLoader({
padding = "md",
}: PageLoaderProps) {
const resolvedSize = size ?? (showLabel ? "md" : "lg");
const wrapper = ["flex items-center justify-center", PADDING[padding]]
const wrapper = ["flex h-full items-center justify-center", PADDING[padding]]
.filter(Boolean)
.join(" ");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,9 @@ const steps = [

export default function WelcomeScreen({ namespaceName }: WelcomeScreenProps) {
return (
<div className="-m-8 flex-1 relative overflow-hidden">
<div className="min-h-full relative overflow-hidden px-4 pb-12">
{/* Hero */}
<div className="relative px-8 pt-16 pb-12 overflow-hidden">
<div className="relative pt-16 pb-12 overflow-hidden">
<ConnectionGrid />
<div className="absolute inset-0 bg-gradient-radial from-primary/10 via-transparent to-transparent" />
<div className="absolute top-10 left-1/4 w-96 h-96 bg-primary/8 rounded-full blur-3xl" />
Expand Down Expand Up @@ -174,7 +174,7 @@ export default function WelcomeScreen({ namespaceName }: WelcomeScreenProps) {
</div>

{/* Steps */}
<div className="px-8 pb-12">
<>
<ol className="grid grid-cols-1 md:grid-cols-3 gap-4 max-w-4xl mx-auto">
{steps.map((step, idx) => (
<li
Expand Down Expand Up @@ -249,7 +249,7 @@ export default function WelcomeScreen({ namespaceName }: WelcomeScreenProps) {
Community
</a>
</div>
</div>
</>
</div>
);
}
Loading
Loading