diff --git a/.env.local b/.env.local index 2d9a3770..82da3629 100644 --- a/.env.local +++ b/.env.local @@ -7,6 +7,7 @@ SAGITTARIUS_CABLE_URL=http://localhost:80/cable NEXT_PUBLIC_SCULPTOR_VERSION=0.0.0 NEXT_PUBLIC_PICTOR_VERSION=0.15.0 NEXT_PUBLIC_ALLOWED_REDIRECT_DOMAINS=*.code0.tech,*.codezero.build +NEXT_PUBLIC_SUBSCRIPTION_URL=https://codezero.build/subscription NEXT_PUBLIC_OTEL_SERVICE_NAME=#"sculptor-client" NEXT_PUBLIC_OTEL_HEADER=#"Authorization: Basic " diff --git a/src/app/(dashboard)/@modal/(.)upgrade/page.tsx b/src/app/(dashboard)/@modal/(.)upgrade/page.tsx new file mode 100644 index 00000000..8886feb5 --- /dev/null +++ b/src/app/(dashboard)/@modal/(.)upgrade/page.tsx @@ -0,0 +1,3 @@ +import {UpgradePage} from "@edition/license/pages/UpgradePage"; + +export default UpgradePage diff --git a/src/app/(dashboard)/@sidebar/namespace/[namespaceId]/page.tsx b/src/app/(dashboard)/@sidebar/namespace/[namespaceId]/page.tsx index e272971f..7edea2db 100644 --- a/src/app/(dashboard)/@sidebar/namespace/[namespaceId]/page.tsx +++ b/src/app/(dashboard)/@sidebar/namespace/[namespaceId]/page.tsx @@ -3,6 +3,7 @@ import {ProjectListView} from "@edition/project/views/ProjectListView"; import {FlowRecentListView} from "@edition/flow/views/FlowRecentListView"; import {NamespaceMenuView} from "@edition/namespace/views/NamespaceMenuView"; +import {NamespaceUpgradeView} from "@edition/namespace/views/NamespaceUpgradeView"; import {Flex} from "@code0-tech/pictor"; export default () => { @@ -10,8 +11,9 @@ export default () => {
-
+ + -
+ } diff --git a/src/app/(dashboard)/upgrade/page.tsx b/src/app/(dashboard)/upgrade/page.tsx new file mode 100644 index 00000000..9bbda15b --- /dev/null +++ b/src/app/(dashboard)/upgrade/page.tsx @@ -0,0 +1,5 @@ +import {UpgradePage} from "@edition/license/pages/UpgradePage"; + +export default function Page() { + return +} diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts index c18c1036..d35896c0 100644 --- a/src/app/api/config/route.ts +++ b/src/app/api/config/route.ts @@ -12,7 +12,7 @@ export const dynamic = "force-dynamic" */ export function GET() { - const config: ClientOtelConfig = { + const config: ClientOtelConfig & { subscriptionUrl: string | null } = { tracesEndpoint: process.env.NEXT_PUBLIC_OTEL_TRACES_ENDPOINT ?? null, logsEndpoint: process.env.NEXT_PUBLIC_OTEL_LOGS_ENDPOINT ?? null, header: process.env.NEXT_PUBLIC_OTEL_HEADER ?? null, @@ -20,6 +20,7 @@ export function GET() { environment: process.env.NEXT_PUBLIC_OTEL_ENVIRONMENT ?? null, version: process.env.NEXT_PUBLIC_SCULPTOR_VERSION ?? "0.0.0", edition: process.env.NEXT_PUBLIC_EDITION ?? "edition", + subscriptionUrl: process.env.NEXT_PUBLIC_SUBSCRIPTION_URL ?? null, } return NextResponse.json(config, { diff --git a/src/packages/ce/src/license/pages/UpgradePage.tsx b/src/packages/ce/src/license/pages/UpgradePage.tsx new file mode 100644 index 00000000..99d31d05 --- /dev/null +++ b/src/packages/ce/src/license/pages/UpgradePage.tsx @@ -0,0 +1,8 @@ +"use client" + +import React from "react"; +import {notFound} from "next/navigation"; + +export const UpgradePage: React.FC = () => { + return notFound() +} diff --git a/src/packages/ce/src/namespace/components/NamespaceCardComponent.tsx b/src/packages/ce/src/namespace/components/NamespaceCardComponent.tsx new file mode 100644 index 00000000..b2af46e5 --- /dev/null +++ b/src/packages/ce/src/namespace/components/NamespaceCardComponent.tsx @@ -0,0 +1,59 @@ +"use client" + +import React from "react"; +import {Avatar, Badge, Card, Flex, hashToColor, Text, useService} from "@code0-tech/pictor"; +import {IconFolders, IconUsers} from "@tabler/icons-react"; +import Link from "next/link"; +import {Namespace} from "@code0-tech/sagittarius-graphql-types"; +import {OrganizationService} from "@edition/organization/services/Organization.service"; +import {UserService} from "@edition/user/services/User.service"; +import {getNamespaceName} from "@edition/namespace/util/Namespace.name.util"; + +export interface NamespaceCardComponentProps { + namespace: Namespace +} + +export const NamespaceCardComponent: React.FC = (props) => { + + const {namespace} = props + + const organizationService = useService(OrganizationService) + const userService = useService(UserService) + + const number = namespace.id?.match(/Namespace\/(\d+)$/)?.[1] + const name = getNamespaceName(namespace, organizationService, userService) ?? "" + const isPersonal = namespace.parent?.__typename === "User" + const user = namespace.parent?.__typename === "User" + ? userService.getById(namespace.parent.id) : undefined + + return + + + + {isPersonal + ? + : } + + {name} + {isPersonal && Personal} + + + + + + + {namespace.projects?.count ?? 0} projects + + + + + + {namespace.members?.count ?? 0} members + + + + + + +} diff --git a/src/packages/ce/src/namespace/views/NamespaceRowView.tsx b/src/packages/ce/src/namespace/views/NamespaceRowView.tsx index d9e7dbe4..da649a03 100644 --- a/src/packages/ce/src/namespace/views/NamespaceRowView.tsx +++ b/src/packages/ce/src/namespace/views/NamespaceRowView.tsx @@ -2,14 +2,11 @@ import React from "react"; import { - Avatar, Badge, Button, ButtonGroup, - Card, Col, Flex, - hashToColor, Menu, MenuContent, MenuItem, @@ -26,9 +23,7 @@ import { IconAdjustmentsHorizontal, IconArrowsSort, IconCheck, - IconFolders, - IconPlus, - IconUsers + IconPlus } from "@tabler/icons-react"; import Link from "next/link"; import {Namespace} from "@code0-tech/sagittarius-graphql-types"; @@ -37,6 +32,7 @@ import {OrganizationService} from "@edition/organization/services/Organization.s import {UserService} from "@edition/user/services/User.service"; import {useUserSession} from "@edition/user/hooks/User.session.hook"; import {getNamespaceName} from "@edition/namespace/util/Namespace.name.util"; +import {NamespaceCardComponent} from "@edition/namespace/components/NamespaceCardComponent"; const filterLabels = { all: "All", @@ -159,47 +155,11 @@ export const NamespaceRowView: React.FC = () => { {/* ── The grid users choose from; create sits in the same grid ── */} - {visibleNamespaces.map(namespace => { - const number = namespace.id?.match(/Namespace\/(\d+)$/)?.[1] - const name = getNamespaceName(namespace, organizationService, userService) ?? "" - const isPersonal = namespace.parent?.__typename === "User" - const user = namespace.parent?.__typename === "User" - ? userService.getById(namespace.parent.id) : undefined - return - - - - {/* identity: avatar (user avatar for personal, identicon for org), name and personal marker */} - - {isPersonal - ? - : } - - {name} - {isPersonal && Personal} - - - {/* metadata: one calm, labelled line */} - - - - - {namespace.projects?.count ?? 0} projects - - - - - - {namespace.members?.count ?? 0} members - - - - - - + {visibleNamespaces.map(namespace => ( + + - })} + ))} {/* create-workspace affordance, matching card footprint */} diff --git a/src/packages/ce/src/namespace/views/NamespaceUpgradeView.tsx b/src/packages/ce/src/namespace/views/NamespaceUpgradeView.tsx new file mode 100644 index 00000000..5dce4050 --- /dev/null +++ b/src/packages/ce/src/namespace/views/NamespaceUpgradeView.tsx @@ -0,0 +1,3 @@ +import React from "react"; + +export const NamespaceUpgradeView: React.FC = () => null diff --git a/src/packages/cloud/src/license/components/UpgradeButtonComponent.tsx b/src/packages/cloud/src/license/components/UpgradeButtonComponent.tsx new file mode 100644 index 00000000..5641ba00 --- /dev/null +++ b/src/packages/cloud/src/license/components/UpgradeButtonComponent.tsx @@ -0,0 +1,65 @@ +"use client" + +import React from "react"; +import {Button, useService} from "@code0-tech/pictor"; +import Link from "next/link"; +import {usePathname, useSearchParams} from "next/navigation"; +import BorderBeam from "border-beam"; +import {UserService} from "@edition/user/services/User.service"; + +export interface UpgradeButtonComponentProps { + children?: React.ReactNode + namespaceId?: string | number + fullWidth?: boolean + paddingSize?: React.ComponentProps["paddingSize"] + color?: React.ComponentProps["color"] + beamSize?: React.ComponentProps["size"] +} + +export const UpgradeButtonComponent: React.FC = ({children, namespaceId, fullWidth, paddingSize, color = "tertiary", beamSize = "sm"}) => { + + const userService = useService(UserService) + const pathname = usePathname() + const searchParams = useSearchParams() + const [pending, startTransition] = React.useTransition() + + const isSubscription = pathname === "/upgrade" + const namespace = namespaceId?.toString() ?? searchParams.get("namespace") + const href = namespace ? `/upgrade?namespace=${namespace}` : "/upgrade" + + const display = fullWidth ? "block" : "inline-block" + + const openSubscription = () => { + const target = window.open("about:blank", "_blank") + startTransition(async () => { + const [config, tokenPayload] = await Promise.all([ + fetch("/api/config").then(response => response.json()), + userService.usersCreateCraterToken() + ]) + const subscriptionUrl = config?.subscriptionUrl as string | null | undefined + const token = tokenPayload?.token?.token + if (!subscriptionUrl || !token) { + target?.close() + return + } + const url = new URL(subscriptionUrl) + if (namespace) url.searchParams.set("namespace", namespace) + url.searchParams.set("token", token) + if (target) target.location.href = url.toString() + }) + } + + const inner = + + + + if (isSubscription) return {inner} + + return + {inner} + +} diff --git a/src/packages/cloud/src/license/components/UpgradeDialogComponent.tsx b/src/packages/cloud/src/license/components/UpgradeDialogComponent.tsx new file mode 100644 index 00000000..c784d2c5 --- /dev/null +++ b/src/packages/cloud/src/license/components/UpgradeDialogComponent.tsx @@ -0,0 +1,122 @@ +"use client" + +import React from "react"; +import { + Card, + Col, + Dialog, + DialogContent, + DialogOverlay, + DialogPortal, + Flex, + Row, + Spacing, + Text +} from "@code0-tech/pictor"; +import {IconAdjustmentsHorizontal, IconArrowRight, IconBolt, IconHeadset, IconSparkles} from "@tabler/icons-react"; +import BorderBeam from "border-beam"; +import {UpgradeButtonComponent} from "@cloud-internal/license/components/UpgradeButtonComponent"; + +export interface UpgradeDialogComponentProps { + open?: boolean + onOpenChange?: (open: boolean) => void +} + +const benefits = [ + { + icon: IconBolt, + title: "Up to 10k executions", + text: "Run your flows without hitting the free-tier cap.", + }, + { + icon: IconSparkles, + title: "Up to 4mio AI tokens", + text: "Higher token limits for AI actions across the whole workspace.", + }, + { + icon: IconAdjustmentsHorizontal, + title: "Limits that fit you", + text: "Scale executions and AI as you grow. Pro, Max or fully custom.", + }, + { + icon: IconHeadset, + title: "Priority support", + text: "Get faster help from our team when it matters most.", + }, +] + +export const UpgradeDialogComponent: React.FC = ({open, onOpenChange}) => { + + return onOpenChange?.(open)}> + + + + + + + + + PRO + + + + or + + + + MAX + + + + + + Upgrade your workspace + + + Unlock higher limits for executions and AI usage. +
+ Pick the plan that fits. Pro, Max or fully custom. +
+
+ + + {benefits.map(benefit => { + const Icon = benefit.icon + return + + + + {benefit.title} + {benefit.text} + + + + })} + + + + + Full access starts at + + + + 8,95€ + + + /per month + + + + + + Choose your plan + + +
+
+
+} diff --git a/src/packages/cloud/src/license/pages/UpgradePage.tsx b/src/packages/cloud/src/license/pages/UpgradePage.tsx new file mode 100644 index 00000000..0be30af9 --- /dev/null +++ b/src/packages/cloud/src/license/pages/UpgradePage.tsx @@ -0,0 +1,25 @@ +"use client" + +import React from "react"; +import {useRouter} from "next/navigation"; +import {UpgradeDialogComponent} from "@cloud-internal/license/components/UpgradeDialogComponent"; + +export const UpgradePage: React.FC = () => { + + const router = useRouter() + + return { + if (open) return + + const nav = (window as unknown as { navigation?: { entries(): { url: string }[], currentEntry?: { index: number } } }).navigation + if (!nav?.entries) { + router.back() + return + } + + const index = nav.currentEntry?.index ?? 0 + if (index > 0) router.back() + else router.push("/") + }}/> +} diff --git a/src/packages/cloud/src/namespace/components/NamespaceCardComponent.tsx b/src/packages/cloud/src/namespace/components/NamespaceCardComponent.tsx new file mode 100644 index 00000000..bc4b55df --- /dev/null +++ b/src/packages/cloud/src/namespace/components/NamespaceCardComponent.tsx @@ -0,0 +1,79 @@ +"use client" + +import React from "react"; +import {Avatar, Badge, Card, Flex, hashToColor, Text, useService} from "@code0-tech/pictor"; +import {IconFolders, IconUsers} from "@tabler/icons-react"; +import {isFuture, isPast} from "date-fns"; +import Link from "next/link"; +import {Namespace} from "@code0-tech/sagittarius-graphql-types"; +import {OrganizationService} from "@edition/organization/services/Organization.service"; +import {UserService} from "@edition/user/services/User.service"; +import {getNamespaceName} from "@edition/namespace/util/Namespace.name.util"; +import {UpgradeButtonComponent} from "@cloud-internal/license/components/UpgradeButtonComponent"; + +export interface NamespaceCardComponentProps { + namespace: Namespace +} + +export const NamespaceCardComponent: React.FC = (props) => { + + const {namespace} = props + + const organizationService = useService(OrganizationService) + const userService = useService(UserService) + + const number = namespace.id?.match(/Namespace\/(\d+)$/)?.[1] + const name = getNamespaceName(namespace, organizationService, userService) ?? "" + const isPersonal = namespace.parent?.__typename === "User" + const user = namespace.parent?.__typename === "User" + ? userService.getById(namespace.parent.id) : undefined + + const hasActiveLicense = namespace.licenses?.nodes?.some(license => + !!license?.startDate && !!license?.endDate && isPast(license.startDate) && isFuture(license.endDate) + ) ?? false + + const Content = + + + + {isPersonal + ? + : } + + {name} + {isPersonal && Personal} + + + + + + + {namespace.projects?.count ?? 0} projects + + + + + + {namespace.members?.count ?? 0} members + + + + + + + + return hasActiveLicense ? (Content) : ( + + {Content} + + + Free plan, executions & AI capped + + + + + ) +} diff --git a/src/packages/cloud/src/namespace/views/NamespaceUpgradeView.tsx b/src/packages/cloud/src/namespace/views/NamespaceUpgradeView.tsx new file mode 100644 index 00000000..11c07021 --- /dev/null +++ b/src/packages/cloud/src/namespace/views/NamespaceUpgradeView.tsx @@ -0,0 +1,32 @@ +"use client" + +import React from "react"; +import {useService, useStore} from "@code0-tech/pictor"; +import {isFuture, isPast} from "date-fns"; +import {useParams} from "next/navigation"; +import {Namespace} from "@code0-tech/sagittarius-graphql-types"; +import {NamespaceService} from "@cloud-internal/namespace/services/Namespace.service"; +import {UpgradeButtonComponent} from "@cloud-internal/license/components/UpgradeButtonComponent"; + +export const NamespaceUpgradeView: React.FC = () => { + + const params = useParams() + const namespaceService = useService(NamespaceService) + const namespaceStore = useStore(NamespaceService) + + const namespaceIndex = params.namespaceId as any as number + const namespaceId: Namespace['id'] = `gid://sagittarius/Namespace/${namespaceIndex}` + + const namespace = React.useMemo( + () => namespaceService.getById(namespaceId), + [namespaceStore, namespaceId] + ) + + const hasActiveLicense = namespace?.licenses?.nodes?.some(license => + !!license?.startDate && !!license?.endDate && isPast(license.startDate) && isFuture(license.endDate) + ) ?? false + + if (hasActiveLicense) return null + + return +}