mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: refactor <Paywall /> component to be universal (#21740)
During development of #21659 I approved some `<Paywall />` code that had an extensive props system, however, I wasn't a huge fan of this. This approach attempts to take it further like something `shadcn` would, where-in we define the `<Paywall />` (and its subset of components) and we wrap around those when needed for `<PaywallAIGovernance />` and `<PaywallPremium />`. Theoretically there is no real CSS/Design changes here. However screenshot for prosperity. | Previously | Now | | --- | --- | | <img width="2306" height="614" alt="CleanShot 2026-01-29 at 10 56 05@2x" src="https://github.com/user-attachments/assets/83a4aa1b-da74-459d-ae11-fae06c1a8371" /> | <img width="2308" height="622" alt="CleanShot 2026-01-29 at 10 55 05@2x" src="https://github.com/user-attachments/assets/4aa43b09-6705-4af3-86cc-edc0c08e53b1" /> | --------- Co-authored-by: Ben Potter <me@bpmct.net> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Ben Potter
Claude Opus 4.5
parent
036ed5672f
commit
e45635aab6
@@ -1,4 +1,5 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
type HTMLAttributes,
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
const styles = {
|
||||
badge: {
|
||||
@@ -145,18 +147,15 @@ export const PremiumBadge: FC<PremiumBadgeProps> = ({
|
||||
children = "Premium",
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
css={[
|
||||
styles.badge,
|
||||
(theme) => ({
|
||||
backgroundColor: theme.branding.premium.background,
|
||||
border: `1px solid ${theme.branding.premium.border}`,
|
||||
color: theme.branding.premium.text,
|
||||
}),
|
||||
]}
|
||||
<Badge
|
||||
size="sm"
|
||||
className={cn(
|
||||
"bg-surface-purple border border-solid border-border-purple uppercase",
|
||||
"tracking-[0.085em] text-white rounded-full px-3 font-semibold",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import type { Paywall } from "./Paywall";
|
||||
|
||||
// Centralized AI Bridge paywall configuration to avoid duplication.
|
||||
export const aiBridgePaywallConfig: Omit<ComponentProps<typeof Paywall>, ""> = {
|
||||
message: "AI Bridge",
|
||||
description:
|
||||
"AI Bridge provides auditable visibility into user prompts and LLM tool calls from developer tools within Coder Workspaces. AI Bridge requires a Premium license with AI Governance add-on.",
|
||||
documentationLink: "https://coder.com/docs/ai-coder/ai-governance",
|
||||
documentationLinkText: "Learn about AI Governance",
|
||||
badgeText: "AI Governance",
|
||||
ctaText: "Contact Sales",
|
||||
ctaLink: "https://coder.com/contact",
|
||||
features: [
|
||||
{ text: "Auditable history of user prompts" },
|
||||
{ text: "Logged LLM tool invocations" },
|
||||
{ text: "Token usage and consumption visibility" },
|
||||
{ text: "Centrally-managed MCP servers" },
|
||||
{
|
||||
text: "Visit",
|
||||
link: {
|
||||
href: "https://coder.com/docs/ai-coder/ai-bridge",
|
||||
text: "AI Bridge Docs",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Paywall } from "./Paywall";
|
||||
import { PremiumBadge } from "components/Badges/Badges";
|
||||
import {
|
||||
Paywall,
|
||||
PaywallContent,
|
||||
PaywallCTA,
|
||||
PaywallDescription,
|
||||
PaywallFeature,
|
||||
PaywallFeatures,
|
||||
PaywallHeading,
|
||||
PaywallSeparator,
|
||||
PaywallStack,
|
||||
PaywallTitle,
|
||||
} from "./Paywall";
|
||||
|
||||
const meta: Meta<typeof Paywall> = {
|
||||
title: "components/Paywall",
|
||||
@@ -9,10 +21,40 @@ const meta: Meta<typeof Paywall> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Paywall>;
|
||||
|
||||
export const Premium: Story = {
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
message: "Black Lotus",
|
||||
description:
|
||||
"Adds 3 mana of any single color of your choice to your mana pool, then is discarded. Tapping this artifact can be played as an interrupt.",
|
||||
children: (
|
||||
<>
|
||||
<PaywallContent>
|
||||
<PaywallHeading>
|
||||
<PaywallTitle>Black Lotus</PaywallTitle>
|
||||
<PremiumBadge />
|
||||
</PaywallHeading>
|
||||
<PaywallDescription>
|
||||
Adds 3 mana of any single color of your choice to your mana pool,
|
||||
then is discarded. Tapping this artifact can be played as an
|
||||
interrupt.
|
||||
</PaywallDescription>
|
||||
</PaywallContent>
|
||||
<PaywallSeparator />
|
||||
<PaywallStack>
|
||||
<PaywallFeatures>
|
||||
<PaywallFeature>
|
||||
High availability & workspace proxies
|
||||
</PaywallFeature>
|
||||
<PaywallFeature>
|
||||
Multi-org & role-based access control
|
||||
</PaywallFeature>
|
||||
<PaywallFeature>24x7 global support with SLA</PaywallFeature>
|
||||
<PaywallFeature>
|
||||
Unlimited Git & external auth integrations
|
||||
</PaywallFeature>
|
||||
</PaywallFeatures>
|
||||
<PaywallCTA href="https://coder.com/pricing#compare-plans">
|
||||
Learn about Premium
|
||||
</PaywallCTA>
|
||||
</PaywallStack>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,134 +1,192 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import Link from "@mui/material/Link";
|
||||
import { PremiumBadge } from "components/Badges/Badges";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { CircleCheckBigIcon } from "lucide-react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import type React from "react";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
interface PaywallProps {
|
||||
message: string;
|
||||
description?: ReactNode;
|
||||
documentationLink?: string;
|
||||
documentationLinkText?: string;
|
||||
features?: Array<{
|
||||
text: string;
|
||||
link?: { href: string; text: string };
|
||||
}>;
|
||||
badgeText?: string;
|
||||
ctaText?: string;
|
||||
ctaLink?: string;
|
||||
}
|
||||
|
||||
export const Paywall: FC<PaywallProps> = ({
|
||||
message,
|
||||
description,
|
||||
documentationLink,
|
||||
documentationLinkText = "Read the documentation",
|
||||
features,
|
||||
badgeText = "Premium",
|
||||
ctaText = "Learn about Premium",
|
||||
ctaLink = "https://coder.com/pricing#compare-plans",
|
||||
}) => {
|
||||
const defaultFeatures: Array<{
|
||||
text: string;
|
||||
link?: { href: string; text: string };
|
||||
}> = [
|
||||
{ text: "High availability & workspace proxies" },
|
||||
{ text: "Multi-org & role-based access control" },
|
||||
{ text: "24x7 global support with SLA" },
|
||||
{ text: "Unlimited Git & external auth integrations" },
|
||||
];
|
||||
|
||||
const displayFeatures = features ?? defaultFeatures;
|
||||
export const Paywall = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) => {
|
||||
return (
|
||||
<div css={styles.root}>
|
||||
<div>
|
||||
<Stack direction="row" alignItems="center" className="mb-6">
|
||||
<h5 className="font-semibold font-inherit text-xl m-0">{message}</h5>
|
||||
<PremiumBadge>{badgeText}</PremiumBadge>
|
||||
</Stack>
|
||||
|
||||
{description && (
|
||||
<p className="font-inherit max-w-md text-sm mb-4">{description}</p>
|
||||
)}
|
||||
{documentationLink && (
|
||||
<Link
|
||||
href={documentationLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-semibold"
|
||||
>
|
||||
{documentationLinkText}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-px h-[220px] bg-highlight-purple/50 ml-2" />
|
||||
<Stack direction="column" alignItems="left" spacing={3}>
|
||||
<ul className="m-0 px-6 text-sm font-medium">
|
||||
{displayFeatures.map((feature, index) => (
|
||||
<li key={index} css={styles.feature}>
|
||||
<FeatureIcon />
|
||||
{feature.text}
|
||||
{feature.link && (
|
||||
<>
|
||||
{" "}
|
||||
<Link
|
||||
href={feature.link.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-semibold text-sm"
|
||||
>
|
||||
{feature.link.text}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="px-7">
|
||||
<Button asChild>
|
||||
<a href={ctaLink} target="_blank" rel="noreferrer">
|
||||
{ctaText}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center justify-center min-h-[280px] p-4 rounded-lg gap-8",
|
||||
"border border-solid border-border-purple bg-[linear-gradient(160deg,transparent,hsl(var(--surface-purple)))]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeatureIcon: FC = () => {
|
||||
export const PaywallContent: FC<React.ComponentProps<"div">> = ({
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return <div {...props}>{children}</div>;
|
||||
};
|
||||
|
||||
export const PaywallHeading: FC<React.ComponentProps<"div">> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<CircleCheckBigIcon
|
||||
aria-hidden="true"
|
||||
className="size-icon-sm"
|
||||
css={[
|
||||
(theme) => ({
|
||||
color: theme.branding.premium.border,
|
||||
}),
|
||||
]}
|
||||
<div
|
||||
className={cn("flex flex-row gap-4 items-center mb-6", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PaywallTitle: FC<React.ComponentProps<"h5">> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<h5
|
||||
className={cn("font-semibold font-inherit text-xl m-0", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
};
|
||||
|
||||
export const PaywallDescription: FC<React.ComponentProps<"p">> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<p
|
||||
className={cn("font-inherit max-w-md text-sm mb-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export const PaywallDocumentationLink: FC<React.ComponentProps<"a">> = ({
|
||||
children = "Read the documentation",
|
||||
className,
|
||||
href,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn("text-content-link font-medium", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const PaywallSeparator: FC<React.ComponentProps<"hr">> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<hr
|
||||
className={cn(
|
||||
"w-0 h-[220px] border-0 border-l border-highlight-purple/50 ml-2 mr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
root: (theme) => ({
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: 280,
|
||||
padding: 24,
|
||||
borderRadius: 8,
|
||||
gap: 32,
|
||||
backgroundImage: `linear-gradient(160deg, transparent, ${theme.branding.premium.background})`,
|
||||
border: `1px solid ${theme.branding.premium.border}`,
|
||||
}),
|
||||
feature: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: 3,
|
||||
gap: 8,
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
export const PaywallStack: FC<React.ComponentProps<"div">> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col items-start gap-6", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PaywallFeatures: FC<React.ComponentProps<"ul">> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<ul
|
||||
className={cn("list-none m-0 px-6 text-sm font-medium", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export const PaywallFeature: FC<React.ComponentProps<"li">> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<li className={cn("flex items-center gap-2 p-[3px]", className)} {...props}>
|
||||
<FeatureIcon className="flex-shrink-0" />
|
||||
<span className="flex-1">{children}</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const PaywallCTA: FC<React.ComponentProps<"a">> = ({
|
||||
children,
|
||||
className,
|
||||
href,
|
||||
target = "_blank",
|
||||
rel = "noreferrer",
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Button asChild>
|
||||
<a
|
||||
href={href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
className={cn("mx-7", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const FeatureIcon: FC<React.ComponentProps<"svg">> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<CircleCheckBigIcon
|
||||
aria-hidden="true"
|
||||
className="size-icon-sm"
|
||||
css={(theme) => ({
|
||||
color: theme.branding.premium.border,
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PaywallAIGovernance } from "./PaywallAIGovernance";
|
||||
|
||||
const meta: Meta<typeof PaywallAIGovernance> = {
|
||||
title: "components/Paywall/AIGovernance",
|
||||
component: PaywallAIGovernance,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PaywallAIGovernance>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { PremiumBadge } from "components/Badges/Badges";
|
||||
import { docs } from "utils/docs";
|
||||
import {
|
||||
Paywall,
|
||||
PaywallContent,
|
||||
PaywallCTA,
|
||||
PaywallDescription,
|
||||
PaywallDocumentationLink,
|
||||
PaywallFeature,
|
||||
PaywallFeatures,
|
||||
PaywallHeading,
|
||||
PaywallSeparator,
|
||||
PaywallStack,
|
||||
PaywallTitle,
|
||||
} from "./Paywall";
|
||||
|
||||
const PaywallAIGovernance = () => {
|
||||
return (
|
||||
<Paywall>
|
||||
<PaywallContent>
|
||||
<PaywallHeading>
|
||||
<PaywallTitle>AI Bridge</PaywallTitle>
|
||||
<PremiumBadge>AI Governance</PremiumBadge>
|
||||
</PaywallHeading>
|
||||
<PaywallDescription>
|
||||
AI Bridge provides auditable visibility into user prompts and LLM tool
|
||||
calls from developer tools within Coder Workspaces. AI Bridge requires
|
||||
a Premium license with AI Governance add-on.
|
||||
</PaywallDescription>
|
||||
<PaywallDocumentationLink href={docs("/ai-coder/ai-governance")}>
|
||||
Learn about AI Governance
|
||||
</PaywallDocumentationLink>
|
||||
</PaywallContent>
|
||||
<PaywallSeparator />
|
||||
<PaywallStack>
|
||||
<PaywallFeatures>
|
||||
<PaywallFeature>Auditable history of user prompts</PaywallFeature>
|
||||
<PaywallFeature>Logged LLM tool invocations</PaywallFeature>
|
||||
<PaywallFeature>
|
||||
Token usage and consumption visibility
|
||||
</PaywallFeature>
|
||||
<PaywallFeature>Centrally-managed MCP servers</PaywallFeature>
|
||||
<PaywallFeature>
|
||||
<span>
|
||||
Visit{" "}
|
||||
<a
|
||||
href="https://coder.com/docs/ai-coder/ai-bridge"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-content-link"
|
||||
>
|
||||
AI Bridge Docs
|
||||
</a>
|
||||
</span>
|
||||
</PaywallFeature>
|
||||
</PaywallFeatures>
|
||||
<PaywallCTA href="https://coder.com/contact">Contact Sales</PaywallCTA>
|
||||
</PaywallStack>
|
||||
</Paywall>
|
||||
);
|
||||
};
|
||||
|
||||
export { PaywallAIGovernance };
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PaywallPremium } from "./PaywallPremium";
|
||||
|
||||
const meta: Meta<typeof PaywallPremium> = {
|
||||
title: "components/Paywall/Premium",
|
||||
component: PaywallPremium,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PaywallPremium>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
message: "Black Lotus",
|
||||
description:
|
||||
"Adds 3 mana of any single color of your choice to your mana pool, then is discarded. Tapping this artifact can be played as an interrupt.",
|
||||
documentationLink: "https://coder.com/docs",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { PremiumBadge } from "components/Badges/Badges";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import {
|
||||
Paywall,
|
||||
PaywallContent,
|
||||
PaywallCTA,
|
||||
PaywallDescription,
|
||||
PaywallDocumentationLink,
|
||||
PaywallFeature,
|
||||
PaywallFeatures,
|
||||
PaywallHeading,
|
||||
PaywallSeparator,
|
||||
PaywallStack,
|
||||
PaywallTitle,
|
||||
} from "./Paywall";
|
||||
|
||||
type PaywallPremiumProps = React.ComponentProps<"div"> & {
|
||||
message: string;
|
||||
description: ReactNode;
|
||||
documentationLink: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const PaywallPremium = ({
|
||||
message,
|
||||
description,
|
||||
documentationLink,
|
||||
compact = false,
|
||||
className,
|
||||
...props
|
||||
}: PaywallPremiumProps) => {
|
||||
const PREMIUM_FEATURES = [
|
||||
"High availability & workspace proxies",
|
||||
"Multi-org & role-based access control",
|
||||
"24x7 global support with SLA",
|
||||
"Unlimited Git & external auth integrations",
|
||||
];
|
||||
|
||||
return (
|
||||
<Paywall
|
||||
className={cn(
|
||||
compact && "max-w-[770px] py-4 px-[36px] gap-[18px] min-h-[230px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PaywallContent>
|
||||
<PaywallHeading className={cn(compact && "mb-[18px]")}>
|
||||
<PaywallTitle className={cn(compact && "text-lg leading-none")}>
|
||||
{message}
|
||||
</PaywallTitle>
|
||||
<PremiumBadge />
|
||||
</PaywallHeading>
|
||||
<PaywallDescription
|
||||
className={cn(
|
||||
compact &&
|
||||
"text-sm max-w-[360px] mt-2 mb-3.5 leading-relaxed text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</PaywallDescription>
|
||||
<PaywallDocumentationLink href={documentationLink}>
|
||||
Read the documentation
|
||||
</PaywallDocumentationLink>
|
||||
</PaywallContent>
|
||||
<PaywallSeparator className="h-[180px]" />
|
||||
<PaywallStack className={cn(compact && "gap-4")}>
|
||||
<PaywallFeatures className={cn(compact && "pr-0")}>
|
||||
{PREMIUM_FEATURES.map((feature) => (
|
||||
<PaywallFeature
|
||||
className={cn(compact && "text-[13px] leading-tight")}
|
||||
key={feature}
|
||||
>
|
||||
{feature}
|
||||
</PaywallFeature>
|
||||
))}
|
||||
</PaywallFeatures>
|
||||
<PaywallCTA href="https://coder.com/pricing#compare-plans">
|
||||
Learn about Premium
|
||||
</PaywallCTA>
|
||||
</PaywallStack>
|
||||
</Paywall>
|
||||
);
|
||||
};
|
||||
|
||||
export { PaywallPremium };
|
||||
@@ -14,5 +14,6 @@ export const Premium: Story = {
|
||||
message: "Black Lotus",
|
||||
description:
|
||||
"Adds 3 mana of any single color of your choice to your mana pool, then is discarded. Tapping this artifact can be played as an interrupt.",
|
||||
documentationLink: "https://coder.com/docs",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,142 +1,23 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import Link from "@mui/material/Link";
|
||||
import { PremiumBadge } from "components/Badges/Badges";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { CircleCheckBigIcon } from "lucide-react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { PaywallPremium } from "./PaywallPremium";
|
||||
|
||||
interface PopoverPaywallProps {
|
||||
message: string;
|
||||
description?: ReactNode;
|
||||
documentationLink?: string;
|
||||
badgeText?: string;
|
||||
ctaText?: string;
|
||||
ctaLink?: string;
|
||||
documentationLink: string;
|
||||
}
|
||||
|
||||
export const PopoverPaywall: FC<PopoverPaywallProps> = ({
|
||||
message,
|
||||
description,
|
||||
documentationLink,
|
||||
badgeText = "Premium",
|
||||
ctaText = "Learn about Premium",
|
||||
ctaLink = "https://coder.com/pricing#compare-plans",
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
css={[
|
||||
styles.root,
|
||||
(theme) => ({
|
||||
backgroundImage: `linear-gradient(160deg, transparent, ${theme.branding.premium.background})`,
|
||||
border: `1px solid ${theme.branding.premium.border}`,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<div>
|
||||
<Stack direction="row" alignItems="center" css={{ marginBottom: 18 }}>
|
||||
<h5 css={styles.title}>{message}</h5>
|
||||
<PremiumBadge>{badgeText}</PremiumBadge>
|
||||
</Stack>
|
||||
|
||||
{description && <p css={styles.description}>{description}</p>}
|
||||
<Link
|
||||
href={documentationLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
css={{ fontWeight: 600 }}
|
||||
>
|
||||
Read the documentation
|
||||
</Link>
|
||||
</div>
|
||||
<div css={styles.separator} />
|
||||
<Stack direction="column" alignItems="left" spacing={2}>
|
||||
<ul css={styles.featureList}>
|
||||
<li css={styles.feature}>
|
||||
<FeatureIcon /> High availability & workspace proxies
|
||||
</li>
|
||||
<li css={styles.feature}>
|
||||
<FeatureIcon /> Multi-org & role-based access control
|
||||
</li>
|
||||
<li css={styles.feature}>
|
||||
<FeatureIcon /> 24x7 global support with SLA
|
||||
</li>
|
||||
<li css={styles.feature}>
|
||||
<FeatureIcon /> Unlimited Git & external auth integrations
|
||||
</li>
|
||||
</ul>
|
||||
<div css={styles.learnButton}>
|
||||
<Button asChild>
|
||||
<a href={ctaLink} target="_blank" rel="noreferrer">
|
||||
{ctaText}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeatureIcon: FC = () => {
|
||||
return (
|
||||
<CircleCheckBigIcon
|
||||
aria-hidden="true"
|
||||
className="size-icon-sm"
|
||||
css={[
|
||||
(theme) => ({
|
||||
color: theme.branding.premium.border,
|
||||
}),
|
||||
]}
|
||||
<PaywallPremium
|
||||
message={message}
|
||||
description={description}
|
||||
documentationLink={documentationLink}
|
||||
compact={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
maxWidth: 770,
|
||||
padding: "24px 36px",
|
||||
borderRadius: 8,
|
||||
gap: 18,
|
||||
},
|
||||
title: {
|
||||
fontWeight: 600,
|
||||
fontFamily: "inherit",
|
||||
fontSize: 18,
|
||||
margin: 0,
|
||||
},
|
||||
description: (theme) => ({
|
||||
marginTop: 8,
|
||||
fontFamily: "inherit",
|
||||
maxWidth: 360,
|
||||
lineHeight: "160%",
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 14,
|
||||
}),
|
||||
separator: (theme) => ({
|
||||
width: 1,
|
||||
height: 180,
|
||||
backgroundColor: theme.palette.divider,
|
||||
marginLeft: 8,
|
||||
}),
|
||||
featureList: {
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
marginRight: 8,
|
||||
padding: "0 0 0 24px",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
},
|
||||
learnButton: {
|
||||
padding: "0 28px",
|
||||
},
|
||||
feature: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: 3,
|
||||
gap: 8,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
--surface-sky: 201 94% 86%;
|
||||
--surface-red: 0 93% 94%;
|
||||
--surface-purple: 251 91% 95%;
|
||||
--border-purple: 255 92% 76%;
|
||||
--border-default: 240 6% 90%;
|
||||
--border-success: 142 76% 36%;
|
||||
--border-sky: 203 90% 40%;
|
||||
@@ -74,6 +75,7 @@
|
||||
--surface-sky: 204 80% 16%;
|
||||
--surface-red: 0 75% 15%;
|
||||
--surface-purple: 261 73% 23%;
|
||||
--border-purple: 255 92% 76%;
|
||||
--border-default: 240 4% 16%;
|
||||
--border-success: 142 76% 36%;
|
||||
--border-warning: 30.66, 97.16%, 72.35%;
|
||||
|
||||
@@ -3,8 +3,7 @@ import {
|
||||
PaginationContainer,
|
||||
type PaginationResult,
|
||||
} from "components/PaginationWidget/PaginationContainer";
|
||||
import { aiBridgePaywallConfig } from "components/Paywall/AIBridgePaywallConfig";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { PaywallAIGovernance } from "components/Paywall/PaywallAIGovernance";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -34,7 +33,7 @@ export const RequestLogsPageView: FC<RequestLogsPageViewProps> = ({
|
||||
filterProps,
|
||||
}) => {
|
||||
if (!isRequestLogsVisible) {
|
||||
return <Paywall {...aiBridgePaywallConfig} />;
|
||||
return <PaywallAIGovernance />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
PaginationContainer,
|
||||
type PaginationResult,
|
||||
} from "components/PaginationWidget/PaginationContainer";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { PaywallPremium } from "components/Paywall/PaywallPremium";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { Table, TableBody, TableCell, TableRow } from "components/Table/Table";
|
||||
import { TableLoader } from "components/TableLoader/TableLoader";
|
||||
@@ -130,7 +130,7 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
<Paywall
|
||||
<PaywallPremium
|
||||
message="Audit logs"
|
||||
description="Audit logs allow you to monitor user operations on your deployment. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/security/audit-logs")}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
PaginationContainer,
|
||||
type PaginationResult,
|
||||
} from "components/PaginationWidget/PaginationContainer";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { PaywallPremium } from "components/Paywall/PaywallPremium";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { Table, TableBody, TableCell, TableRow } from "components/Table/Table";
|
||||
import { TableLoader } from "components/TableLoader/TableLoader";
|
||||
@@ -125,7 +125,7 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
<Paywall
|
||||
<PaywallPremium
|
||||
message="Connection logs"
|
||||
description="Connection logs allow you to see how and when users connect to workspaces. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/monitoring/connection-logs")}
|
||||
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
import type { SerpentOption } from "api/typesGenerated";
|
||||
import { aiBridgePaywallConfig } from "components/Paywall/AIBridgePaywallConfig";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { PaywallAIGovernance } from "components/Paywall/PaywallAIGovernance";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
@@ -49,7 +48,7 @@ export const AIGovernanceSettingsPageView: FC<
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Paywall {...aiBridgePaywallConfig} />
|
||||
<PaywallAIGovernance />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { PaywallPremium } from "components/Paywall/PaywallPremium";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
@@ -78,7 +78,7 @@ const IdpOrgSyncPage: FC = () => {
|
||||
</header>
|
||||
<ChooseOne>
|
||||
<Cond condition={!isIdpSyncEnabled}>
|
||||
<Paywall
|
||||
<PaywallPremium
|
||||
message="IdP Organization Sync"
|
||||
description="Configure organization mappings to synchronize claims in your auth provider to organizations within Coder. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/users/idp-sync")}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Badge } from "components/Badge/Badge";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { PaywallPremium } from "components/Paywall/PaywallPremium";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -44,7 +44,7 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
return (
|
||||
<ChooseOne>
|
||||
<Cond condition={!groupsEnabled}>
|
||||
<Paywall
|
||||
<PaywallPremium
|
||||
message="Groups"
|
||||
description="Organize users into groups with restricted access to templates. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/users/groups-roles")}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Badges, PremiumBadge } from "components/Badges/Badges";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { IconField } from "components/IconField/IconField";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { PaywallPremium } from "components/Paywall/PaywallPremium";
|
||||
import { PopoverPaywall } from "components/Paywall/PopoverPaywall";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import {
|
||||
@@ -115,7 +115,7 @@ export const CreateOrganizationPageView: FC<
|
||||
<ChooseOne>
|
||||
<Cond condition={!isEntitled}>
|
||||
<div className="min-w-fit mx-auto">
|
||||
<Paywall
|
||||
<PaywallPremium
|
||||
message="Organizations"
|
||||
description="Create multiple organizations within a single Coder deployment, allowing several platform teams to operate with isolated users, templates, and distinct underlying infrastructure."
|
||||
documentationLink={docs("/admin/users/organizations")}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { PaywallPremium } from "components/Paywall/PaywallPremium";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import {
|
||||
Table,
|
||||
@@ -52,7 +52,7 @@ export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
{!isCustomRolesEnabled && (
|
||||
<Paywall
|
||||
<PaywallPremium
|
||||
message="Custom Roles"
|
||||
description="Create custom roles to grant users a tailored set of granular permissions."
|
||||
documentationLink={docs("/admin/users/groups-roles")}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { PaywallPremium } from "components/Paywall/PaywallPremium";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
|
||||
import { RequirePermission } from "modules/permissions/RequirePermission";
|
||||
@@ -130,7 +130,7 @@ const IdpSyncPage: FC = () => {
|
||||
</header>
|
||||
<ChooseOne>
|
||||
<Cond condition={!isIdpSyncEnabled}>
|
||||
<Paywall
|
||||
<PaywallPremium
|
||||
message="IdP Sync"
|
||||
description="Configure group and role mappings to manage permissions outside of Coder. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/users/idp-sync")}
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ import { Button } from "components/Button/Button";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { PaywallPremium } from "components/Paywall/PaywallPremium";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
@@ -55,7 +55,7 @@ export const OrganizationProvisionerKeysPageView: FC<
|
||||
</SettingsHeader>
|
||||
|
||||
{showPaywall ? (
|
||||
<Paywall
|
||||
<PaywallPremium
|
||||
message="Provisioners"
|
||||
description="Provisioners run your Terraform to create templates and workspaces. You need a Premium license to use this feature for multiple organizations."
|
||||
documentationLink={docs("/")}
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ import { Checkbox } from "components/Checkbox/Checkbox";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { PaywallPremium } from "components/Paywall/PaywallPremium";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
@@ -95,7 +95,7 @@ export const OrganizationProvisionersPageView: FC<
|
||||
)}
|
||||
|
||||
{showPaywall ? (
|
||||
<Paywall
|
||||
<PaywallPremium
|
||||
message="Provisioners"
|
||||
description="Provisioners run your Terraform to create templates and workspaces. You need a Premium license to use this feature for multiple organizations."
|
||||
documentationLink={docs("/")}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { setGroupRole, setUserRole, templateACL } from "api/queries/templates";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { PaywallPremium } from "components/Paywall/PaywallPremium";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import type { FC } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
@@ -28,7 +28,7 @@ const TemplatePermissionsPage: FC = () => {
|
||||
<title>{pageTitle(template.name, "Permissions")}</title>
|
||||
|
||||
{!isTemplateRBACEnabled ? (
|
||||
<Paywall
|
||||
<PaywallPremium
|
||||
message="Template permissions"
|
||||
description="Control access of templates for users and groups to templates. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/templates/template-permissions")}
|
||||
|
||||
@@ -63,6 +63,7 @@ module.exports = {
|
||||
destructive: "hsl(var(--border-destructive))",
|
||||
success: "hsl(var(--border-success))",
|
||||
hover: "hsl(var(--border-hover))",
|
||||
purple: "hsl(var(--border-purple))",
|
||||
},
|
||||
overlay: "hsla(var(--overlay-default))",
|
||||
input: "hsl(var(--input))",
|
||||
|
||||
Reference in New Issue
Block a user