Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,33 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { HoverEffect } from "./HoverEffect";
describe("HoverEffect accessibility", () => {
it("names image preview and modal external-link controls by action", async () => {
render(
<MemoryRouter>
<HoverEffect
items={[
{
name: "Analytics",
description: "View reporting snapshots.",
img: "/analytics.png",
link: {
href: "/analytics",
},
subFeatures: [{ name: "Reports" }],
},
]}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: "preview image for Analytics" }));
const externalLink = await screen.findByRole("link", { name: "open Analytics" });
expect(externalLink).toHaveAttribute("href", "/analytics");
});
});
@@ -0,0 +1,197 @@
import { AnimatePresence, motion } from "framer-motion";
import { t as i18nT } from "i18next";
import { useState } from "react";
import LinkOrAhref from "../ui/buttons/LinkOrAhref";
import clsx from "clsx";
import CheckIcon from "../ui/icons/CheckIcon";
import { Colors } from "~/application/enums/shared/Colors";
import Modal from "../ui/modals/Modal";
import XIcon from "../ui/icons/XIcon";
import ExternalLinkEmptyIcon from "../ui/icons/ExternalLinkEmptyIcon";
import { Link } from "react-router";
export interface HoverEffectItem {
name: string;
description?: string;
img?: string;
className?: string;
link?: {
text?: string;
href: string;
target?: undefined | "_blank";
};
subFeatures?: {
name: string;
description?: string;
}[];
highlight?: {
text: string;
color?: Colors;
};
disabled?: boolean;
}
export const HoverEffect = ({ items }: { items: HoverEffectItem[] }) => {
let [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [selectedImage, setSelectedImage] = useState<HoverEffectItem | null>(null);
const [showImage, setShowImage] = useState<boolean>(false);
return (
<div
className={clsx(
"grid w-full grid-cols-1",
items.length > 1 && "md:grid-cols-2",
items.length === 2 && "lg:grid-cols-2",
items.length === 3 && "lg:grid-cols-3",
items.length === 4 && "lg:grid-cols-4",
items.length > 4 && "lg:grid-cols-3"
)}
>
{items.map((item, idx) => (
<LinkOrAhref
key={idx}
to={item?.link?.href}
target={item?.link?.target}
className="group relative block h-full w-full p-2"
onMouseEnter={() => setHoveredIndex(idx)}
onMouseLeave={() => setHoveredIndex(null)}
>
<AnimatePresence>
{hoveredIndex === idx && (
<motion.span
className="absolute inset-0 block h-full w-full rounded-3xl bg-slate-200/[0.8] dark:bg-slate-800/[0.8]"
layoutId="hoverBackground" // required for the background to follow
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { duration: 0.15 },
}}
exit={{
opacity: 0,
transition: { duration: 0.15, delay: 0.2 },
}}
/>
)}
</AnimatePresence>
<div
className={clsx(
"relative h-full w-full overflow-hidden rounded-2xl border border-transparent bg-linear-to-br p-3",
item.className || "text-muted-foreground bg-slate-900/5 dark:bg-white/5"
)}
>
<div className="relative">
<div className="p-4">
<div className="mt-4 flex items-center justify-between space-x-2">
<h4 className="text-foreground font-bold tracking-wide">
<div className="flex items-center space-x-2">
<Name item={item} />
</div>
</h4>
{item.highlight && (
<div
className={clsx(
"border-border inline-flex cursor-default items-center truncate rounded-md border px-2 py-1 text-sm font-medium",
item.highlight.color &&
clsx(
item.highlight.color === Colors.GREEN && "bg-teal-500/30 text-teal-800 dark:text-teal-100",
item.highlight.color === Colors.ROSE && "bg-rose-500/30 text-rose-800 dark:text-rose-100",
item.highlight.color === Colors.VIOLET && "bg-violet-500/30 text-violet-800 dark:text-violet-100",
item.highlight.color === Colors.BLUE && "bg-blue-500/30 text-blue-800 dark:text-blue-100",
item.highlight.color === Colors.YELLOW && "bg-yellow-500/30 text-yellow-800 dark:text-yellow-100",
item.highlight.color === Colors.RED && "bg-red-500/30 text-red-800 dark:text-red-100",
item.highlight.color === Colors.ORANGE && "bg-orange-500/30 text-orange-800 dark:text-orange-100",
item.highlight.color === Colors.CYAN && "bg-cyan-500/30 text-cyan-800 dark:text-cyan-100",
item.highlight.color === Colors.INDIGO && "bg-indigo-500/30 text-indigo-800 dark:text-indigo-100",
item.highlight.color === Colors.PINK && "bg-pink-500/30 text-pink-800 dark:text-pink-100",
item.highlight.color === Colors.PURPLE && "bg-purple-500/30 text-purple-800 dark:text-purple-100",
item.highlight.color === Colors.LIME && "bg-lime-500/30 text-lime-800 dark:text-lime-100",
item.highlight.color === Colors.EMERALD && "bg-emerald-500/30 text-emerald-800 dark:text-emerald-100",
item.highlight.color === Colors.AMBER && "bg-amber-500/30 text-amber-800 dark:text-amber-100",
item.highlight.color === Colors.GRAY && "bg-gray-500/30 text-gray-800 dark:text-gray-100",
item.highlight.color === Colors.SLATE && "bg-slate-500/30 text-slate-800 dark:text-slate-100",
item.highlight.color === Colors.GRAY && "bg-gray-500/30 text-gray-800 dark:text-gray-100",
item.highlight.color === Colors.NEUTRAL && "bg-neutral-500/30 text-neutral-800 dark:text-neutral-100"
)
)}
>
{item.highlight.text}
</div>
)}
</div>
<p className="mt-4 text-sm leading-relaxed tracking-wide">{item.description}</p>
{item.img && (
<div className="hidden xl:block">
<button
type="button"
aria-label={`preview image for ${item.name}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setSelectedImage(item);
setShowImage(true);
}}
className="mt-4 block w-full"
>
<img src={item.img} alt={item.name} className={clsx("w-full rounded-lg object-cover", items.length === 1 ? "h-full" : "h-44")} />
</button>
</div>
)}
{item.subFeatures && (
<div className="mt-4 flex flex-wrap gap-2">
{item.subFeatures.map((subFeature, idx) => (
<div
key={idx}
className={clsx("text-muted-foreground bg-muted/30 border-border flex space-x-1 rounded-md border px-2 py-1 text-xs font-medium")}
>
<CheckIcon className="text-muted-foreground/50 h-4 w-4" aria-hidden="true" />
<div>{subFeature.name}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</LinkOrAhref>
))}
<Modal open={showImage} setOpen={() => setShowImage(false)}>
<div>
<div className="flex justify-between space-x-2">
<h1 className="text-2xl font-bold">{selectedImage?.name}</h1>
<div className="flex items-center space-x-4">
{selectedImage?.link && (
<div>
<Link to={selectedImage.link.href} target={selectedImage.link.target} aria-label={`open ${selectedImage.name}`}>
<div className="text-muted-foreground hover:text-foreground focus:ring-ring hover:bg-secondary rounded-md p-2 focus:ring-2 focus:ring-offset-2 focus:outline-hidden">
<ExternalLinkEmptyIcon className="h-6 w-6" aria-hidden="true" />
</div>
</Link>
</div>
)}
<button
type="button"
className="text-muted-foreground hover:text-foreground focus:ring-ring hover:bg-secondary rounded-md p-2 focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
onClick={() => setShowImage(false)}
>
<span className="sr-only">{i18nT("shared.close")}</span>
<XIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
</div>
<p className="text-muted-foreground mt-2">{selectedImage?.description}</p>
</div>
{selectedImage && <img src={selectedImage.img} alt={selectedImage.name} className="mt-4 h-full w-full rounded-lg object-cover" />}
</Modal>
</div>
);
};
function Name({ item }: { item: HoverEffectItem }) {
switch (item.name) {
default:
return item.name;
}
}
@@ -0,0 +1,73 @@
import React, { useRef } from "react";
import { t as i18nT } from "i18next";
import { motion, useScroll, useSpring, useTransform } from "framer-motion";
export const MovingLine = () => {
const transition = {
duration: 14,
ease: "easeInOut",
};
const ref = useRef<any>(null);
// Track scroll progress, lies between 0 and 1.
const { scrollYProgress } = useScroll({
target: ref,
offset: ["end end", "start start"],
});
// when scroll progress reached 1, path length becomes 0.
const pathLengthValue = useTransform(scrollYProgress, [0, 1], [1, 0]);
const PATH = "M0.5 0.980671L0.5 1566.02";
return (
<div className="mx-auto flex w-full max-w-4xl flex-row items-start space-x-10" ref={ref}>
<svg width="1" height="1567" viewBox="0 0 1 1567" fill="none" xmlns="http://www.w3.org/2000/svg" className="shrink-0">
<path d={PATH} stroke="url(#paint0_linear_207_38)" />
<defs>
<linearGradient id="paint0_linear_207_38" x1="1" y1="-102.823" x2="1" y2="1566.02" gradientUnits="userSpaceOnUse">
<stop stop-color="#3879E7" stop-opacity="0" />
<stop offset="1" stop-color="#3879E7" />
</linearGradient>
</defs>
<motion.path
// animatng pathLength value, goes from 1 to 0
style={{
pathLength: useSpring(pathLengthValue, {
stiffness: 500,
damping: 100,
}),
}}
transition={transition}
d={PATH}
stroke="var(--blue-500)"
strokeOpacity="1"
strokeLinecap={"round"}
strokeWidth="3"
/>
</svg>
<div className="flex w-full flex-col">
<Content />
<Content />
<Content />
</div>
</div>
);
};
{
/* dummy content to fill up the screen */
}
export const Content = () => {
return (
<div className="content mb-10 w-full">
<p className="text-2xl font-bold text-white">{i18nT("components.aceternity.movingLine.title")}</p>
<p className="text-base font-normal text-neutral-300">{i18nT("components.aceternity.movingLine.description")}</p>
<div className="flex w-full space-x-4">
<div className="mt-4 h-40 w-full rounded-md bg-linear-to-tr from-slate-800 to-slate-700 md:h-96" />
<div className="mt-4 h-40 w-full rounded-md bg-linear-to-tr from-slate-800 to-slate-700 md:h-96" />
</div>
</div>
);
};
@@ -0,0 +1,232 @@
"use client";
import React, { useEffect, useState } from "react";
import { cn } from "~/lib/utils";
import ButtonEvent from "../ui/buttons/ButtonEvent";
import UserAvatarBadge from "../core/users/UserAvatarBadge";
import LinkOrAhref from "../ui/buttons/LinkOrAhref";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useLocaleFormatters } from "~/locale/format";
export type InfiniteMovingCardItem = {
company?: string;
companyUrl?: string;
title?: string;
quote: string;
name: string;
personalWebsite?: string;
avatar: string;
role?: string;
createdAt?: Date;
quoteUrl?: string;
};
function Stars({ color = "text-yellow-400" }: { color?: string }) {
return (
<div className="flex items-center">
{[0, 1, 2, 3, 4].map((item) => (
<svg key={item} className={cn("ms-1 size-4", color)} aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 22 20">
<path d="M20.924 7.625a1.523 1.523 0 0 0-1.238-1.044l-5.051-.734-2.259-4.577a1.534 1.534 0 0 0-2.752 0L7.365 5.847l-5.051.734A1.535 1.535 0 0 0 1.463 9.2l3.656 3.563-.863 5.031a1.532 1.532 0 0 0 2.226 1.616L11 17.033l4.518 2.375a1.534 1.534 0 0 0 2.226-1.617l-.863-5.03L20.537 9.2a1.523 1.523 0 0 0 .387-1.575Z" />
</svg>
))}
</div>
);
}
export const InfiniteMovingCards = ({
items,
direction = "left",
speed = "fast",
pauseOnHover = true,
className,
}: {
items: InfiniteMovingCardItem[];
direction?: "left" | "right";
speed?: "fast" | "normal" | "slow";
pauseOnHover?: boolean;
className?: string;
}) => {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const containerRef = React.useRef<HTMLDivElement>(null);
const scrollerRef = React.useRef<HTMLUListElement>(null);
useEffect(() => {
addAnimation();
}, []);
const [start, setStart] = useState(false);
function addAnimation() {
if (containerRef.current && scrollerRef.current) {
const scrollerContent = Array.from(scrollerRef.current.children);
scrollerContent.forEach((item) => {
const duplicatedItem = item.cloneNode(true);
if (scrollerRef.current) {
scrollerRef.current.appendChild(duplicatedItem);
}
});
getDirection();
getSpeed();
setStart(true);
}
}
const getDirection = () => {
if (containerRef.current) {
if (direction === "left") {
containerRef.current.style.setProperty("--animation-direction", "forwards");
} else {
containerRef.current.style.setProperty("--animation-direction", "reverse");
}
}
};
const getSpeed = () => {
if (containerRef.current) {
if (speed === "fast") {
containerRef.current.style.setProperty("--animation-duration", "20s");
} else if (speed === "normal") {
containerRef.current.style.setProperty("--animation-duration", "40s");
} else {
containerRef.current.style.setProperty("--animation-duration", "100s");
}
}
};
const colors = [
"text-green-400",
"text-yellow-400",
"text-purple-400",
"text-blue-400",
"text-red-400",
"text-cyan-400",
"text-indigo-400",
"text-pink-400",
"text-rose-400",
"text-violet-400",
"text-lime-400",
"text-emerald-400",
];
return (
<div
ref={containerRef}
className={cn(
"scroller relative z-20 max-w-7xl overflow-hidden [mask-image:linear-gradient(to_right,transparent,white_20%,white_80%,transparent)]",
className
)}
>
<ul
ref={scrollerRef}
className={cn(
" flex w-max min-w-full shrink-0 flex-nowrap gap-4 py-4",
start && "animate-scroll ",
pauseOnHover && "hover:[animation-play-state:paused]"
)}
>
{items.map((testimonial, idx) => {
let randomColor = "text-yellow-400";
if (idx < colors.length) {
randomColor = colors[idx];
} else {
const randomIdx = Math.floor(Math.random() * colors.length);
randomColor = colors[randomIdx];
}
return (
<li
className={clsx(
"relative flex w-[350px] max-w-full shrink-0 flex-col justify-between rounded-2xl border border-slate-700 px-8 py-6 md:w-[300px]"
)}
style={{
background: "linear-gradient(180deg, var(--slate-800), var(--slate-900)",
}}
key={testimonial.name}
>
{testimonial.quoteUrl && (
<ButtonEvent
event={{ action: "click", category: "testimonials", label: "quote", value: testimonial.name }}
target="_blank"
rel="noreferrer"
className="absolute inset-0 z-10"
to={testimonial.quoteUrl}
/>
)}
<blockquote>
<div
aria-hidden="true"
className="user-select-none -z-1 pointer-events-none absolute -left-0.5 -top-0.5 h-[calc(100%_+_4px)] w-[calc(100%_+_4px)]"
></div>
<div className="relative z-20 mt-6 flex flex-row items-center">
<span className="flex flex-col gap-3">
{/* <span className=" text-muted-foreground text-sm font-normal leading-[1.6]">{testimonial.name}</span> */}
<div className=" -mx-1">
<Stars color={randomColor} />
</div>
<span className=" text-foreground text-sm font-black leading-[1.6]">{testimonial.title}</span>
</span>
</div>
<span className="line-clamp-6 text-sm font-normal leading-[1.6]">
{/* <ShowMoreLinesText lines="4"> */}
{testimonial.quote}
{/* </ShowMoreLinesText> */}
</span>
</blockquote>
<div className="mt-4 inline-flex items-center">
{testimonial.avatar ? (
<img alt="testimonial" src={testimonial.avatar} className="h-6 w-6 shrink-0 rounded-full object-cover object-center" />
) : (
<UserAvatarBadge avatar={undefined} className="h-6 w-6 shrink-0 rounded-full object-cover object-center" />
)}
<span className="flex grow flex-col pl-4">
<LinkOrAhref
to={testimonial.personalWebsite}
target="_blank"
rel="noreferrer"
className={clsx("title-font text-left text-xs font-medium", testimonial.personalWebsite && "hover:underline")}
// event={{ action: "click", category: "testimonial-user", label: testimonial.name, value: testimonial.personalWebsite ?? "" }}
>
{testimonial.name}
</LinkOrAhref>
<span className="text-muted-foreground flex items-center space-x-1 truncate text-sm">
{testimonial.role && <div className="truncate">{t(testimonial.role)}</div>}
{testimonial.company && (
<>
<div>@</div>
<div className="text-muted-foreground truncate text-sm">
{testimonial.companyUrl ? (
<ButtonEvent
to={testimonial.companyUrl}
target="_blank"
rel="noreferrer"
className="hover:underline"
event={{ action: "click", category: "testimonial-company", label: testimonial.company, value: testimonial.companyUrl ?? "" }}
>
{testimonial.company}
</ButtonEvent>
) : (
testimonial.company
)}
</div>
</>
)}
{testimonial.createdAt && (
<time
className="truncate"
dateTime={new Date(testimonial.createdAt).toISOString()}
title={formatters.dateTime(testimonial.createdAt, { timeStyle: "medium" })}
>
{formatters.relativeDate(testimonial.createdAt)}
</time>
)}
</span>
</span>
</div>
</li>
);
})}
</ul>
</div>
);
};
@@ -0,0 +1,76 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import TenantActivityLogToolbar from "~/components/activity/TenantActivityLogToolbar";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("~/components/ui/input/InputFilters", () => ({
default: ({ filters }: { filters: { name: string; title: string }[] }) => (
<div data-testid="input-filters">
{filters.map((filter) => (
<span key={filter.name}>{filter.title}</span>
))}
</div>
),
}));
const filters = [
{ name: "action", title: "models.log.action" },
{ name: "url", title: "models.log.url" },
{ name: "actor", title: "models.log.actor" },
];
function renderToolbar(initialEntry: string) {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<TenantActivityLogToolbar filters={filters} />
</MemoryRouter>
);
}
describe("TenantActivityLogToolbar", () => {
it("renders the provided filters", () => {
renderToolbar("/acme/settings/logs?action=create");
expect(screen.getByTestId("input-filters")).toBeInTheDocument();
expect(screen.getByText("models.log.action")).toBeInTheDocument();
expect(screen.getByText("models.log.url")).toBeInTheDocument();
expect(screen.getByText("models.log.actor")).toBeInTheDocument();
});
it("preserves active filters while replacing stale pagination with CSV export", () => {
renderToolbar("/acme/settings/logs?q=login&action=auth&url=%2Flogin&actor=Grace&page=3&pageSize=25");
const exportUrl = new URL(screen.getByRole("link", { name: "Export" }).getAttribute("href") ?? "", "https://example.test");
expect(exportUrl.pathname).toBe("/acme/settings/logs");
expect(exportUrl.searchParams.get("q")).toBe("login");
expect(exportUrl.searchParams.get("action")).toBe("auth");
expect(exportUrl.searchParams.get("url")).toBe("/login");
expect(exportUrl.searchParams.get("actor")).toBe("Grace");
expect(exportUrl.searchParams.get("pageSize")).toBe("25");
expect(exportUrl.searchParams.get("tenantId")).toBeNull();
expect(exportUrl.searchParams.get("page")).toBeNull();
expect(exportUrl.searchParams.get("export")).toBe("csv");
});
it("replaces an existing export value with csv", () => {
renderToolbar("/acme/settings/logs?export=json&actor=user-1");
const exportUrl = new URL(screen.getByRole("link", { name: "Export" }).getAttribute("href") ?? "", "https://example.test");
expect(exportUrl.pathname).toBe("/acme/settings/logs");
expect(exportUrl.searchParams.get("actor")).toBe("user-1");
expect(exportUrl.searchParams.get("export")).toBe("csv");
});
it("creates a valid tenant-relative CSV export href without existing search params", () => {
renderToolbar("/acme/settings/logs");
expect(screen.getByRole("link", { name: "Export" })).toHaveAttribute("href", "/acme/settings/logs?export=csv");
});
});
@@ -0,0 +1,34 @@
import { Link, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
import InputFilters from "~/components/ui/input/InputFilters";
import { Button } from "~/components/ui/button";
type Props = {
filters: FilterablePropertyDto[];
};
export default function TenantActivityLogToolbar({ filters }: Props) {
const { t } = useTranslation();
const location = useLocation();
const exportLabel = getFallbackTranslation(t("shared.export"), "Export");
const exportSearchParams = new URLSearchParams(location.search);
exportSearchParams.delete("page");
exportSearchParams.set("export", "csv");
const exportHref = `${location.pathname}?${exportSearchParams.toString()}`;
return (
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link to={exportHref} reloadDocument>
{exportLabel}
</Link>
</Button>
<InputFilters filters={filters} />
</div>
);
}
function getFallbackTranslation(value: string, fallback: string) {
return value === "shared.export" ? fallback : value;
}
@@ -0,0 +1,40 @@
import { vi, describe, it, expect } from "vitest";
import "@testing-library/jest-dom";
import { render } from "@testing-library/react";
import { BrowserRouter as Router } from "react-router";
import SetupSteps from "./SetupSteps";
vi.mock("react-i18next", () => ({
useTranslation: () => {
return { t: (str: string) => str };
},
}));
describe("SetupSteps component", () => {
const mockItems = [
{ title: "Step 1", description: "Description 1", path: "/step1", completed: true },
{ title: "Step 2", description: "Description 2", path: "/step2", completed: false },
{ title: "Step 3", description: "Description 3", path: "/step3", completed: false },
];
it("renders a list of setup steps with correct details", () => {
const { getAllByRole, getByText } = render(
<Router>
<SetupSteps items={mockItems} />
</Router>
);
const stepItems = getAllByRole("listitem");
expect(stepItems).toHaveLength(mockItems.length);
stepItems.forEach((stepItem, index) => {
const item = mockItems[index];
const title = getByText(item.title);
const description = getByText(item.description);
expect(title).toBeInTheDocument();
expect(description).toBeInTheDocument();
});
});
});
@@ -0,0 +1,75 @@
import { useTranslation } from "react-i18next";
import { SetupItem } from "~/application/dtos/setup/SetupItem";
import ButtonSecondary from "../ui/buttons/ButtonSecondary";
import clsx from "clsx";
import { CheckCircleIcon } from "~/components/ui/icons/vendor";
interface Props {
items: SetupItem[];
}
function CompletedIcon() {
return (
<CheckCircleIcon className="border-border h-6 w-6 rounded-full border text-gray-200" />
);
}
function IncompletedIcon() {
return (
<CheckCircleIcon className="h-6 w-6 rounded-full border border-teal-500 text-teal-500" />
);
}
function SetupStep({ item, index, total }: { item: SetupItem; index: number; total: number }) {
const { t } = useTranslation();
return (
<li key={index} className="border-border bg-background space-y-3 rounded-lg border p-4">
<div className="flex items-center space-x-2">
<div className="shrink-0">{!item.completed ? <CompletedIcon /> : <IncompletedIcon />}</div>
<div className="flex w-full justify-between">
<div className="font-medium">{item.title}</div>
<div className=" text-muted-foreground text-sm">
{index + 1}/{total}
</div>
</div>
</div>
<p className=" text-sm">{item.description}</p>
<div>
<ButtonSecondary to={item.path} className="mt-3">
{!item.completed ? t("app.sidebar.setup") : `${t("shared.goTo")} ${item.title.toLowerCase()}`} &rarr;
</ButtonSecondary>
</div>
</li>
);
}
export default function SetupSteps({ items }: Props) {
const { t } = useTranslation();
return (
<div>
<h3 className="text-foreground font-medium leading-4">{t("onboarding.gettingStarted.title")}</h3>
<ul
className={clsx(
"mt-3 grid grid-cols-1 gap-4",
items.length === 1 && "lg:grid-cols-3",
items.length === 2 && "lg:grid-cols-2",
items.length === 3 && "lg:grid-cols-3",
items.length === 4 && "lg:grid-cols-2",
items.length === 5 && "lg:grid-cols-3",
items.length === 6 && "lg:grid-cols-3",
items.length === 7 && "lg:grid-cols-4",
items.length === 8 && "lg:grid-cols-4",
items.length === 9 && "lg:grid-cols-3",
items.length === 10 && "lg:grid-cols-5",
items.length === 11 && "lg:grid-cols-4",
items.length === 12 && "lg:grid-cols-4"
)}
>
{items.map((item, index) => (
<SetupStep key={index} item={item} index={index} total={items.length} />
))}
</ul>
</div>
);
}
@@ -0,0 +1,109 @@
import React from "react";
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { BrowserRouter as Router } from "react-router";
import fs from "node:fs";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import AnalyticsOverview from "./AnalyticsOverview";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "de" } }),
}));
vi.mock("i18next", () => ({
t: (key: string) => key,
}));
vi.mock("~/utils/data/useRootData", () => ({
useRootData: () => ({ authenticated: false }),
}));
const overview = {
uniqueVisitors: 1234,
pageViews: 34,
events: 5,
liveVisitors: 1,
top: {
httpReferrers: [{ name: "Direct", count: 1234 }],
urls: [
{ name: null, count: 10 },
{ name: "/pricing", count: 9 },
{ name: "/docs", count: 4 },
],
routes: [{ name: "/portals/:portal", count: 3 }],
sources: [{ name: "newsletter", count: 6 }],
via: [{ name: "partner", count: 4 }],
medium: [{ name: "email", count: 6 }],
campaign: [{ name: "spring", count: 2 }],
user: [],
},
} as any;
function renderOverview() {
return render(
<Router>
<AnalyticsOverview overview={overview} withUsers={false} />
</Router>
);
}
describe("AnalyticsOverview", () => {
test("renders top-item tabs with accessible state and mobile-friendly sizing", () => {
renderOverview();
const pagesTab = screen.getByRole("tab", { name: "analytics.pages" });
const routesTab = screen.getByRole("tab", { name: "analytics.routes" });
const tablist = pagesTab.closest('[role="tablist"]');
expect(tablist).toHaveClass("min-w-0", "overflow-x-auto");
expect(pagesTab).toHaveClass("min-h-10", "shrink-0", "whitespace-nowrap");
expect(pagesTab).toHaveAttribute("aria-selected", "true");
expect(routesTab).toHaveAttribute("aria-selected", "false");
const panel = screen.getByRole("tabpanel", { name: "analytics.pages" });
expect(panel).toHaveAttribute("id", pagesTab.getAttribute("aria-controls"));
expect(within(panel).getByText("shared.notSet")).toBeInTheDocument();
expect(within(panel).getByText("/pricing")).toBeInTheDocument();
fireEvent.click(routesTab);
expect(routesTab).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel", { name: "analytics.routes" })).toHaveTextContent("/portals/:portal");
});
test("supports keyboard tab selection", () => {
renderOverview();
const pagesTab = screen.getByRole("tab", { name: "analytics.pages" });
fireEvent.keyDown(pagesTab, { key: "ArrowRight" });
expect(screen.getByRole("tab", { name: "analytics.routes" })).toHaveAttribute("aria-selected", "true");
});
test("formats overview numbers with the active locale", () => {
renderOverview();
expect(screen.getAllByText("1.234").length).toBeGreaterThanOrEqual(2);
});
test("keeps analytics overview free of legacy number formatters", () => {
const sourceRoot = path.resolve(process.cwd(), "apps/web/app/components/analytics");
const overviewSource = fs.readFileSync(path.join(sourceRoot, "AnalyticsOverview.tsx"), "utf8");
const chartSource = fs.readFileSync(path.join(sourceRoot, "AnalyticsOverviewChart.tsx"), "utf8");
expect(`${overviewSource}\n${chartSource}`).not.toContain("NumberUtils");
});
test("precomputes top-item max counts once per item set", () => {
const source = fs.readFileSync(path.resolve(process.cwd(), "apps/web/app/components/analytics/AnalyticsOverview.tsx"), "utf8");
expect(source).toContain("useMemo");
expect(source).toContain("const maxCount = useMemo");
expect(source).toContain('fallbackName: t("shared.notSet")');
expect(source).not.toContain('fallbackName: "?"');
expect(source).not.toContain('fallbackName: "none"');
expect(source).not.toContain("const counts = items.map");
expect(source).not.toContain("Math.max(...counts)");
});
});
@@ -0,0 +1,329 @@
import { Link } from "react-router";
import { t as i18nT } from "i18next";
import clsx from "clsx";
import { lazy, Suspense, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import UrlUtils from "~/utils/app/UrlUtils";
import { useRootData } from "~/utils/data/useRootData";
import type { AnalyticsOverviewDto } from "~/utils/helpers/.server/AnalyticsService";
import { useLocaleFormatters } from "~/locale/format";
import { Card } from "../ui/card";
import { Label } from "../ui/label";
const AnalyticsOverviewChart = lazy(() => import("./AnalyticsOverviewChart"));
function joinRootUrl(rootUrl: string | undefined, path: string) {
if (!rootUrl) {
return path;
}
return `${rootUrl.replace(/\/+$/g, "")}/${path.replace(/^\/+/g, "")}`;
}
const MetricCard = ({ label, value }: { label: string; value: string | number }) => {
const formatters = useLocaleFormatters();
return (
<Card className="group-hover:bg-secondary mx-auto h-24 gap-3 p-4">
<Label>{label}</Label>
<div className="text-2xl font-semibold tracking-normal">{formatters.number(Number(value), { maximumFractionDigits: 0 })}</div>
</Card>
);
};
const Metrics = ({ authenticated, overview, rootUrl }: { authenticated: Boolean; overview: AnalyticsOverviewDto; rootUrl?: string }) => {
const { t } = useTranslation();
return (
<dl className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{authenticated ? (
<>
<Link to={joinRootUrl(rootUrl, "visitors")} className="group">
<MetricCard label={t("analytics.uniqueVisitors")} value={overview.uniqueVisitors} />
</Link>
<Link to={joinRootUrl(rootUrl, "page-views")} className="group">
<MetricCard label={t("analytics.pageViews")} value={overview.pageViews} />
</Link>
<Link to={joinRootUrl(rootUrl, "events")} className="group">
<MetricCard label={t("analytics.events")} value={overview.events} />
</Link>
</>
) : (
<>
<MetricCard label={t("analytics.uniqueVisitors")} value={overview.uniqueVisitors} />
<MetricCard label={t("analytics.pageViews")} value={overview.pageViews} />
<MetricCard label={t("analytics.events")} value={overview.events} />
</>
)}
<div>
<MetricCard label={t("analytics.liveVisitors")} value={overview.liveVisitors} />
</div>
</dl>
);
};
interface TopItemDto {
title: string;
items: { name: string | null; count: number; image?: any }[];
viewMoreRoute?: string;
fallbackName?: string;
bgClassName?: string;
tabTitle?: string;
}
function TopItemsData({ title, items, viewMoreRoute, fallbackName, bgClassName }: TopItemDto) {
const { authenticated } = useRootData();
const formatters = useLocaleFormatters();
const maxCount = useMemo(() => Math.max(0, ...items.map((item) => item.count)), [items]);
function getWidthPercentageCss(current: { count: number }) {
const percentage = maxCount > 0 ? (current.count / maxCount) * 100 : 0;
if (percentage >= 95) {
return "w-[95%]";
} else if (percentage >= 90) {
return "w-[90%]";
} else if (percentage >= 80) {
return "w-[80%]";
} else if (percentage >= 70) {
return "w-[70%]";
} else if (percentage >= 60) {
return "w-[60%]";
} else if (percentage >= 50) {
return "w-[50%]";
} else if (percentage >= 40) {
return "w-[40%]";
} else if (percentage >= 30) {
return "w-[30%]";
} else if (percentage >= 20) {
return "w-[20%]";
} else if (percentage >= 10) {
return "w-[10%]";
} else if (percentage >= 3) {
return "w-[3%]";
}
return "w-[3%]";
}
const renderTopItems = () => {
return items.map((item, idx) => (
<div key={idx} className="flex justify-between space-x-2">
<div className="w-full truncate">
<div className="flex items-center space-x-1">
<div
className={clsx("flex items-center space-x-2 overflow-visible px-2 py-0.5 text-sm", getWidthPercentageCss(item), bgClassName ?? "bg-orange-50")}
>
{item.image !== undefined && <div className="flex-shrink-0">{item.image}</div>}
{fallbackName ? <span>{!item.name ? fallbackName : item.name}</span> : <div>{item.name}</div>}
</div>
</div>
</div>
<div className="px-2 py-0.5 text-right text-sm font-extrabold">{formatters.number(item.count, { maximumFractionDigits: 0 })}</div>
</div>
));
};
return (
<>
<div className="h-48 space-y-1 overflow-y-auto">{renderTopItems()}</div>
{viewMoreRoute && authenticated && (
<Link to={viewMoreRoute} className="text-muted-foreground hover:text-foreground/80 flex justify-center p-1 text-xs font-medium underline">
{i18nT("analytics.viewMore")}
</Link>
)}
</>
);
}
function TopItems({ tabs }: { tabs: TopItemDto[] }) {
const [selectedTab, setSelectedTab] = useState(0);
const tab = tabs[selectedTab] ?? tabs[0];
const baseId = useId();
useEffect(() => {
if (selectedTab >= tabs.length) {
setSelectedTab(0);
}
}, [selectedTab, tabs.length]);
function selectAdjacentTab(offset: number) {
setSelectedTab((current) => (current + offset + tabs.length) % tabs.length);
}
return (
<div className="border-border bg-background min-w-0 space-y-1 rounded-md border px-4 py-2 shadow-sm">
<div className="flex min-w-0 items-center justify-between gap-2">
<h4 id={`${baseId}-heading`} className="min-w-0 truncate text-sm font-bold">
{tab.title}
</h4>
{tabs.length > 1 && (
<div role="tablist" aria-labelledby={`${baseId}-heading`} className="flex min-w-0 items-center gap-1 overflow-x-auto">
{tabs.map((item, idx) => {
const selected = selectedTab === idx;
return (
<button
key={idx}
id={`${baseId}-tab-${idx}`}
type="button"
role="tab"
aria-selected={selected}
aria-controls={`${baseId}-panel`}
tabIndex={selected ? 0 : -1}
onClick={() => setSelectedTab(idx)}
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
event.preventDefault();
selectAdjacentTab(1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
selectAdjacentTab(-1);
} else if (event.key === "Home") {
event.preventDefault();
setSelectedTab(0);
} else if (event.key === "End") {
event.preventDefault();
setSelectedTab(tabs.length - 1);
}
}}
className={clsx(
"min-h-10 shrink-0 rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
selected ? "text-theme-500 hover:text-theme-600 underline" : "text-muted-foreground hover:text-foreground"
)}
>
{item.tabTitle}
</button>
);
})}
</div>
)}
</div>
<div id={`${baseId}-panel`} role={tabs.length > 1 ? "tabpanel" : undefined} aria-labelledby={tabs.length > 1 ? `${baseId}-tab-${selectedTab}` : undefined}>
<TopItemsData {...tab} />
</div>
</div>
);
}
export default function AnalyticsOverview({ overview, withUsers, rootUrl }: { overview: AnalyticsOverviewDto; withUsers: boolean; rootUrl?: string }) {
const { authenticated } = useRootData();
const { t } = useTranslation();
return (
<div className="space-y-2">
<div className="w-64"></div>
<Metrics authenticated={authenticated} overview={overview} rootUrl={rootUrl} />
{overview.charts && (
<Suspense fallback={<Card className="h-56 animate-pulse p-4" />}>
<AnalyticsOverviewChart chartData={overview.charts.pageViewsAndUniqueVisitors} pageViewsLabel={t("analytics.pageViews")} uniqueVisitorsLabel={t("analytics.uniqueVisitors")} />
</Suspense>
)}
<div className={clsx("grid gap-2", withUsers && "md:grid-cols-2 lg:grid-cols-4", !withUsers && "md:grid-cols-2 lg:grid-cols-3")}>
<TopItems
tabs={[
{
title: t("analytics.topHttpReferrers"),
items: overview.top.httpReferrers.map((item) => {
return {
image:
!item.name || item.name === "Direct" ? (
<svg
className="text-muted-foreground h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : item.name.startsWith("localhost") ? (
<svg
className="text-muted-foreground h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
) : (
<img className="h-4 w-4" src={`http://www.google.com/s2/favicons?domain=${item.name}&sz=256`} alt={item.name} />
),
name: UrlUtils.stripTrailingSlash(item.name?.replace("https://", "").replace("http://", "").replace("www.", "") ?? ""),
count: item.count,
};
}),
fallbackName: t("analytics.direct"),
bgClassName: "bg-orange-50 dark:bg-orange-900 dark:text-white",
},
]}
/>
<TopItems
tabs={[
{
title: t("analytics.topPages"),
items: overview.top.urls,
fallbackName: t("shared.notSet"),
bgClassName: "bg-emerald-50 dark:bg-emerald-900 dark:text-white",
tabTitle: t("analytics.pages"),
},
{
title: t("analytics.topRoutes"),
items: overview.top.routes,
fallbackName: t("shared.notSet"),
bgClassName: "bg-emerald-50 dark:bg-emerald-900 dark:text-white",
tabTitle: t("analytics.routes"),
},
]}
/>
<TopItems
tabs={[
{
tabTitle: t("analytics.source"),
title: t("analytics.topSources"),
items: overview.top.sources,
fallbackName: t("analytics.fallbacks.noReferrer"),
bgClassName: "bg-indigo-50 dark:bg-indigo-900 dark:text-white",
},
{
tabTitle: t("analytics.referrers"),
title: t("analytics.topReferrers"),
items: overview.top.via,
fallbackName: t("shared.notSet"),
bgClassName: "bg-teal-50 dark:bg-teal-900 dark:text-white",
},
{
tabTitle: t("analytics.medium"),
title: t("analytics.medium"),
items: overview.top.medium,
fallbackName: t("shared.notSet"),
bgClassName: "bg-indigo-50 dark:bg-indigo-900 dark:text-white",
},
{
tabTitle: t("analytics.campaigns"),
title: t("analytics.campaigns"),
items: overview.top.campaign,
fallbackName: t("shared.notSet"),
bgClassName: "bg-indigo-50 dark:bg-indigo-900 dark:text-white",
},
]}
/>
{withUsers && (
<TopItems
tabs={[
{
tabTitle: t("models.user.plural"),
title: t("models.user.plural"),
items: overview.top.user,
fallbackName: t("shared.notSet"),
bgClassName: "bg-indigo-50 dark:bg-indigo-900 dark:text-white",
},
]}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type { AnalyticsOverviewDto } from "~/utils/helpers/.server/AnalyticsService";
import { useLocaleFormatters } from "~/locale/format";
import { Card } from "../ui/card";
type AnalyticsOverviewChartProps = {
chartData: NonNullable<AnalyticsOverviewDto["charts"]>["pageViewsAndUniqueVisitors"];
pageViewsLabel: string;
uniqueVisitorsLabel: string;
};
export default function AnalyticsOverviewChart({ chartData, pageViewsLabel, uniqueVisitorsLabel }: AnalyticsOverviewChartProps) {
const formatters = useLocaleFormatters();
const valueFormatter = (value: number) => formatters.number(value, { maximumFractionDigits: 0 });
return (
<Card className="h-56 p-4">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
<YAxis tickFormatter={valueFormatter} tickLine={false} axisLine={false} fontSize={12} width={48} />
<Tooltip formatter={(value) => valueFormatter(Number(value))} />
<Legend />
<Area type="monotone" dataKey="Page views" name={pageViewsLabel} stroke="#4f46e5" fill="#4f46e5" fillOpacity={0.16} strokeWidth={2} />
<Area type="monotone" dataKey="Unique visitors" name={uniqueVisitorsLabel} stroke="#0891b2" fill="#0891b2" fillOpacity={0.16} strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</Card>
);
}
@@ -0,0 +1,57 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import AnalyticsPageViewsTable from "./AnalyticsPageViewsTable";
import { BrowserRouter as Router } from "react-router";
import { AnalyticsPageView } from "@toolctrl/db/prisma-browser";
import { vi, describe, expect, test } from "vitest";
vi.mock("react-i18next", () => ({
useTranslation: () => {
return { t: (str: string) => str, i18n: { exists: (key: string) => ["analytics.url", "analytics.viewed"].includes(key) } };
},
}));
const d = "2023-03-21";
const items = [
{ id: "1", url: "/page1", route: "/page1", createdAt: d, uniqueVisitorId: "1" },
{ id: "2", url: "/page2", route: "/page2", createdAt: d, uniqueVisitorId: "1" },
] as unknown as AnalyticsPageView[];
describe("AnalyticsPageViewsTable", () => {
test("renders table with correct headers and values", () => {
const result = render(
<Router>
<AnalyticsPageViewsTable items={items} />
</Router>
);
const table = result.container.getElementsByTagName("table")[0];
// Check number of rows
const rows = screen.getAllByRole("row");
expect(rows.length).toBe(items.length + 1); // Include header row
// Check number of columns
const headerRow = table.getElementsByTagName("thead")[0];
const headers = headerRow.getElementsByTagName("th");
expect(headers.length).toBe(2);
// Check header titles
const headerTitles = [...headers].map((header) => header.textContent);
expect(headerTitles).toContain("analytics.url");
expect(headerTitles).toContain("analytics.viewed");
// Check cell values
items.forEach((item, i) => {
const tbody = table.getElementsByTagName("tbody")[0];
const row = tbody.getElementsByTagName("tr")[i];
const cells = row.getElementsByTagName("td");
expect(cells.length).toBe(2);
expect(cells[0].textContent).toContain(item.url);
expect(cells[0].textContent).toContain(item.route);
expect(cells[1].textContent).toMatch(/21\.03\.2023|Mar 21, 2023/);
});
});
});
@@ -0,0 +1,31 @@
import { AnalyticsPageView } from "@toolctrl/db/prisma-browser";
import { useTranslation } from "react-i18next";
import DateCell from "../ui/dates/DateCell";
import TableSimple from "../ui/tables/TableSimple";
export default function AnalyticsPageViewsTable({ items }: { items: AnalyticsPageView[] }) {
const { t } = useTranslation();
return (
<TableSimple
items={items}
headers={[
{
name: "url",
title: t("analytics.url"),
className: "w-full",
value: (item) => (
<div className="flex flex-col truncate">
<div className="font-medium text-gray-800">{item.url}</div>
<div className="text-muted-foreground">{item.route}</div>
</div>
),
},
{
name: "date",
title: t("analytics.viewed"),
value: (item) => <DateCell date={item.createdAt} />,
},
]}
/>
);
}
@@ -0,0 +1,22 @@
import { ReactNode } from "react";
import CommandPalette from "../ui/commandPalettes/CommandPalette";
import SubframeAppShell from "./SubframeAppShell";
import SkipToContent from "../core/SkipToContent";
interface Props {
layout: "app" | "admin" | "docs";
children: ReactNode;
}
export default function AppLayout({ layout, children }: Props) {
// D-D0a: App-Rahmen ist jetzt Davids Subframe-Shell (SidebarWithSections + Topbar) statt der
// shadcn-Shell. Der CommandPalette-Wrapper bleibt (liefert den useCommandPalette-Kontext fuer die Topbar).
return (
<div>
<SkipToContent />
<CommandPalette key={layout} layout={layout}>
<SubframeAppShell layout={layout}>{children}</SubframeAppShell>
</CommandPalette>
</div>
);
}
@@ -0,0 +1,87 @@
import { useState } from "react";
import { useFetcher, Link } from "react-router";
import { useTranslation } from "react-i18next";
import { AlertTriangleIcon } from "~/components/ui/icons/vendor";
type Props = {
changePasswordHref: string;
};
// Phase 01 / #27 — soft-banner that surfaces after a successful login when
// the submitted password no longer satisfies the current policy. Wraps the
// existing yellow `WarningBanner` palette to stay visually consistent with
// other tenant-dashboard banners, but adds two interactive controls:
//
// - "Change password now" → deep-links to the existing
// /app/<tenant>/settings/profile page (anchored to the password
// section).
// - "Remind me in 7 days" → POSTs to a tiny resource route which sets a
// short-lived UX cookie. While that cookie is in the future the tenant
// layout omits the banner entirely.
//
// The banner also offers a local "dismiss for this page-load" close button
// so the user can hide it without committing to either action.
export default function PasswordPolicyBanner({ changePasswordHref }: Props) {
const { t } = useTranslation("auth");
const fetcher = useFetcher();
const [locallyDismissed, setLocallyDismissed] = useState(false);
if (locallyDismissed) return null;
if (fetcher.state !== "idle" && fetcher.formAction === "/api/password-policy-remind") {
// Optimistically hide while the remind cookie is being set so the user
// gets immediate feedback on click.
return null;
}
if (fetcher.data && typeof fetcher.data === "object" && (fetcher.data as { ok?: boolean }).ok === true) {
return null;
}
return (
<section
role="status"
data-testid="password-policy-banner"
className="not-prose rounded-md border border-yellow-300 bg-yellow-50"
>
<div className="rounded-md p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex">
<div className="shrink-0">
<AlertTriangleIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium leading-5 text-yellow-900">{t("passwordPolicyBanner.title")}</h3>
<div className="mt-2 text-sm leading-5 text-yellow-800">{t("passwordPolicyBanner.body")}</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:ml-3 sm:shrink-0">
<Link
to={changePasswordHref}
data-testid="password-policy-banner-change"
className="inline-flex items-center justify-center rounded-md border border-yellow-400 bg-yellow-100 px-3 py-1.5 text-xs font-semibold text-yellow-900 shadow-sm hover:bg-yellow-200 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 focus:ring-offset-yellow-50"
>
{t("passwordPolicyBanner.changeNow")}
</Link>
<fetcher.Form method="post" action="/api/password-policy-remind">
<button
type="submit"
data-testid="password-policy-banner-remind"
className="inline-flex items-center justify-center rounded-md border border-yellow-300 bg-white px-3 py-1.5 text-xs font-medium text-yellow-900 shadow-sm hover:bg-yellow-50 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 focus:ring-offset-yellow-50"
>
{t("passwordPolicyBanner.remindLater")}
</button>
</fetcher.Form>
<button
type="button"
data-testid="password-policy-banner-dismiss"
aria-label={t("passwordPolicyBanner.dismiss")}
onClick={() => setLocallyDismissed(true)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-yellow-700 hover:bg-yellow-100 focus:outline-none focus:ring-2 focus:ring-yellow-500"
>
<span aria-hidden="true">×</span>
</button>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,56 @@
import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router";
import UserDisplayUtils from "~/utils/app/UserDisplayUtils";
import { UserWithoutPassword } from "~/utils/db/users.db.server";
import UrlUtils from "~/utils/app/UrlUtils";
import { GlobeIcon } from "~/components/ui/icons/vendor";
interface Props {
user: UserWithoutPassword;
}
export default function ProfileBanner({ user }: Props) {
const { t } = useTranslation();
const params = useParams();
return (
<div className="mx-auto max-w-5xl px-4 sm:px-8">
<div className="py-2 md:flex md:items-center md:justify-between">
<div className="min-w-0 flex-1">
{/*Profile */}
<div className="flex items-center">
<Link to={UrlUtils.currentTenantUrl(params, `settings/profile`)} aria-label={t("models.user.avatarAlt")}>
{user?.avatar && <img className="hidden h-12 w-12 rounded-full sm:block" src={user?.avatar} alt={t("models.user.avatarAlt")} />}
</Link>
<div>
<div className="flex items-center">
<div>
{(() => {
if (user?.avatar) {
return <img className="h-12 w-12 rounded-sm sm:hidden" src={user?.avatar} alt={t("models.user.avatarAlt")} />;
} else {
return (
<span className="inline-flex h-12 w-12 items-center justify-center rounded-sm bg-slate-800 shadow-xl sm:hidden">
<span className="text-sm font-medium leading-none text-white">{UserDisplayUtils.avatarText(user)}</span>
</span>
);
}
})()}
</div>
<h1 className="text-foreground ml-3 text-lg font-bold leading-7 sm:truncate sm:leading-9">
{t("shared.hi")} {user?.firstName && <span>{user?.firstName} 👋!</span>}
</h1>
</div>
<dl className="flex flex-col sm:ml-3 sm:flex-row sm:flex-wrap">
<dt className="sr-only">{t("models.user.email")}</dt>
<dd className="text-muted-foreground flex items-center text-xs font-medium lowercase sm:mr-6">
<GlobeIcon className="text-muted-foreground mr-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{user?.email}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { UI_ACTION_IDS, UI_SURFACE_IDS, getSidebarSurfaceId, type UiLayout } from "~/application/ui/uiRegistry";
import SubframeAppShell from "./SubframeAppShell";
// D-D0a — Contract-Test der neuen Subframe-App-Shell: stabile Shell-Surface-/Action-data-ui-ids
// (analog zum bisherigen ShadcnSidebarLayout-Test), damit der Reachability-Kontrakt erhalten bleibt.
vi.mock("~/components/ui/commandPalettes/CommandPalette", () => ({
useCommandPalette: () => ({ toggle: vi.fn() }),
}));
vi.mock("~/modules/onboarding/components/OnboardingSession", () => ({ default: () => null }));
vi.mock("~/utils/data/useTitleData", () => ({ useTitleData: () => "Dashboard" }));
vi.mock("~/utils/data/useAppOrAdminData", () => ({
useAppOrAdminData: () => ({
user: { email: "u@example.test", firstName: "U", lastName: "Ser", avatar: null },
currentTenant: null,
myTenants: [],
entities: [],
entityGroups: [],
productAccess: {},
}),
}));
vi.mock("~/utils/data/useRootData", () => ({
useRootData: () => ({ userSession: { lightOrDarkMode: "light" }, csrf: "x", appConfiguration: {}, routing: {} }),
}));
vi.mock("../layouts/sidebars/shadcn/nav-actions", () => ({
NavActions: () => <button data-ui-id="ui.action.shell.search">Search</button>,
}));
vi.mock("~/application/sidebar/AppSidebar", () => ({ AppSidebar: () => [] }));
vi.mock("~/application/sidebar/AdminSidebar", () => ({ AdminSidebar: () => [] }));
vi.mock("~/application/sidebar/DocsSidebar", () => ({ DocsSidebar: () => [] }));
describe("SubframeAppShell UI IDs", () => {
function renderShell(layout: UiLayout) {
const router = createMemoryRouter(
[
{
path: "/",
element: (
<SubframeAppShell layout={layout}>
<section>Page content</section>
</SubframeAppShell>
),
},
],
{ initialEntries: ["/"] }
);
render(<RouterProvider router={router} />);
}
function expectNoDuplicateDataUiIds() {
const ids = [...document.querySelectorAll("[data-ui-id]")].map((element) => element.getAttribute("data-ui-id"));
expect(new Set(ids).size).toBe(ids.length);
}
it.each<UiLayout>(["app", "admin", "docs"])("renders stable %s shell surface and action IDs", (layout) => {
renderShell(layout);
expect(document.querySelector(`[data-ui-id="${UI_SURFACE_IDS.shell}"]`)).toBeInTheDocument();
expect(document.querySelector(`[data-ui-id="${UI_SURFACE_IDS.shellInset}"]`)).toBeInTheDocument();
expect(document.querySelector(`[data-ui-id="${getSidebarSurfaceId(layout)}"]`)).toBeInTheDocument();
expect(document.querySelector(`[data-ui-id="${UI_SURFACE_IDS.shellHeader}"]`)).toBeInTheDocument();
expect(document.querySelector(`[data-ui-id="${UI_SURFACE_IDS.shellBreadcrumb}"]`)).toBeInTheDocument();
expect(document.querySelector(`[data-ui-id="${UI_SURFACE_IDS.shellBreadcrumbCurrent}"]`)).toHaveTextContent("Dashboard");
expect(document.querySelector(`[data-ui-id="${UI_SURFACE_IDS.shellNavActions}"]`)).toBeInTheDocument();
expect(document.querySelector(`[data-ui-id="${UI_ACTION_IDS.sidebarTrigger}"]`)).toBeInTheDocument();
expect(document.querySelector(`[data-ui-id="${UI_ACTION_IDS.search}"]`)).toBeInTheDocument();
expect(document.querySelector(`[data-ui-id="${UI_SURFACE_IDS.shellMain}"]`)).toContainElement(screen.getByText("Page content"));
expectNoDuplicateDataUiIds();
});
});
@@ -0,0 +1,327 @@
import { useMemo, useRef, useState } from "react";
import { useFetcher, useLocation, useNavigate, useParams, useSearchParams, useSubmit } from "react-router";
import { useTranslation } from "react-i18next";
import {
FeatherBell,
FeatherLogOut,
FeatherMenu,
FeatherMoon,
FeatherSettings,
FeatherSun,
FeatherUserSearch,
} from "@subframe/core";
import * as SubframeCore from "@subframe/core";
import { SidebarWithSections } from "~/ui/components/SidebarWithSections";
import { Breadcrumbs } from "~/ui/components/Breadcrumbs";
import { SearchInput } from "~/ui/components/SearchInput";
import { IconButton } from "~/ui/components/IconButton";
import { Avatar } from "~/ui/components/Avatar";
import { DropdownMenu } from "~/ui/components/DropdownMenu";
import { useCommandPalette } from "~/components/ui/commandPalettes/CommandPalette";
import { useTitleData } from "~/utils/data/useTitleData";
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
import { useRootData } from "~/utils/data/useRootData";
import { UI_ACTION_IDS, UI_SURFACE_IDS, getSidebarSurfaceId } from "~/application/ui/uiRegistry";
import { AppSidebar } from "~/application/sidebar/AppSidebar";
import { AdminSidebar } from "~/application/sidebar/AdminSidebar";
import { DocsSidebar } from "~/application/sidebar/DocsSidebar";
import { SideBarItem } from "~/application/sidebar/SidebarItem";
import { SidebarGroupDto } from "~/application/sidebar/SidebarGroupDto";
import UrlUtils from "~/utils/app/UrlUtils";
import UserDisplayUtils from "~/utils/app/UserDisplayUtils";
import { getUserHasPermission } from "~/utils/helpers/PermissionsHelper";
import { appPath } from "~/utils/app/CanonicalUrlUtils";
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
import { DARK_MODE_IN_APP } from "~/application/Constants";
type UiLayout = "app" | "admin" | "docs";
// D-D0a — App-Shell nach Davids Subframe-Design (DefaultPageLayout: SidebarWithSections + HeaderNavigation).
// Ersetzt die shadcn-Shell. Sidebar mit funktionierendem Collapse-Toggle; Topbar = Breadcrumb + Suchfeld
// (-> Cmd+K) + Notification-Bell + User-Avatar-Dropdown (Profil/Theme/Logout + "Arbeitsbereich wechseln"
// = Tenant-Switcher). Nav-Items + Gating kommen aus der bestehenden AppSidebar-Config (getMenu).
// Shell-data-ui-ids bleiben erhalten (uiRegistry).
export default function SubframeAppShell({ children, layout, menuItems }: { children: React.ReactNode; layout: UiLayout; menuItems?: SideBarItem[] }) {
const { t, i18n } = useTranslation();
const params = useParams();
const location = useLocation();
const appData = useAppOrAdminData();
const rootData = useRootData();
const title = useTitleData() ?? "";
const commandPalette = useCommandPalette();
const fetcher = useFetcher();
const submit = useSubmit();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const mainElement = useRef<HTMLElement>(null);
const [collapsed, setCollapsed] = useState(false); // Desktop-Collapse (Davids Toggle)
const [mobileOpen, setMobileOpen] = useState(false); // Mobile-Drawer
const sidebarSurfaceId = getSidebarSurfaceId(layout);
// ---- Menü bauen (identisches Gating wie shadcn-Shell) ----
function getPath(item: SideBarItem): string {
return UrlUtils.replaceVariables(params, item.path) ?? "";
}
function normalizePath(path: string): string {
return path.length > 1 ? path.replace(/\/+$/, "") : path;
}
function pathMatchesCurrent(path: string, exact?: boolean): boolean {
if (!path) return false;
const currentPath = normalizePath(location.pathname);
const itemPath = normalizePath(path);
return exact ? currentPath === itemPath : currentPath === itemPath || currentPath.startsWith(`${itemPath}/`);
}
function isCurrent(item: SideBarItem): boolean {
return pathMatchesCurrent(getPath(item), item.exact);
}
function currentIsChild(item: SideBarItem): boolean {
return item.items?.some((child) => isCurrent(child) || currentIsChild(child)) ?? false;
}
function prepareMenuItem(item: SideBarItem): SideBarItem {
const childItems = item.isCollapsible === false ? [] : item.items?.map((child) => prepareMenuItem(child));
const prepared: SideBarItem = {
...item,
path: UrlUtils.replaceVariables(params, item.path) ?? item.path,
redirectTo: item.redirectTo ? (UrlUtils.replaceVariables(params, item.redirectTo) ?? item.redirectTo) : item.redirectTo,
items: childItems,
};
return { ...prepared, isActive: prepared.isActive || isCurrent(prepared) || currentIsChild(prepared) };
}
function allowCurrentUserType(item: SideBarItem) {
return !item.adminOnly || Boolean(appData?.user?.admin);
}
function allowCurrentTenantUserType(item: SideBarItem) {
const currentRole = "currentRole" in appData ? (appData.currentRole as TenantUserType) : null;
return !item.tenantUserTypes || (currentRole !== null && item.tenantUserTypes.includes(currentRole));
}
function checkUserRolePermissions(item: SideBarItem) {
return !item.permission || getUserHasPermission(appData, item.permission);
}
function getVisibleItem(item: SideBarItem): SideBarItem | null {
if (item.hidden === true || !allowCurrentUserType(item) || !allowCurrentTenantUserType(item)) return null;
const items = item.items?.map((child) => getVisibleItem(child)).filter((c): c is SideBarItem => c !== null) ?? [];
const hasPermission = checkUserRolePermissions(item);
if (!hasPermission && items.length === 0) return null;
return { ...item, path: hasPermission ? item.path : "", redirectTo: hasPermission ? item.redirectTo : undefined, items };
}
function getMenuItems(): SideBarItem[] {
let menu: SideBarItem[] = [];
if (menuItems) menu = menuItems;
else if (layout === "admin") menu = AdminSidebar({ t, appConfiguration: rootData.appConfiguration, routing: rootData.routing });
else if (layout === "app")
menu = AppSidebar({
t,
i18n,
tenantId: params.tenant ?? "",
entities: appData?.entities ?? [],
entityGroups: appData?.entityGroups ?? [],
appConfiguration: rootData.appConfiguration,
routing: rootData.routing,
productAccess: appData?.productAccess,
});
else if (layout === "docs") menu = DocsSidebar();
return menu.map((item) => prepareMenuItem(item));
}
const menu: SidebarGroupDto[] = useMemo(() => {
const groups: SidebarGroupDto[] = [];
getMenuItems().forEach((group) => {
const items = group.items?.map((item) => getVisibleItem(item)).filter((item): item is SideBarItem => item !== null) ?? [];
if (group.hidden === true || items.length === 0) return;
const type: SidebarGroupDto["type"] = group.isSecondary ? "secondary" : group.isQuickLink ? "quick-link" : "main";
groups.push({ id: group.id, uiIdPattern: group.uiIdPattern, uiIdParams: group.uiIdParams, title: group.title.toString(), items, type });
});
return groups.filter((g) => g.items.length > 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layout, menuItems, location.pathname, params.tenant, appData?.productAccess, appData?.entities, appData?.entityGroups]);
const navMain = menu.filter((g) => g.type === "main");
const navSecondary = menu.filter((g) => g.type === "secondary");
// ---- User + Theme-Toggle + Tenant-Switch ----
const isDarkMode = rootData.userSession?.lightOrDarkMode === "dark";
function onThemeToggle() {
const form = new FormData();
form.set("action", "toggleLightOrDarkMode");
form.set("redirect", location.pathname + "?" + searchParams.toString());
if (rootData.csrf) form.set("csrf", rootData.csrf);
fetcher.submit(form, { method: "post", action: "/preferences", preventScrollReset: true });
}
// Logout per useSubmit (nicht verschachteltes <Form>): das Radix-Dropdown schliesst beim Klick und wuerde
// ein natives Form-Submit verschlucken; useSubmit triggert die Navigation imperativ + ueberlebt das Unmount.
function onLogout() {
const form = new FormData();
if (rootData.csrf) form.set("csrf", rootData.csrf);
submit(form, { method: "post", action: "/logout" });
}
const user = appData?.user;
const currentTenant = "currentTenant" in appData ? appData.currentTenant : null;
const myTenants = appData?.myTenants ?? [];
function renderNavItem(item: SideBarItem) {
const href = item.redirectTo || item.path;
const label = item.title?.toString() ?? "";
return (
<a key={item.id ?? href ?? label} href={href || undefined} title={label} className="w-full no-underline" {...uiIdAttrs(item)}>
<SidebarWithSections.NavItem
icon={item.icon}
selected={item.isActive}
variant={collapsed ? "icon-only" : "icon-and-text"}
className={item.disabled ? "pointer-events-none opacity-50" : undefined}
>
{label}
</SidebarWithSections.NavItem>
</a>
);
}
return (
<div className="flex h-screen w-full items-stretch bg-surface-neutral-primary" data-ui-id={UI_SURFACE_IDS.shell}>
{/* Mobile-Backdrop */}
{mobileOpen ? <div className="fixed inset-0 z-40 bg-surface-neutral-overlay md:hidden" onClick={() => setMobileOpen(false)} aria-hidden /> : null}
<SidebarWithSections
data-ui-id={sidebarSurfaceId}
data-ui-layout={layout}
variant={collapsed ? "closed" : "open"}
onToggleCollapse={() => setCollapsed((v) => !v)}
className={SubframeCore.twClassNames(
"z-50 max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:transition-transform",
mobileOpen ? "max-md:translate-x-0" : "max-md:-translate-x-full"
)}
>
<div data-ui-id={`${sidebarSurfaceId}.content`} className="flex w-full flex-col gap-1">
{navMain.map((group) => (
<SidebarWithSections.NavSection
key={group.id ?? group.title}
label={group.title}
showLabel={!collapsed && !!group.title}
{...uiIdAttrs(group)}
>
{group.items.map((item) => renderNavItem(item))}
</SidebarWithSections.NavSection>
))}
{navSecondary.length > 0 ? <SidebarWithSections.NavDivider /> : null}
{navSecondary.flatMap((group) => group.items.map((item) => renderNavItem(item)))}
</div>
</SidebarWithSections>
<div className="flex h-full min-w-0 grow flex-col" data-ui-id={UI_SURFACE_IDS.shellInset}>
{/* Topbar (Davids HeaderNavigation: Breadcrumb + Suche + Bell + Avatar) */}
<header
className="flex h-14 w-full shrink-0 items-center justify-between gap-2 border-b border-solid border-border-default bg-surface-neutral-secondary pl-6 pr-3 py-3 mobile:px-4"
data-ui-id={UI_SURFACE_IDS.shellHeader}
>
<div className="flex min-w-0 items-center gap-3" data-ui-id={UI_SURFACE_IDS.shellBreadcrumb}>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-icon-neutral-secondary hover:bg-surface-neutral-tertiary md:hidden"
onClick={() => setMobileOpen((v) => !v)}
aria-label={t("shared.menu")}
data-ui-id={UI_ACTION_IDS.sidebarTrigger}
>
<FeatherMenu />
</button>
<Breadcrumbs>
{currentTenant ? (
<>
<Breadcrumbs.Item>{currentTenant.name}</Breadcrumbs.Item>
<Breadcrumbs.Divider />
</>
) : null}
<Breadcrumbs.Item active data-ui-id={UI_SURFACE_IDS.shellBreadcrumbCurrent}>
{title || t("app.sidebar.dashboard")}
</Breadcrumbs.Item>
</Breadcrumbs>
</div>
<div className="flex items-center gap-2 self-stretch pl-1" data-ui-id={UI_SURFACE_IDS.shellNavActions}>
<button
type="button"
className="hidden md:flex md:min-w-[192px] md:max-w-[320px] md:grow"
onClick={() => commandPalette.toggle()}
aria-label={t("shared.search")}
data-ui-id={UI_ACTION_IDS.search}
>
<SearchInput className="pointer-events-none h-auto w-full" size="small" placeholder={t("shared.searchPlaceholder")} />
</button>
<a href={params.tenant ? UrlUtils.currentTenantUrl(params, "notifications") : "#"} aria-label={t("app.sidebar.notifications", { defaultValue: "Notifications" })} data-ui-id={UI_ACTION_IDS.notifications}>
<IconButton variant="ghost" size="small" icon={<FeatherBell />} />
</a>
{user ? (
<SubframeCore.DropdownMenu.Root>
<SubframeCore.DropdownMenu.Trigger asChild>
<Avatar className="cursor-pointer" border image={user.avatar ?? undefined} data-ui-id={UI_ACTION_IDS.more}>
{UserDisplayUtils.avatarText(user)}
</Avatar>
</SubframeCore.DropdownMenu.Trigger>
<SubframeCore.DropdownMenu.Portal>
<SubframeCore.DropdownMenu.Content side="bottom" align="end" sideOffset={4} asChild>
<DropdownMenu className="min-w-56">
<DropdownMenu.DropdownUserItem title={UserDisplayUtils.fullName(user)} subtitle={user.email} image={user.avatar ?? undefined} />
<DropdownMenu.DropdownDivider />
{layout === "app" && params.tenant ? (
<DropdownMenu.DropdownItem
icon={<FeatherSettings />}
onClick={() => navigate(UrlUtils.currentTenantUrl(params, "settings/profile"))}
data-ui-id={UI_ACTION_IDS.profile}
>
{t("app.navbar.profile")}
</DropdownMenu.DropdownItem>
) : null}
{DARK_MODE_IN_APP ? (
<DropdownMenu.DropdownItem icon={isDarkMode ? <FeatherSun /> : <FeatherMoon />} onClick={onThemeToggle}>
{isDarkMode ? t("settings.profile.session.switchToLightMode") : t("settings.profile.session.switchToDarkMode")}
</DropdownMenu.DropdownItem>
) : null}
<DropdownMenu.DropdownItem icon={<FeatherLogOut />} onClick={onLogout} data-ui-id={UI_ACTION_IDS.signOut}>
{t("app.navbar.signOut")}
</DropdownMenu.DropdownItem>
{layout === "app" && currentTenant && myTenants.length > 0 ? (
<>
<DropdownMenu.DropdownDivider />
<DropdownMenu.DropdownNestedItem label={t("account.selectTenant.title")} icon={<FeatherUserSearch />}>
{myTenants.map((tenant) => (
<a key={tenant.id} href={appPath({ tenantSlug: tenant.slug, path: "dashboard", mode: "host-aware" })} className="no-underline">
<DropdownMenu.DropdownItem selected={tenant.id === currentTenant.id}>{tenant.name}</DropdownMenu.DropdownItem>
</a>
))}
</DropdownMenu.DropdownNestedItem>
</>
) : null}
</DropdownMenu>
</SubframeCore.DropdownMenu.Content>
</SubframeCore.DropdownMenu.Portal>
</SubframeCore.DropdownMenu.Root>
) : null}
</div>
</header>
<main id="main-content" ref={mainElement} className="flex-1 overflow-auto focus:outline-hidden" tabIndex={-1} data-ui-id={UI_SURFACE_IDS.shellMain}>
<div key={params.tenant} className="pb-20 sm:pb-0">
{children}
</div>
</main>
</div>
</div>
);
}
// data-ui-id-Attribute (id + Pattern + Params) wie getUiIdAttributes der shadcn-Shell.
function uiIdAttrs(item: Pick<SideBarItem, "id" | "uiIdPattern" | "uiIdParams">): Record<string, string> {
const attrs: Record<string, string> = {};
if (item.id) attrs["data-ui-id"] = item.id;
if (item.uiIdPattern) attrs["data-ui-id-pattern"] = item.uiIdPattern;
if (item.uiIdParams) {
Object.entries(item.uiIdParams).forEach(([key, value]) => {
attrs[`data-ui-id-param-${key.replace(/[^a-zA-Z0-9]/g, "").toLowerCase()}`] = String(value);
});
}
return attrs;
}
@@ -0,0 +1,45 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Modal from "~/components/ui/modals/Modal";
import { LogWithDetails } from "~/utils/db/logs.db.server";
import { redactDiagnosticJsonString } from "~/utils/redaction";
export default function LogDetailsButton({ item }: { item: LogWithDetails }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
function stringifyDetails() {
return redactDiagnosticJsonString(item.details);
}
return (
<>
<div className="flex space-x-2">
{item.details !== null && (
<button type="button" onClick={() => setOpen(true)} className="hover:text-foreground italic underline">
{t("models.log.details")}
</button>
)}
</div>
<Modal className="sm:max-w-md" open={open} setOpen={setOpen}>
<div className="flex justify-between space-x-2">
<div className="text-foreground text-lg font-medium">{t("models.log.details")}</div>
<div>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(stringifyDetails());
}}
className="bg-theme-100 text-theme-700 hover:bg-theme-200 focus:ring-ring inline-flex items-center rounded-md border border-transparent px-3 py-2 text-sm font-medium leading-4 focus:outline-hidden focus:ring-2 focus:ring-offset-2"
>
{t("shared.copy")}
</button>
</div>
</div>
<div className="prose bg-foreground/90 border-border mt-2 rounded-lg border-2 border-dashed">
<pre>{stringifyDetails()}</pre>
</div>
</Modal>
</>
);
}
@@ -0,0 +1,116 @@
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import type { Log } from "@toolctrl/db/prisma-browser";
import { LogWithDetails } from "~/utils/db/logs.db.server";
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
import TableSimple from "~/components/ui/tables/TableSimple";
import { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
import { Link } from "react-router";
import LogDetailsButton from "./LogDetailsButton";
import { absoluteAppPath } from "~/utils/app/CrossHostUrls";
import { useRootData } from "~/utils/data/useRootData";
import { createLocaleFormatters } from "~/locale/format";
import { getLogActionI18nKey } from "./logActionI18n";
export type LogsTableItem = Log & {
user?: LogWithDetails["user"] | { id: string; email: string; firstName?: string | null; lastName?: string | null } | null;
apiKey?: LogWithDetails["apiKey"] | { id: string; alias: string } | null;
tenant?: LogWithDetails["tenant"] | { id: string; name: string; slug?: string | null } | null;
comment?: LogWithDetails["comment"] | { id: string; value: string } | null;
};
interface Props {
items: LogsTableItem[];
withTenant: boolean;
pagination: PaginationDto;
}
export default function LogsTable({ withTenant, items, pagination }: Props) {
const { i18n, t } = useTranslation();
const formatters = useMemo(() => createLocaleFormatters(i18n.language), [i18n.language]);
const rootData = useRootData();
const headers = useMemo(() => {
let headers: RowHeaderDisplayDto<LogsTableItem>[] = [];
if (withTenant) {
headers.push({
name: "tenant",
title: t("models.tenant.object"),
value: (item) => (
<div>
{item.tenant ? (
<Link
to={absoluteAppPath(rootData.routing, item.tenant.slug ?? item.tenant.id)}
className="focus:bg-secondary/90 hover:border-border rounded-md border-b border-dashed border-transparent"
>
{item.tenant.name}
</Link>
) : (
<div>-</div>
)}
</div>
),
breakpoint: "sm",
});
}
headers = [
...headers,
{
name: "action",
title: t("models.log.action"),
value: (item) => (
<div>
{t(getLogActionI18nKey(item.action) ?? item.action, {
defaultValue: item.action,
})}
</div>
),
},
{
name: "url",
title: t("models.log.url"),
value: (item) => <div>{item.url}</div>,
},
{
name: "details",
title: t("models.log.details"),
value: (item) => <LogDetailsButton item={item as LogWithDetails} />,
},
{
name: "createdBy",
title: t("shared.createdBy"),
value: (item) => (
<div>
{item.user && (
<span>
{item.user.firstName} {item.user.lastName} <span className="text-muted-foreground text-xs">({item.user.email})</span>
</span>
)}
{item.apiKey && (
<span>
{t("models.apiKey.object")} <span className="text-muted-foreground text-xs">({item.apiKey.alias})</span>
</span>
)}
</div>
),
},
{
name: "createdAt",
title: t("shared.createdAt"),
value: (item) => formatters.relativeDate(item.createdAt),
formattedValue: (item) => (
<div className="flex flex-col">
<div>{formatters.date(item.createdAt, { dateStyle: "medium" })}</div>
<div className="text-xs">{formatters.relativeDate(item.createdAt)}</div>
</div>
),
className: "text-muted-foreground text-xs",
breakpoint: "sm",
sortable: true,
},
];
return headers;
}, [formatters, rootData.routing, t, withTenant]);
return <TableSimple items={items} headers={headers} pagination={pagination} className={() => "text-sm"} />;
}
@@ -0,0 +1,124 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { getLogActionI18nKey, LOG_ACTION_I18N_KEYS } from "./logActionI18n";
const repoRoot = process.cwd();
describe("log action i18n", () => {
it.each([
["Admin Settings Updated", "models.log.actions.adminSettingsUpdated"],
["admin.settings.updated", "models.log.actions.adminSettingsUpdated"],
["API Key Created", "models.log.actions.apiKeyCreated"],
["API Key Updated", "models.log.actions.apiKeyUpdated"],
["API Key Rotated", "models.log.actions.apiKeyRotated"],
["API Key Deleted", "models.log.actions.apiKeyDeleted"],
["admin.apiKeys.created", "models.log.actions.apiKeyCreated"],
["admin.apiKeys.updated", "models.log.actions.apiKeyUpdated"],
["admin.apiKeys.deleted", "models.log.actions.apiKeyDeleted"],
["plugin.settings_frame.violation", "models.log.actions.pluginSettingsFrameViolation"],
["settings.apiKeys.created", "models.log.actions.apiKeyCreated"],
["settings.apiKeys.updated", "models.log.actions.apiKeyUpdated"],
["settings.apiKeys.rotated", "models.log.actions.apiKeyRotated"],
["settings.apiKeys.deleted", "models.log.actions.apiKeyDeleted"],
["admin.accountUserRoles.created", "models.log.actions.created"],
["admin.accountUserRoles.deleted", "models.log.actions.deleted"],
["admin.adminUserRoles.created", "models.log.actions.created"],
["admin.adminUserRoles.deleted", "models.log.actions.deleted"],
["admin.permissions.created", "models.log.actions.created"],
["admin.permissions.updated", "models.log.actions.updated"],
["admin.permissions.deleted", "models.log.actions.deleted"],
["admin.roles.created", "models.log.actions.created"],
["admin.roles.updated", "models.log.actions.updated"],
["admin.roles.deleted", "models.log.actions.deleted"],
["Created", "models.log.actions.created"],
["Created pricing plan", "models.log.actions.createdPricingPlan"],
["admin.pricing.plan.created", "models.log.actions.createdPricingPlan"],
["admin.pricing.createdPlan", "models.log.actions.createdPricingPlan"],
["Created pricing plans", "models.log.actions.createdPricingPlans"],
["admin.pricing.createdPlans", "models.log.actions.createdPricingPlans"],
["Deleted", "models.log.actions.deleted"],
["Deleted pricing plan", "models.log.actions.deletedPricingPlan"],
["admin.pricing.deletedPlan", "models.log.actions.deletedPricingPlan"],
["Subscribed", "models.log.actions.subscribed"],
["settings.groups.created", "models.log.actions.created"],
["settings.groups.updated", "models.log.actions.updated"],
["settings.groups.deleted", "models.log.actions.deleted"],
["settings.memberRoles.created", "models.log.actions.created"],
["settings.memberRoles.deleted", "models.log.actions.deleted"],
["settings.rolesAndPermissions.userRoles.created", "models.log.actions.created"],
["settings.rolesAndPermissions.userRoles.deleted", "models.log.actions.deleted"],
["settings.tenant.updateDetails", "models.log.actions.updateTenantDetails"],
["Update tenant details", "models.log.actions.updateTenantDetails"],
["Updated", "models.log.actions.updated"],
["Updated AI overage configuration", "models.log.actions.updatedAiOverageConfiguration"],
["admin.pricing.aiOverage.updated", "models.log.actions.updatedAiOverageConfiguration"],
["Updated pricing plan", "models.log.actions.updatedPricingPlan"],
["portal.pricing.plan.updated", "models.log.actions.updatedPricingPlan"],
["Workspace Role Created", "models.log.actions.workspaceRoleCreated"],
["Workspace Role Updated", "models.log.actions.workspaceRoleUpdated"],
["Workspace Role Deleted", "models.log.actions.workspaceRoleDeleted"],
["upload.rejected.scan", "models.log.actions.uploadRejectedScan"],
])("maps %s to %s", (action, key) => {
expect(getLogActionI18nKey(action)).toBe(key);
});
it("leaves unknown actions available for the original-value fallback", () => {
expect(getLogActionI18nKey("Custom Billing Export")).toBeUndefined();
});
it("keeps every mapped audit action backed by English and German locale copy", () => {
const en = readTranslations("en");
const de = readTranslations("de");
for (const key of Object.values(LOG_ACTION_I18N_KEYS)) {
expect(readTranslationValue(en, key), `en:${key}`).toEqual(expect.any(String));
expect(readTranslationValue(de, key), `de:${key}`).toEqual(expect.any(String));
expect(readTranslationValue(en, key)?.trim(), `en:${key}`).not.toBe("");
expect(readTranslationValue(de, key)?.trim(), `de:${key}`).not.toBe("");
}
});
it("maps every literal createLog/createAdminLog action in route code", () => {
const routeFiles = listRouteSourceFiles(path.join(repoRoot, "apps/web/app/routes"));
const actions = routeFiles.flatMap((file) => {
const source = fs.readFileSync(file, "utf8");
return Array.from(source.matchAll(/create(?:Admin)?Log\(\s*[^,\n]+,\s*(?:[^,\n]+,\s*)?"([^"]+)"/gu))
.map((match) => match[1])
.filter((action): action is string => typeof action === "string");
});
expect(actions).toEqual(expect.arrayContaining(["admin.pricing.plan.created", "portal.pricing.plan.updated"]));
for (const action of actions) {
expect(getLogActionI18nKey(action), action).toBeDefined();
}
});
});
function listRouteSourceFiles(directory: string): string[] {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
return listRouteSourceFiles(absolutePath);
}
if (!/\.(ts|tsx)$/u.test(entry.name) || /\.test\./u.test(entry.name)) {
return [];
}
return [absolutePath];
});
}
function readTranslations(locale: "de" | "en") {
return JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales", locale, "translations.json"), "utf8")) as Record<string, unknown>;
}
function readTranslationValue(translations: Record<string, unknown>, key: string): string | undefined {
const value = key.split(".").reduce<unknown>((current, segment) => {
if (!current || typeof current !== "object" || !(segment in current)) {
return undefined;
}
return (current as Record<string, unknown>)[segment];
}, translations);
return typeof value === "string" ? value : undefined;
}
@@ -0,0 +1,58 @@
export const LOG_ACTION_I18N_KEYS = {
"Admin Settings Updated": "models.log.actions.adminSettingsUpdated",
"admin.settings.updated": "models.log.actions.adminSettingsUpdated",
"API Key Created": "models.log.actions.apiKeyCreated",
"API Key Updated": "models.log.actions.apiKeyUpdated",
"API Key Rotated": "models.log.actions.apiKeyRotated",
"API Key Deleted": "models.log.actions.apiKeyDeleted",
"admin.apiKeys.created": "models.log.actions.apiKeyCreated",
"admin.apiKeys.updated": "models.log.actions.apiKeyUpdated",
"admin.apiKeys.deleted": "models.log.actions.apiKeyDeleted",
"plugin.settings_frame.violation": "models.log.actions.pluginSettingsFrameViolation",
"settings.apiKeys.created": "models.log.actions.apiKeyCreated",
"settings.apiKeys.updated": "models.log.actions.apiKeyUpdated",
"settings.apiKeys.rotated": "models.log.actions.apiKeyRotated",
"settings.apiKeys.deleted": "models.log.actions.apiKeyDeleted",
"admin.accountUserRoles.created": "models.log.actions.created",
"admin.accountUserRoles.deleted": "models.log.actions.deleted",
"admin.adminUserRoles.created": "models.log.actions.created",
"admin.adminUserRoles.deleted": "models.log.actions.deleted",
"admin.permissions.created": "models.log.actions.created",
"admin.permissions.updated": "models.log.actions.updated",
"admin.permissions.deleted": "models.log.actions.deleted",
"admin.roles.created": "models.log.actions.created",
"admin.roles.updated": "models.log.actions.updated",
"admin.roles.deleted": "models.log.actions.deleted",
"Created": "models.log.actions.created",
"Created pricing plan": "models.log.actions.createdPricingPlan",
"admin.pricing.plan.created": "models.log.actions.createdPricingPlan",
"admin.pricing.createdPlan": "models.log.actions.createdPricingPlan",
"Created pricing plans": "models.log.actions.createdPricingPlans",
"admin.pricing.createdPlans": "models.log.actions.createdPricingPlans",
"Deleted": "models.log.actions.deleted",
"Deleted pricing plan": "models.log.actions.deletedPricingPlan",
"admin.pricing.deletedPlan": "models.log.actions.deletedPricingPlan",
"Subscribed": "models.log.actions.subscribed",
"settings.groups.created": "models.log.actions.created",
"settings.groups.updated": "models.log.actions.updated",
"settings.groups.deleted": "models.log.actions.deleted",
"settings.memberRoles.created": "models.log.actions.created",
"settings.memberRoles.deleted": "models.log.actions.deleted",
"settings.rolesAndPermissions.userRoles.created": "models.log.actions.created",
"settings.rolesAndPermissions.userRoles.deleted": "models.log.actions.deleted",
"settings.tenant.updateDetails": "models.log.actions.updateTenantDetails",
"Update tenant details": "models.log.actions.updateTenantDetails",
"Updated": "models.log.actions.updated",
"Updated AI overage configuration": "models.log.actions.updatedAiOverageConfiguration",
"admin.pricing.aiOverage.updated": "models.log.actions.updatedAiOverageConfiguration",
"Updated pricing plan": "models.log.actions.updatedPricingPlan",
"portal.pricing.plan.updated": "models.log.actions.updatedPricingPlan",
"Workspace Role Created": "models.log.actions.workspaceRoleCreated",
"Workspace Role Updated": "models.log.actions.workspaceRoleUpdated",
"Workspace Role Deleted": "models.log.actions.workspaceRoleDeleted",
"upload.rejected.scan": "models.log.actions.uploadRejectedScan",
} as const;
export function getLogActionI18nKey(action: string): string | undefined {
return LOG_ACTION_I18N_KEYS[action as keyof typeof LOG_ACTION_I18N_KEYS];
}
@@ -0,0 +1,74 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import AdminAuditLogToolbar from "./AdminAuditLogToolbar";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("~/components/ui/input/InputFilters", () => ({
default: ({ filters }: { filters: { name: string; title: string }[] }) => (
<div data-testid="input-filters">
{filters.map((filter) => (
<span key={filter.name}>{filter.title}</span>
))}
</div>
),
}));
const filters = [
{ name: "action", title: "models.log.action" },
{ name: "url", title: "models.log.url" },
{ name: "tenantId", title: "models.tenant.object" },
];
function renderToolbar(initialEntry: string) {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<AdminAuditLogToolbar filters={filters} />
</MemoryRouter>
);
}
describe("AdminAuditLogToolbar", () => {
it("renders the provided filters", () => {
renderToolbar("/audit-trails?action=create");
expect(screen.getByTestId("input-filters")).toBeInTheDocument();
expect(screen.getByText("models.log.action")).toBeInTheDocument();
expect(screen.getByText("models.log.url")).toBeInTheDocument();
expect(screen.getByText("models.tenant.object")).toBeInTheDocument();
});
it("preserves active filters while replacing stale pagination with CSV export", () => {
renderToolbar("/audit-trails?q=login&action=auth&url=%2Flogin&tenantId=tenant-1&page=3&pageSize=25");
const exportUrl = new URL(screen.getByRole("link", { name: "Export" }).getAttribute("href") ?? "", "https://example.test");
expect(exportUrl.pathname).toBe("/audit-trails");
expect(exportUrl.searchParams.get("q")).toBe("login");
expect(exportUrl.searchParams.get("action")).toBe("auth");
expect(exportUrl.searchParams.get("url")).toBe("/login");
expect(exportUrl.searchParams.get("tenantId")).toBe("tenant-1");
expect(exportUrl.searchParams.get("pageSize")).toBe("25");
expect(exportUrl.searchParams.get("page")).toBeNull();
expect(exportUrl.searchParams.get("export")).toBe("csv");
});
it("replaces an existing export value with csv", () => {
renderToolbar("/audit-trails?export=json&userId=user-1");
const exportUrl = new URL(screen.getByRole("link", { name: "Export" }).getAttribute("href") ?? "", "https://example.test");
expect(exportUrl.searchParams.get("userId")).toBe("user-1");
expect(exportUrl.searchParams.get("export")).toBe("csv");
});
it("creates a valid CSV export href without existing search params", () => {
renderToolbar("/audit-trails");
expect(screen.getByRole("link", { name: "Export" })).toHaveAttribute("href", "/audit-trails?export=csv");
});
});
@@ -0,0 +1,35 @@
import { Link, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
import InputFilters from "~/components/ui/input/InputFilters";
import { Button } from "~/components/ui/button";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
type Props = {
filters: FilterablePropertyDto[];
};
export default function AdminAuditLogToolbar({ filters }: Props) {
const { t } = useTranslation();
const location = useLocation();
const exportLabel = getFallbackTranslation(t("shared.export"), "Export");
const exportSearchParams = new URLSearchParams(location.search);
exportSearchParams.delete("page");
exportSearchParams.set("export", "csv");
const exportHref = adminPath(`audit-trails?${exportSearchParams.toString()}`, { mode: "host-aware" });
return (
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link to={exportHref} reloadDocument>
{exportLabel}
</Link>
</Button>
<InputFilters filters={filters} />
</div>
);
}
function getFallbackTranslation(value: string, fallback: string) {
return value === "shared.export" ? fallback : value;
}
@@ -0,0 +1,134 @@
import type { ReactNode } from "react";
import { twClassNames } from "~/ui/utils";
import { Logo } from "~/ui/components/Logo";
type StatusTone = "positive" | "negative" | "neutral";
const STATUS_BADGE: Record<StatusTone, string> = {
positive: "bg-surface-feedback-subtle-positive",
negative: "bg-surface-feedback-subtle-negative",
neutral: "bg-surface-feedback-subtle-neutral",
};
const STATUS_ICON: Record<StatusTone, string> = {
positive: "text-icon-feedback-positive",
negative: "text-icon-feedback-negative",
neutral: "text-icon-neutral-primary",
};
// Gemeinsames Auth-Layout nach Davids Subframe-Auth-Screens (Login/Signup/Reset/2FA/Magic-Link):
// zentriertes Logo (64) + Card (Titel/Untertitel + Inhalt) + optionaler Footer. min-h-screen statt Davids
// h-full (Auth-Routen ohne full-height-Parent). Titel-Ausrichtung + Untertitel-Gewicht variieren je Screen
// in Davids Designs -> als Props (titleAlign/subtitleStrong). twClassNames, damit cardClassName Basis-Tokens
// (gap/shadow) sauber ueberschreibt.
export function AuthShell({
title,
subtitle,
children,
footer,
cardClassName = "max-w-[400px]",
logoIconOnly = false,
titleAlign = "start",
subtitleStrong = false,
dataUiId,
}: {
title: ReactNode;
subtitle?: ReactNode;
children: ReactNode;
footer?: ReactNode;
/** Breite/Overrides der Card; Forms = max-w-[400px] (Default). */
cardClassName?: string;
/** Icon-only-Logo (wie Davids 2FA-/Status-Screens) statt vollem Wortmarken-Logo. */
logoIconOnly?: boolean;
/** Titel-/Untertitel-Ausrichtung. David: Login=center, Signup/Forgot/Reset/2FA=start. */
titleAlign?: "center" | "start";
/** Untertitel-Gewicht. David: Forgot/2FA = medium-150, sonst regular-0. */
subtitleStrong?: boolean;
dataUiId?: string;
}) {
return (
<div
className="flex min-h-screen w-full flex-col items-center justify-center gap-8 bg-surface-neutral-primary px-4 py-12 mobile:gap-6 mobile:py-6"
data-ui-id={dataUiId}
>
<Logo size="64" showIcon={logoIconOnly || undefined} hideText={logoIconOnly || undefined} />
<div
className={twClassNames(
"flex w-full flex-col items-stretch gap-4 rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-8 py-8 shadow-sm mobile:px-6",
cardClassName
)}
>
<div className={twClassNames("flex w-full flex-col gap-1", titleAlign === "center" ? "items-center" : "items-start")}>
<h1 className="text-heading- font-heading- text-text-neutral-primary">{title}</h1>
{subtitle ? (
<p
className={
subtitleStrong
? "text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-tertiary"
: "text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary"
}
>
{subtitle}
</p>
) : null}
</div>
{children}
</div>
{footer ? <div className="flex w-full flex-wrap items-center justify-center gap-2">{footer}</div> : null}
</div>
);
}
// Status-Screen nach Davids Auth-Status-Design (Magic-Link Sent/Invalid, Forgot Sent, Reset Success ...):
// volles Logo + Card mit farbigem Icon-Badge + Titel + Nachricht + optionalem Hinweis + Aktionen.
export function AuthStatus({
icon,
tone = "neutral",
title,
message,
hint,
children,
dataUiId,
}: {
icon: ReactNode;
tone?: StatusTone;
title: ReactNode;
message: ReactNode;
hint?: ReactNode;
/** Aktions-Buttons (vertikal gestapelt, zentriert). */
children?: ReactNode;
dataUiId?: string;
}) {
return (
<div
className="flex min-h-screen w-full flex-col items-center justify-center gap-8 bg-surface-neutral-primary px-4 py-12 mobile:gap-6 mobile:py-6"
data-ui-id={dataUiId}
>
<Logo size="64" />
<div className="flex w-full max-w-[400px] flex-col items-center gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-8 py-8 text-center shadow-sm mobile:px-6">
<div className={`flex h-12 w-12 flex-none items-center justify-center rounded-round ${STATUS_BADGE[tone]}`}>
<span className={`text-heading- font-heading- ${STATUS_ICON[tone]}`}>{icon}</span>
</div>
<div className="flex w-full flex-col items-center gap-2">
<h1 className="text-center text-heading- font-heading- text-text-neutral-primary">{title}</h1>
<p className="text-center text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-tertiary">{message}</p>
{hint ? (
<p className="text-center text-body-extra-small-regular-0 font-body-extra-small-regular-0 text-text-neutral-tertiary">{hint}</p>
) : null}
</div>
{children ? <div className="flex w-full flex-col items-center gap-3">{children}</div> : null}
</div>
</div>
);
}
// Davids "oder"-Trenner zwischen Primär- und Sekundär-Aktion (z.B. Login vs. Magic-Link).
export function AuthDivider({ label }: { label: ReactNode }) {
return (
<div className="flex w-full items-center justify-center gap-3">
<div className="flex h-px grow shrink-0 basis-0 flex-col items-center bg-border-default" />
<span className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">{label}</span>
<div className="flex h-px grow shrink-0 basis-0 flex-col items-center bg-border-default" />
</div>
);
}
@@ -0,0 +1,30 @@
import clsx from "clsx";
import { Link } from "react-router";
import { iconLight, iconDark } from "~/assets/brand/default";
import { useRootData } from "~/utils/data/useRootData";
import { Fragment } from "react";
interface Props {
className?: string;
size?: string;
fromConfig?: boolean;
}
export default function Icon({ className = "", size = "h-9", fromConfig = true }: Props) {
const { appConfiguration } = useRootData();
return (
<Link to="/" className={clsx(className, "flex")}>
{fromConfig && appConfiguration?.branding.icon ? (
<Fragment>
<img className={clsx(size, "hidden w-auto dark:block")} src={appConfiguration.branding.iconDarkMode ?? appConfiguration.branding.icon} alt="Icon" />
<img className={clsx(size, "w-auto dark:hidden")} src={appConfiguration.branding.icon} alt="Icon" />
</Fragment>
) : (
<Fragment>
<img className={clsx(size, "hidden w-auto dark:block")} src={iconDark} alt="Logo" />
<img className={clsx(size, "w-auto dark:hidden")} src={iconLight} alt="Logo" />
</Fragment>
)}
</Link>
);
}
@@ -0,0 +1,35 @@
import { logoLight, logoDark } from "~/assets/brand/default";
import clsx from "clsx";
import { Link } from "react-router";
import { useRootData } from "~/utils/data/useRootData";
import { Fragment } from "react";
interface Props {
className?: string;
size?: string;
to?: string;
}
export default function Logo({ className = "", size = "h-9", to }: Props) {
const { appConfiguration } = useRootData();
return (
<Link to={to ?? "/"} className={clsx(className, "flex")}>
{/* <BrandLogo className="h-10 w-auto mx-auto" /> */}
{appConfiguration?.branding.logo ? (
<Fragment>
<img
className={clsx(size, "mx-auto hidden w-auto dark:block")}
src={appConfiguration.branding.logoDarkMode ?? appConfiguration.branding.logo}
alt="Logo"
/>
<img className={clsx(size, "mx-auto w-auto dark:hidden")} src={appConfiguration.branding.logo} alt="Logo" />
</Fragment>
) : (
<Fragment>
<img className={clsx(size, "mx-auto hidden w-auto dark:block")} src={logoDark} alt="Logo" />
<img className={clsx(size, "mx-auto w-auto dark:hidden")} src={logoLight} alt="Logo" />
</Fragment>
)}
</Link>
);
}
@@ -0,0 +1,29 @@
import { logoLight } from "~/assets/brand/default";
import clsx from "clsx";
import { Link } from "react-router";
import { useRootData } from "~/utils/data/useRootData";
import { Fragment } from "react";
interface Props {
className?: string;
size?: string;
to?: string;
}
export default function LogoLightMode({ className = "", size = "h-9", to }: Props) {
const { appConfiguration } = useRootData();
return (
<Link to={to ?? "/"} className={clsx(className, "flex")}>
{/* <BrandLogo className="h-10 w-auto mx-auto" /> */}
{appConfiguration?.branding.logo ? (
<Fragment>
<img className={clsx(size, "mx-auto w-auto")} src={appConfiguration.branding.logo} alt="Logo" />
</Fragment>
) : (
<Fragment>
<img className={clsx(size, "mx-auto w-auto")} src={logoLight} alt="Logo" />
</Fragment>
)}
</Link>
);
}
@@ -0,0 +1,54 @@
import { useEffect, useRef } from "react";
interface Props {
/** Render the mCaptcha widget. When false (or unconfigured) nothing is rendered. */
enabled: boolean;
/** Public base URL of the self-hosted mCaptcha instance (web config / MCAPTCHA_INSTANCE_URL). */
instanceUrl?: string;
/** Public sitekey for this surface (e.g. MCAPTCHA_SITEKEY_LOGIN). */
sitekey?: string;
}
// mCaptcha's vanilla glue mounts the widget into an element with this id and
// injects a hidden <input name="mcaptcha__token"> into the enclosing <form>
// on solve. Keep in sync with @mcaptcha/vanilla-glue (exported `ID`).
const MOUNT_ID = "mcaptcha__widget-container";
/**
* mCaptcha (proof-of-work) widget — replaces the former no-op RecaptchaWrapper.
*
* Render this **inside** the <form> it should protect: the glue injects the
* hidden `mcaptcha__token` field next to this mount, so it is submitted with
* the form and verified server-side by `@toolctrl/captcha` (see AuthService /
* api.leads.capture / infra/mcaptcha/README.md + AD-7).
*
* Client load failures are non-fatal — the server verifier fails open
* (CAPTCHA_FAIL_OPEN, default true) when no token is present. The glue is
* imported dynamically so it never runs during SSR.
*/
export default function Captcha({ enabled, instanceUrl, sitekey }: Props) {
const active = enabled && Boolean(instanceUrl && sitekey);
const initialized = useRef(false);
useEffect(() => {
if (!active || initialized.current) return;
initialized.current = true;
let cancelled = false;
void (async () => {
try {
const { default: Widget } = await import("@mcaptcha/vanilla-glue");
if (cancelled) return;
// Widget self-mounts into #MOUNT_ID and injects the hidden mcaptcha__token field.
void new Widget({ siteKey: { instanceUrl: new URL(instanceUrl!), key: sitekey! } });
} catch (err) {
console.error("mCaptcha widget failed to initialise", err);
}
})();
return () => {
cancelled = true;
};
}, [active, instanceUrl, sitekey]);
if (!active) return null;
return <div id={MOUNT_ID} className="m-captcha" data-ui-id="ui.captcha.mount" />;
}
@@ -0,0 +1,110 @@
import { Link, useFetcher, useLocation, useSearchParams } from "react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { allCookieCategories } from "~/application/cookies/ApplicationCookies";
import { CookieCategory } from "~/application/cookies/CookieCategory";
import { useRootData } from "~/utils/data/useRootData";
import CookieHelper from "~/utils/helpers/CookieHelper";
import OpenModal from "../ui/modals/OpenModal";
import CookieConsentSettings from "./CookieConsentSettings";
import { Button } from "../ui/button";
import { ShieldCheckIcon } from "~/components/ui/icons/vendor";
export default function CookieConsentBanner() {
const { t } = useTranslation();
const fetcher = useFetcher();
const location = useLocation();
const isLoading = fetcher.state === "submitting";
const { userSession, appConfiguration } = useRootData();
const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(CookieHelper.requiresCookieConsent(userSession, searchParams));
const [showCookieSettingsModal, setShowCookieSettingsModal] = useState(false);
useEffect(() => {
setIsOpen(CookieHelper.requiresCookieConsent(userSession, searchParams));
}, [userSession, searchParams]);
if (!appConfiguration.cookies.enabled) {
return null;
}
function setCookies(selectedCookies: CookieCategory[]) {
const form = CookieHelper.getUpdateCookieConsentForm({ selectedCookies, location, searchParams });
let redirectTo = location.pathname + location.search;
form.set("redirect", redirectTo);
fetcher.submit(form, {
method: "post",
action: "/preferences",
preventScrollReset: true,
});
}
return (
<>
{isOpen && (
<div className="relative">
<div className="fixed inset-x-0 bottom-0 z-30 pb-2 sm:pb-5">
<div className="mx-auto max-w-5xl px-2 sm:px-6 lg:px-8">
<div className="bg-background border-border text-foreground max-h-[70vh] overflow-y-auto rounded-lg border p-2 shadow-lg sm:max-h-[50vh] sm:p-3">
<div className="flex flex-col items-center justify-between space-y-2 sm:flex-row sm:space-y-0">
<div className="flex items-start space-x-2">
<div className="bg-primary rounded-full p-1">
<ShieldCheckIcon className="text-primary-foreground h-6 w-6" />
</div>
<div>
<p className="text-foreground font-medium">
<span className="md:hidden"> {t("cookies.titleSmall")} </span>
<span className="hidden md:inline"> {t("cookies.title")} </span>
</p>
<p className="text-muted-foreground text-sm font-light">
<span className="md:hidden">
{t("cookies.descriptionSmall")}{" "}
<Link to="/privacy-policy" className="underline">
{t("shared.learnMore")}
</Link>
.{" "}
</span>
<span className="hidden md:inline">
{" "}
{t("cookies.description")}{" "}
<Link to="/privacy-policy" className="underline">
{t("shared.learnMore")}
</Link>{" "}
</span>
</p>
</div>
</div>
<div className="border-border w-full border border-t sm:hidden"></div>
<div className="flex w-full shrink-0 flex-col space-y-2 sm:mt-0 sm:w-auto sm:flex-row sm:space-y-0 sm:space-x-2">
<Button variant="outline" type="button" onClick={() => setShowCookieSettingsModal(true)}>
{" "}
{t("cookies.settings")}{" "}
</Button>
<Button
disabled={isLoading}
// className={clsx(isLoading && "base-spinner")}
onClick={() => {
setCookies(allCookieCategories);
setIsOpen(false);
}}
>
{t("cookies.accept")}
</Button>
</div>
</div>
</div>
</div>
</div>
{showCookieSettingsModal && (
<OpenModal onClose={() => setShowCookieSettingsModal(false)}>
<CookieConsentSettings />
</OpenModal>
)}
</div>
)}
</>
);
}
@@ -0,0 +1,77 @@
import { useLocation, useSearchParams, useSubmit } from "react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { allCookieCategories } from "~/application/cookies/ApplicationCookies";
import { CookieCategory } from "~/application/cookies/CookieCategory";
import { useRootData } from "~/utils/data/useRootData";
import CookieHelper from "~/utils/helpers/CookieHelper";
import CookiesList from "./CookiesList";
import { Button } from "../ui/button";
interface Props {
onUpdated?: () => void;
}
export default function CookieConsentSettings({ onUpdated }: Props) {
const { t } = useTranslation();
let { userSession } = useRootData();
const submit = useSubmit();
let location = useLocation();
const [searchParams] = useSearchParams();
const [selectedCookies, setSelectedCookies] = useState<CookieCategory[]>([]);
useEffect(() => {
const initial: CookieCategory[] = [];
allCookieCategories.forEach((cookie) => {
if (userSession.cookies.find((f) => f.allowed && f.category === CookieCategory[cookie])) {
initial.push(cookie);
}
});
setSelectedCookies(initial);
}, [userSession.cookies]);
function setCookies(selectedCookies: CookieCategory[]) {
const form = CookieHelper.getUpdateCookieConsentForm({ selectedCookies, location, searchParams });
submit(form, { method: "post", action: "/preferences", preventScrollReset: true });
if (onUpdated) {
onUpdated();
}
}
function toggle(category: CookieCategory) {
if (selectedCookies.includes(category)) {
setSelectedCookies(selectedCookies.filter((f) => f !== category));
} else {
setSelectedCookies([...selectedCookies, category]);
}
}
function deny() {
setCookies([CookieCategory.REQUIRED]);
}
function allowSelected() {
setCookies(selectedCookies);
}
function allowAll() {
setCookies(allCookieCategories);
}
return (
<div className="space-y-3">
<div className="font-extrabold">{t("cookies.settings")}</div>
<CookiesList selectedCookies={selectedCookies} toggle={toggle} />
<div className="grid gap-2 sm:grid-cols-3">
<Button variant="outline" onClick={deny}>
{t("shared.deny")}
</Button>
<Button variant="outline" onClick={allowSelected}>
{t("shared.allowSelected")}
</Button>
<Button variant="outline" type="button" onClick={allowAll}>
{t("shared.allowAll")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import CookiesList from "./CookiesList";
vi.mock("react-router", () => ({
Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => (
<a href={to} {...props}>
{children}
</a>
),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number }) => (key === "cookies.count" ? `${options?.count} localized cookies` : key),
}),
}));
describe("CookiesList", () => {
it("renders cookie category counts through i18n", () => {
render(<CookiesList editing />);
expect(screen.getAllByText(/\(\d+ localized cookies\)/).length).toBeGreaterThan(0);
expect(screen.queryByText(/\(\d+ cookies\)/)).not.toBeInTheDocument();
});
it("renders cookie expiry values through i18n", () => {
render(<CookiesList editing />);
fireEvent.click(screen.getByRole("button", { name: /cookies\.categories\.REQUIRED\.name/i }));
expect(screen.getByText("cookies.expiry.oneMonth")).toBeInTheDocument();
expect(screen.queryByText("1 month")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,96 @@
import { Link } from "react-router";
import clsx from "clsx";
import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next";
import { allApplicationCookies, allCookieCategories } from "~/application/cookies/ApplicationCookies";
import { CookieCategory } from "~/application/cookies/CookieCategory";
import InputCheckbox from "../ui/input/InputCheckbox";
export default function CookiesList({
selectedCookies,
toggle,
editing,
}: {
selectedCookies?: CookieCategory[];
toggle?: (category: CookieCategory) => void;
editing?: boolean;
}) {
const { t } = useTranslation();
const [openCategories, setOpenCategories] = useState<CookieCategory[]>([]);
function isCategoryOpen(category: CookieCategory) {
return openCategories.includes(category);
}
function toggleOpenCategory(category: CookieCategory) {
const newOpenCategories = isCategoryOpen(category) ? openCategories.filter((c) => c !== category) : [...openCategories, category];
setOpenCategories(newOpenCategories);
}
return (
<div className={clsx("border-border text-foreground bg-secondary overflow-hidden overflow-y-auto rounded-md border", !editing && "max-h-72")}>
{allCookieCategories.map((category, idx) => {
return (
<Fragment key={category}>
<div
className={clsx(
"hover:bg-secondary border-border bg-background flex items-center justify-between px-4 py-2",
idx < allCookieCategories.length - 1 && "border-b"
)}
>
<button type="button" onClick={() => toggleOpenCategory(category)} className="flex w-full items-center space-x-2 px-2 py-3 font-medium">
<div>{isCategoryOpen(category) ? "-" : "+"}</div>
<div>{t("cookies.categories." + CookieCategory[category] + ".name")}</div>
<div className="text-muted-foreground text-xs font-normal">
({t("cookies.count", { count: allApplicationCookies.filter((f) => f.category === category).length })})
</div>
</button>
{toggle !== undefined && selectedCookies !== undefined && (
<InputCheckbox
value={selectedCookies.includes(category) || category === CookieCategory.REQUIRED}
setValue={() => toggle(category)}
disabled={category === CookieCategory.REQUIRED}
/>
)}
</div>
{isCategoryOpen(category) && (
<div className="space-y-2 px-4 py-4 pb-2">
<div className="text-muted-foreground text-sm">{t("cookies.categories." + CookieCategory[category] + ".description")}</div>
{allApplicationCookies
.filter((f) => f.category === category)
.map((item) => {
return (
<div key={item.name} className="border-border bg-secondary/90 space-y-2 rounded-md border border-dashed p-2 py-2">
<div className="flex items-baseline justify-between font-bold">
<div>{item.name}</div>
{item.href?.startsWith("http") ? (
<a target="_blank" rel="noreferrer" href={item.href} className="text-theme-600 text-xs underline">
{t("shared.learnMore")}
</a>
) : item.href ? (
<Link to={item.href} className="text-theme-600 text-xs underline">
{t("shared.learnMore")}
</Link>
) : null}
</div>
<div className="text-muted-foreground text-sm font-light">{item.descriptionKey ? t(item.descriptionKey) : item.description}</div>
<div className="border-border w-full border-t" />
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div>
<span className="font-medium">{t("shared.expiry")}:</span> {item.expiryKey ? t(item.expiryKey) : item.expiry ?? "?"}
</div>
{item.type && (
<div>
<span className="font-medium">{t("shared.type")}:</span> {item.type ?? "?"}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</Fragment>
);
})}
</div>
);
}
@@ -0,0 +1,40 @@
import "@testing-library/jest-dom";
import * as React from "react";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import SkipToContent from "./SkipToContent";
import { expectNoAxeViolations } from "@toolctrl/ui-app/test-setup/axe";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => ({ "shared.a11y.skipToContent": "Skip to content" })[key] ?? key,
}),
}));
describe("SkipToContent", () => {
it("renders a link with the default href #main-content", () => {
render(<SkipToContent />);
const link = screen.getByTestId("skip-to-content");
expect(link).toHaveAttribute("href", "#main-content");
expect(link.tagName).toBe("A");
});
it("accepts a custom targetId", () => {
render(<SkipToContent targetId="page-body" />);
const link = screen.getByTestId("skip-to-content");
expect(link).toHaveAttribute("href", "#page-body");
});
it("uses the sr-only utility so the link is visually hidden by default", () => {
render(<SkipToContent />);
const link = screen.getByTestId("skip-to-content");
expect(link.className).toMatch(/\bsr-only\b/);
// Must opt-in to visibility via focus utilities.
expect(link.className).toMatch(/focus:not-sr-only/);
});
it("has no axe violations", async () => {
await expectNoAxeViolations(<SkipToContent />);
});
});
@@ -0,0 +1,30 @@
import { useTranslation } from "react-i18next";
/**
* WCAG-2.2 keyboard-only "skip to main content" link.
*
* Rendered as the first focusable element inside every shell layout
* (AppLayout, AdminLayout, MarketingLayout). Visually hidden by default
* (`sr-only`), it becomes visible only when focused so sighted keyboard
* users see exactly where they are landing.
*
* The link points at `#main-content`, which the corresponding `<main>`
* element in ShadcnSidebarLayout / public layouts owns as its `id`. The
* `<main>` already carries `tabIndex={0}`, so the anchor's hash navigation
* also moves keyboard focus to the main region.
*
* Spec: docs/improvement-plan/10-compliance-polish/wcag-internal.md
* (section "Keyboard-Navigation: Skip-to-Content")
*/
export default function SkipToContent({ targetId = "main-content" }: { targetId?: string }) {
const { t } = useTranslation();
return (
<a
href={`#${targetId}`}
className="sr-only focus:not-sr-only focus-visible:fixed focus-visible:left-4 focus-visible:top-4 focus-visible:z-[100] focus-visible:rounded-md focus-visible:bg-primary focus-visible:px-4 focus-visible:py-2 focus-visible:text-sm focus-visible:font-medium focus-visible:text-primary-foreground focus-visible:shadow-lg focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
data-testid="skip-to-content"
>
{t("shared.a11y.skipToContent")}
</a>
);
}
@@ -0,0 +1,53 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ApiKeyCreatedModal from "./ApiKeyCreatedModal";
const navigate = vi.fn();
const writeText = vi.fn();
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
Link: ({ children, to, onClick, className }: { children: React.ReactNode; to: string; onClick?: React.MouseEventHandler<HTMLAnchorElement>; className?: string }) => (
<a href={to} onClick={onClick} className={className}>
{children}
</a>
),
useNavigate: () => navigate,
useLocation: () => ({ pathname: "/acme/settings/api/keys/new" }),
useMatches: () => [],
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("~/utils/data/useRootData", () => ({ useRootData: () => ({}) }));
vi.mock("~/utils/helpers/AnalyticsHelper", () => ({ default: { addEvent: vi.fn() } }));
vi.mock("~/components/ui/modals/OpenModal", () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe("ApiKeyCreatedModal", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(navigator, { clipboard: { writeText } });
writeText.mockResolvedValue(undefined);
});
it("labels the plaintext as one-time and copies it only when requested", async () => {
render(<ApiKeyCreatedModal apiKey={{ key: "tc_live_plaintext_secret_once", alias: "Primary integration" }} redirectTo="/acme/settings/api/keys" />);
expect(screen.getByText("models.apiKey.createdTitle")).toBeInTheDocument();
expect(screen.getByText("models.apiKey.aliasWithValue")).toBeInTheDocument();
expect(screen.getByText("models.apiKey.oneTimeDescriptionCreated")).toBeInTheDocument();
expect(screen.getByDisplayValue("tc_live_plaintext_secret_once")).toBeInTheDocument();
expect(writeText).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "shared.copy" }));
expect(writeText).toHaveBeenCalledWith("tc_live_plaintext_secret_once");
await waitFor(() => expect(screen.getByRole("button", { name: "shared.copied" })).toBeInTheDocument());
});
});
@@ -0,0 +1,50 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import InputText from "~/components/ui/input/InputText";
import OpenModal from "~/components/ui/modals/OpenModal";
interface Props {
apiKey: {
key: string;
alias: string;
};
redirectTo: string;
action?: "created" | "rotated";
}
export default function ApiKeyCreatedModal({ apiKey, redirectTo, action = "created" }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const [copied, setCopied] = useState(false);
const isRotated = action === "rotated";
async function copyApiKey() {
await navigator.clipboard.writeText(apiKey.key);
setCopied(true);
}
return (
<OpenModal className="max-w-md" onClose={() => navigate(redirectTo)}>
<div className="space-y-3">
<div className="flex items-baseline justify-between space-x-1">
<h3 className="font-bold">{t(isRotated ? "models.apiKey.rotatedTitle" : "models.apiKey.createdTitle")}</h3>
<div className="text-muted-foreground">{t("models.apiKey.aliasWithValue", { alias: apiKey.alias })}</div>
</div>
<div className="text-muted-foreground text-sm">
{t(isRotated ? "models.apiKey.oneTimeDescriptionRotated" : "models.apiKey.oneTimeDescriptionCreated")}
</div>
<InputText className="grow select-all font-mono" disabled={true} name="apiKey" title={t("models.apiKey.oneTimeLabel")} withLabel={true} defaultValue={apiKey.key} />
<div className="flex flex-col gap-2 border-border border-t pt-4 sm:flex-row">
<ButtonSecondary className="flex w-full justify-center text-center" onClick={copyApiKey}>
{copied ? t("shared.copied") : t("shared.copy")}
</ButtonSecondary>
<ButtonPrimary className="flex w-full justify-center text-center" to={redirectTo}>
{t("shared.acceptAndContinue")}
</ButtonPrimary>
</div>
</div>
</OpenModal>
);
}
@@ -0,0 +1,114 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DefaultEntityTypes } from "~/application/dtos/shared/DefaultEntityTypes";
import ApiKeyForm from "./ApiKeyForm";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (value: string) => value }),
}));
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
useSubmit: () => vi.fn(),
};
});
vi.mock("~/utils/data/useRootData", () => ({
useRootData: () => ({ csrf: "csrf-test-token" }),
}));
vi.mock("~/components/ui/forms/FormGroup", () => ({
default: ({ children }: { children: React.ReactNode }) => <form>{children}</form>,
}));
vi.mock("~/components/ui/input/InputText", () => ({
default: ({ title, name, value, setValue, disabled }: { title: string; name: string; value: string; setValue: (value: string) => void; disabled?: boolean }) => (
<label>
{title}
<input name={name} value={value} onChange={(event) => setValue(event.currentTarget.value)} disabled={disabled} />
</label>
),
}));
vi.mock("~/components/ui/input/InputDate", () => ({
default: ({ title, name, value, onChange, disabled }: { title: string; name: string; value?: Date; onChange: (value: Date) => void; disabled?: boolean }) => (
<label>
{title}
<input
type="date"
name={name}
value={value?.toISOString().split("T")[0] ?? ""}
onChange={(event) => onChange(new Date(event.currentTarget.value))}
disabled={disabled}
/>
</label>
),
}));
vi.mock("~/components/ui/input/InputCheckboxInline", () => ({
default: ({ name, title, value, setValue, disabled }: { name: string; title: React.ReactNode; value?: boolean; setValue?: (value: boolean) => void; disabled?: boolean }) => (
<label>
<input type="checkbox" name={name} checked={value} onChange={(event) => setValue?.(event.currentTarget.checked)} disabled={disabled} />
{title}
</label>
),
}));
vi.mock("~/components/ui/tooltips/HintTooltip", () => ({
default: ({ text }: { text: string }) => <span>{text}</span>,
}));
const entity = {
id: "entity_contacts",
order: 1,
name: "contact",
title: "Contacts",
titlePlural: "Contacts",
active: true,
slug: "contacts",
type: DefaultEntityTypes.AppOnly,
hasApi: true,
showInSidebar: true,
icon: "users",
onEdit: null,
};
describe("ApiKeyForm scope editor", () => {
it("uses accessible CRUD labels and keeps entities[] payloads in sync", () => {
render(<ApiKeyForm entities={[entity]} />);
const createCheckbox = screen.getByRole("checkbox", { name: /models\.apiKey\.create Contacts/i });
const readCheckbox = screen.getByRole("checkbox", { name: /models\.apiKey\.read Contacts/i });
const updateCheckbox = screen.getByRole("checkbox", { name: /models\.apiKey\.update Contacts/i });
const deleteCheckbox = screen.getByRole("checkbox", { name: /models\.apiKey\.delete Contacts/i });
expect(createCheckbox).toBeChecked();
expect(readCheckbox).toBeChecked();
expect(updateCheckbox).toBeChecked();
expect(deleteCheckbox).toBeChecked();
fireEvent.click(deleteCheckbox);
const hiddenPayload = document.querySelector<HTMLInputElement>('input[type="hidden"][name="entities[]"]');
expect(hiddenPayload).toBeInTheDocument();
expect(JSON.parse(hiddenPayload?.value ?? "{}")).toEqual({
entityId: "entity_contacts",
create: true,
read: true,
update: true,
delete: false,
});
});
it("disables scope checkboxes when updates are not allowed", () => {
render(<ApiKeyForm entities={[entity]} canUpdate={false} />);
expect(screen.getByRole("checkbox", { name: /models\.apiKey\.create Contacts/i })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /models\.apiKey\.read Contacts/i })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /models\.apiKey\.update Contacts/i })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /models\.apiKey\.delete Contacts/i })).toBeDisabled();
});
});
@@ -0,0 +1,258 @@
import type { Tenant } from "@toolctrl/db/prisma-browser";
import { useRef, useState, useEffect } from "react";
import { useSubmit } from "react-router";
import { useTranslation } from "react-i18next";
import { DefaultEntityTypes } from "~/application/dtos/shared/DefaultEntityTypes";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import FormGroup from "~/components/ui/forms/FormGroup";
import InputCheckboxInline from "~/components/ui/input/InputCheckboxInline";
import InputDate from "~/components/ui/input/InputDate";
import InputSelect from "~/components/ui/input/InputSelect";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import { useRootData } from "~/utils/data/useRootData";
import type { RedactedApiKeyWithDetails } from "~/utils/db/apiKeys.db.server";
import type { EntitySimple } from "~/utils/db/entities/entities.db.server";
import { updateItemByIdx } from "~/utils/shared/ObjectUtils";
interface Props {
entities: EntitySimple[];
item?: RedactedApiKeyWithDetails | null;
tenants?: Tenant[];
canUpdate?: boolean;
canDelete?: boolean;
canRotate?: boolean;
}
export default function ApiKeyForm({ entities, item, tenants, canUpdate = true, canDelete, canRotate = false }: Props) {
const { t } = useTranslation();
const submit = useSubmit();
const rootData = useRootData();
const inputName = useRef<RefInputText>(null);
const [tenantId, setTenantId] = useState<string | number | undefined>(item?.tenantId ?? "");
const [alias, setAlias] = useState(item?.alias ?? "");
const [expires, setExpires] = useState<Date | undefined>(item?.expires ?? undefined);
const [active, setActive] = useState(item?.active ?? true);
const [permissions, setPermissions] = useState<{ entityId: string; create: boolean; read: boolean; update: boolean; delete: boolean }[]>([]);
useEffect(() => {
const permissions: { entityId: string; create: boolean; read: boolean; update: boolean; delete: boolean }[] = [];
entities
.filter((f) => (f.type === DefaultEntityTypes.AppOnly || f.type === DefaultEntityTypes.All) && f.hasApi)
.forEach((entity) => {
const existing = item?.entities.find((f) => f.entityId === entity.id);
permissions.push({
entityId: entity.id,
create: existing?.create ?? true,
read: existing?.read ?? true,
update: existing?.update ?? true,
delete: existing?.delete ?? true,
});
});
setPermissions(permissions);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
setTimeout(() => {
inputName.current?.input.current?.focus();
}, 100);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function rotateApiKey() {
const form = new FormData();
form.set("action", "rotate");
form.set("id", item?.id ?? "");
if (rootData.csrf) {
form.set("csrf", rootData.csrf);
}
submit(form, { method: "post" });
}
return (
<FormGroup id={item?.id} editing={true} canUpdate={canUpdate} canDelete={canDelete}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
{item && canRotate && (
<div className="col-span-12 flex justify-end">
<ButtonSecondary onClick={rotateApiKey}>{t("models.apiKey.rotate")}</ButtonSecondary>
</div>
)}
{tenants && (
<>
<InputSelect
className="col-span-6"
name="tenant-id"
title={t("models.tenant.object")}
value={tenantId}
setValue={setTenantId}
options={
tenants?.map((tenant) => {
return {
name: tenant.name,
value: tenant.id,
};
}) ?? []
}
disabled={tenants === undefined || !canUpdate}
></InputSelect>
</>
)}
<InputText
disabled={!canUpdate}
ref={inputName}
className="col-span-6"
name="alias"
title={t("models.apiKey.alias")}
value={alias}
setValue={setAlias}
required
autoComplete="off"
/>
<InputDate
disabled={!canUpdate}
className="col-span-6"
name="expires"
title={t("models.apiKey.expires")}
value={expires}
onChange={setExpires}
hint={
<>
{expires && (
<button type="button" onClick={() => setExpires(undefined)} className="text-muted-foreground text-xs hover:text-red-500">
{t("shared.remove")}
</button>
)}
</>
}
/>
<div className="col-span-12 flex flex-col">
<div className="overflow-x-auto">
<div className="inline-block min-w-full py-2 align-middle">
<div className="border-border overflow-hidden border shadow-xs sm:rounded-lg">
<table className="divide-border min-w-full divide-y">
<thead className="bg-secondary">
<tr>
<th scope="col" className="text-muted-foreground select-none truncate px-3 py-2 text-left text-xs font-medium tracking-wider">
<div className="text-muted-foreground flex items-center space-x-1">
<div>{t("models.entity.object")}</div>
</div>
</th>
<th scope="col" className="text-muted-foreground select-none truncate px-3 py-2 text-left text-xs font-medium tracking-wider">
{t("models.apiKey.create")}
</th>
<th scope="col" className="text-muted-foreground select-none truncate px-3 py-2 text-left text-xs font-medium tracking-wider">
{t("models.apiKey.read")}
</th>
<th scope="col" className="text-muted-foreground select-none truncate px-3 py-2 text-left text-xs font-medium tracking-wider">
{t("models.apiKey.update")}
</th>
<th scope="col" className="text-muted-foreground select-none truncate px-3 py-2 text-left text-xs font-medium tracking-wider">
{t("models.apiKey.delete")}
</th>
</tr>
</thead>
<tbody className="divide-border bg-background divide-y">
{entities
.filter((f) => (f.type === DefaultEntityTypes.AppOnly || f.type === DefaultEntityTypes.All) && f.hasApi)
.map((item, idx) => {
const entityLabel = t(item.title);
const entityDescription = `/${item.slug}`;
return (
<tr key={idx}>
<td className="text-muted-foreground whitespace-nowrap px-3 py-2 text-sm">
<input
type="hidden"
name="entities[]"
value={JSON.stringify({
entityId: item.id,
create: permissions.find((f) => f.entityId === item.id)?.create ?? false,
read: permissions.find((f) => f.entityId === item.id)?.read ?? false,
update: permissions.find((f) => f.entityId === item.id)?.update ?? false,
delete: permissions.find((f) => f.entityId === item.id)?.delete ?? false,
})}
/>
<div className="font-medium text-foreground">{entityLabel}</div>
<div className="text-xs">{entityDescription}</div>
</td>
<td className="text-muted-foreground whitespace-nowrap px-3 py-2 text-sm">
<InputCheckboxInline
disabled={!canUpdate}
name={`${item.id}-create`}
title={`${t("models.apiKey.create")} ${entityLabel}`}
help={`${t("models.apiKey.create")} ${entityDescription}`}
value={permissions.find((f) => f.entityId === item.id)?.create ?? false}
setValue={(e) =>
updateItemByIdx(permissions, setPermissions, idx, {
create: e,
})
}
/>
</td>
<td className="text-muted-foreground whitespace-nowrap px-3 py-2 text-sm">
<InputCheckboxInline
disabled={!canUpdate}
name={`${item.id}-read`}
title={`${t("models.apiKey.read")} ${entityLabel}`}
help={`${t("models.apiKey.read")} ${entityDescription}`}
value={permissions.find((f) => f.entityId === item.id)?.read ?? false}
setValue={(e) =>
updateItemByIdx(permissions, setPermissions, idx, {
read: e,
})
}
/>
</td>
<td className="text-muted-foreground whitespace-nowrap px-3 py-2 text-sm">
<InputCheckboxInline
disabled={!canUpdate}
name={`${item.id}-update`}
title={`${t("models.apiKey.update")} ${entityLabel}`}
help={`${t("models.apiKey.update")} ${entityDescription}`}
value={permissions.find((f) => f.entityId === item.id)?.update ?? false}
setValue={(e) =>
updateItemByIdx(permissions, setPermissions, idx, {
update: e,
})
}
/>
</td>
<td className="text-muted-foreground whitespace-nowrap px-3 py-2 text-sm">
<InputCheckboxInline
disabled={!canUpdate}
name={`${item.id}-delete`}
title={`${t("models.apiKey.delete")} ${entityLabel}`}
help={`${t("models.apiKey.delete")} ${entityDescription}`}
value={permissions.find((f) => f.entityId === item.id)?.delete ?? false}
setValue={(e) =>
updateItemByIdx(permissions, setPermissions, idx, {
delete: e,
})
}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
<InputCheckboxInline
disabled={!canUpdate}
className="col-span-12"
name="active"
title={t("models.apiKey.active")}
value={active}
setValue={setActive}
/>
</div>
</FormGroup>
);
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { readRepoFile } from "~/utils/testing/repoPaths";
describe("API log details i18n", () => {
it("keeps visible API log labels behind semantic translation keys", () => {
const tableSource = readRepoFile("apps/web/app/components/core/apiKeys/ApiKeyLogsTable.tsx");
const keysSource = readRepoFile("apps/web/app/components/core/apiKeys/ApiKeysTable.tsx");
const summarySource = readRepoFile("apps/web/app/modules/api/components/ApiKeyLogsSummary.tsx");
const apiDetailsSource = readRepoFile("apps/web/app/modules/api/components/ApiKeyLogsDetails.tsx");
const logDetailsSource = readRepoFile("apps/web/app/components/app/events/LogDetailsButton.tsx");
expect(tableSource).toContain("models.apiKeyLog.duration");
expect(tableSource).toContain("models.apiKeyLog.error");
expect(keysSource).toContain("models.apiKey.adminTenant");
expect(tableSource).toContain('t("shared.notSet")');
expect(apiDetailsSource).toContain('t("shared.notSet")');
expect(logDetailsSource).toContain("models.log.details");
for (const source of [tableSource, keysSource, summarySource, apiDetailsSource]) {
expect(source).toContain("useLocaleFormatters");
expect(source).not.toContain('from "~/utils/shared/DateUtils"');
expect(source).not.toContain('from "~/utils/shared/NumberUtils"');
}
expect(tableSource).not.toContain('title: "Duration"');
expect(tableSource).not.toContain('title: "Error"');
expect(keysSource).not.toContain('?? "- Admin -"');
expect(tableSource).not.toContain('text-gray-300">?</span>');
expect(apiDetailsSource).not.toContain('text-gray-300">?</span>');
expect(apiDetailsSource).not.toContain('text-xs italic">-</span>');
expect(logDetailsSource).not.toContain(">Details</div>");
});
});
@@ -0,0 +1,121 @@
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import type { ApiKeyLogWithDetails } from "~/utils/db/apiKeys.db.server";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import { Colors } from "~/application/enums/shared/Colors";
import TableSimple from "~/components/ui/tables/TableSimple";
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
import { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
import ApiUtils from "~/utils/app/ApiUtils";
import ShowPayloadModalButton from "~/components/ui/json/ShowPayloadModalButton";
import { useLocaleFormatters } from "~/locale/format";
interface Props {
items: ApiKeyLogWithDetails[];
withTenant: boolean;
pagination: PaginationDto;
}
export default function ApiKeyLogsTable({ withTenant, items, pagination }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const [headers, setHeaders] = useState<RowHeaderDisplayDto<ApiKeyLogWithDetails>[]>([]);
useEffect(() => {
let headers: RowHeaderDisplayDto<ApiKeyLogWithDetails>[] = [];
headers.push({
name: "createdAt",
title: t("shared.createdAt"),
value: (item) => formatters.dateTime(item.createdAt),
formattedValue: (item) => <div className="text-muted-foreground text-xs">{item.createdAt && <span>{formatters.dateTime(item.createdAt)}</span>}</div>,
});
if (withTenant) {
headers.push({
name: "tenant",
title: t("models.tenant.object"),
value: (item) => item.apiKey?.tenant.name,
});
}
headers.push({
name: "alias",
title: t("models.apiKey.alias"),
value: (item) => item.apiKey?.alias,
formattedValue: (item) => <div>{item.apiKey ? item.apiKey?.alias : <span className="text-gray-300">{t("shared.notSet")}</span>}</div>,
});
headers.push({
name: "ip",
title: t("models.apiKeyLog.ip"),
value: (item) => item.ip,
formattedValue: (item) => <div>{item.ip.length > 0 ? item.ip : <span className="text-gray-300">{t("shared.notSet")}</span>}</div>,
});
headers.push({
name: "endpoint",
title: t("models.apiKeyLog.endpoint"),
value: (item) => item.endpoint,
});
headers = [
...headers,
{
name: "method",
title: t("models.apiKeyLog.method"),
value: (item) => item.method,
formattedValue: (item) => (
<div>
<SimpleBadge title={item.method} color={ApiUtils.getMethodColor(item.method)} />
</div>
),
},
{
name: "status",
title: t("models.apiKeyLog.status"),
value: (item) => item.status,
formattedValue: (item) => (
<div>
{item.status ? (
<span>
<SimpleBadge title={item.status.toString()} color={item.status.toString().startsWith("4") ? Colors.RED : Colors.GREEN} />
</span>
) : (
<span className="text-gray-300">{t("shared.notSet")}</span>
)}
</div>
),
},
{
name: "duration",
title: t("models.apiKeyLog.duration"),
value: (item) => (
<div>
<div>
{item.duration ? (
<span>{formatters.number(item.duration / 1000, { style: "unit", unit: "second", unitDisplay: "short", maximumFractionDigits: 3 })}</span>
) : (
<span className="text-gray-300">{t("shared.notSet")}</span>
)}
</div>
</div>
),
},
{
name: "params",
title: t("models.apiKeyLog.params"),
value: (item) => <ShowPayloadModalButton description={item.params} payload={item.params} />,
},
{
name: "error",
title: t("models.apiKeyLog.error"),
value: (item) => <div className="text-red-500">{item.error}</div>,
},
];
setHeaders(headers);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [withTenant]);
return (
<div className="space-y-2">
<TableSimple items={items} headers={headers} pagination={pagination} />
</div>
);
}
@@ -0,0 +1,119 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { DefaultEntityTypes } from "~/application/dtos/shared/DefaultEntityTypes";
import ApiKeysTable from "./ApiKeysTable";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (value: string) => value }),
}));
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
};
});
vi.mock("~/utils/data/useRootData", () => ({ useRootData: () => ({}) }));
vi.mock("~/utils/helpers/AnalyticsHelper", () => ({ default: { addEvent: vi.fn() } }));
vi.mock("~/components/ui/input/InputSearch", () => ({
default: () => <input aria-label="search" />,
}));
vi.mock("~/components/ui/buttons/ButtonTertiary", () => ({
default: ({ children, to }: { children: React.ReactNode; to?: string }) => <a href={to}>{children}</a>,
}));
const now = new Date("2026-01-01T00:00:00.000Z");
const entity = {
id: "entity_contacts",
order: 1,
name: "contact",
title: "Contacts",
titlePlural: "Contacts",
active: true,
slug: "contacts",
type: DefaultEntityTypes.AppOnly,
hasApi: true,
showInSidebar: true,
icon: "users",
onEdit: null,
};
const apiKey = {
id: "api_key_1",
keyPrefix: "tc_live",
keyLastFour: "9xyz",
keyHash: "hash-value",
hashedAt: now,
keyVersion: 1,
alias: "Primary integration",
active: true,
expires: null,
createdAt: now,
tenantId: "tenant_1",
tenant: { id: "tenant_1", name: "Acme", slug: "acme", deactivatedReason: null },
createdByUserId: "user_1",
createdByUser: {
id: "user_1",
email: "admin@example.com",
firstName: "Ada",
lastName: "Admin",
phone: null,
locale: "en",
createdAt: now,
},
entities: [
{
id: "permission_1",
apiKeyId: "api_key_1",
entityId: "entity_contacts",
create: true,
read: true,
update: false,
delete: false,
entity: { id: "entity_contacts", title: "Contacts", titlePlural: "Contacts", slug: "contacts" },
},
],
_count: { apiKeyLogs: 2 },
};
describe("ApiKeysTable redacted key display", () => {
it("renders redacted metadata without raw key text or copy controls", () => {
const { container } = render(<ApiKeysTable entities={[entity]} items={[apiKey]} />);
expect(screen.getByText("tc_live…••••9xyz")).toBeInTheDocument();
expect(screen.queryByText("tc_live_raw_secret_that_must_not_render")).not.toBeInTheDocument();
expect(container).not.toHaveTextContent("tc_live_raw_secret_that_must_not_render");
expect(screen.queryByRole("button", { name: "shared.copy" })).not.toBeInTheDocument();
});
it("keeps inactive state visible alongside redacted metadata", () => {
render(<ApiKeysTable entities={[entity]} items={[{ ...apiKey, active: false }]} />);
expect(screen.getByText("tc_live…••••9xyz")).toBeInTheDocument();
expect(screen.getByText("shared.inactive")).toBeInTheDocument();
});
it("sorts rendered rows without mutating the source items", () => {
const olderApiKey = { ...apiKey, id: "api_key_older", alias: "Older integration", createdAt: new Date("2025-01-01T00:00:00.000Z") };
const newerApiKey = { ...apiKey, id: "api_key_newer", alias: "Newer integration", createdAt: new Date("2026-01-01T00:00:00.000Z") };
const sourceItems = [olderApiKey, newerApiKey];
render(<ApiKeysTable entities={[entity]} items={sourceItems} />);
expect(screen.getByText("Newer integration")).toBeInTheDocument();
expect(sourceItems.map((item) => item.id)).toEqual(["api_key_older", "api_key_newer"]);
});
it("prepares reusable row metadata outside the table row render loop", () => {
const source = readFileSync(path.resolve(process.cwd(), "apps/web/app/components/core/apiKeys/ApiKeysTable.tsx"), "utf8");
expect(source).toContain("const filteredSortedRows = useMemo<ApiKeyTableRow[]>(");
expect(source).toContain("apiKeyEntityById: new Map(item.entities.map");
expect(source).toContain("redactedKey: getRedactedKey(item)");
});
});
@@ -0,0 +1,208 @@
import clsx from "clsx";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { DefaultEntityTypes } from "~/application/dtos/shared/DefaultEntityTypes";
import ButtonTertiary from "~/components/ui/buttons/ButtonTertiary";
import EmptyState from "~/components/ui/emptyState/EmptyState";
import InputSearch from "~/components/ui/input/InputSearch";
import { useLocaleFormatters } from "~/locale/format";
import type { RedactedApiKeyWithDetails } from "~/utils/db/apiKeys.db.server";
import type { EntitySimple } from "~/utils/db/entities/entities.db.server";
interface Props {
entities: EntitySimple[];
items: RedactedApiKeyWithDetails[];
withTenant?: boolean;
canCreate?: boolean;
}
type Header = {
name?: string;
title: string;
};
type ApiKeyTableRow = {
apiKeyEntityById: Map<string, RedactedApiKeyWithDetails["entities"][number]>;
item: RedactedApiKeyWithDetails;
redactedKey: string;
};
function getRedactedKey(item: RedactedApiKeyWithDetails) {
return [item.keyPrefix, item.keyLastFour ? `••••${item.keyLastFour}` : undefined].filter(Boolean).join("…") || "••••";
}
export default function ApiKeysTable({ entities, items, withTenant, canCreate }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const [searchInput, setSearchInput] = useState("");
const apiEntities = useMemo(
() => entities.filter((f) => (f.type === DefaultEntityTypes.AppOnly || f.type === DefaultEntityTypes.All) && f.hasApi),
[entities]
);
const headers = useMemo(() => {
const nextHeaders: Header[] = [];
if (withTenant) {
nextHeaders.push({ name: "tenant", title: t("models.tenant.object") });
}
nextHeaders.push({ name: "key", title: t("models.apiKey.key") });
nextHeaders.push({ name: "alias", title: t("models.apiKey.alias") });
nextHeaders.push({ name: "usage", title: t("models.apiKey.usage") });
apiEntities.forEach((entity) => {
nextHeaders.push({ name: "entityId", title: t(entity.titlePlural) });
});
nextHeaders.push({ name: "expires", title: t("models.apiKey.expires") });
nextHeaders.push({ name: "createdAt", title: t("shared.createdAt") });
nextHeaders.push({ name: "createdByUser", title: t("shared.createdBy") });
return nextHeaders;
}, [apiEntities, t, withTenant]);
const filteredSortedRows = useMemo<ApiKeyTableRow[]>(() => {
if (!items) {
return [];
}
const normalizedSearchInput = searchInput.trim().toUpperCase();
const filtered = normalizedSearchInput ? items.filter((item) => item.tenant?.name?.toString()?.toUpperCase().includes(normalizedSearchInput)) : [...items];
return filtered
.sort((x, y) => {
if (x.createdAt && y.createdAt) {
return x.createdAt > y.createdAt ? -1 : 1;
}
return -1;
})
.map((item) => ({
apiKeyEntityById: new Map(item.entities.map((apiKeyEntity) => [apiKeyEntity.entityId, apiKeyEntity])),
item,
redactedKey: getRedactedKey(item),
}));
}, [items, searchInput]);
// function deleteApiKey(item: ApiKey) {
// if (confirmDelete.current) {
// confirmDelete.current.setValue(item);
// confirmDelete.current.show(t("shared.delete"), t("shared.delete"), t("shared.cancel"), t("shared.warningCannotUndo"));
// }
// }
// function confirmDeleteApiKey(item: ApiKey) {
// const form = new FormData();
// form.set("action", "delete");
// form.set("id", item.id);
// submit(form, {
// method: "post",
// });
// }
return (
<div className="space-y-2">
<InputSearch value={searchInput} setValue={setSearchInput} onNewRoute={canCreate ? "new" : ""} />
{(() => {
if (filteredSortedRows.length === 0) {
return (
<div>
<EmptyState
className="bg-background"
captions={{
thereAreNo: t("shared.noRecords"),
}}
/>
</div>
);
} else {
return (
<div>
<div>
<div className="flex flex-col">
<div className="overflow-x-auto">
<div className="inline-block min-w-full py-2 align-middle">
<div className="border-border overflow-hidden border shadow-xs sm:rounded-lg">
<table className="divide-border min-w-full divide-y">
<thead className="bg-secondary">
<tr>
{headers.map((header, idx) => {
return (
<th
key={idx}
scope="col"
className="text-muted-foreground truncate px-3 py-2 text-left text-xs font-medium tracking-wider select-none"
>
<div className="text-muted-foreground flex items-center space-x-1">
<div>{header.title}</div>
</div>
</th>
);
})}
</tr>
</thead>
<tbody className="divide-border bg-background divide-y">
{filteredSortedRows.map(({ apiKeyEntityById, item, redactedKey }, idx) => {
return (
<tr key={idx}>
{withTenant && (
<td className="text-muted-foreground px-3 py-2 text-sm whitespace-nowrap">
{item.tenant?.name ?? t("models.apiKey.adminTenant")}
</td>
)}
<td className="text-muted-foreground px-3 py-2 text-sm whitespace-nowrap">
<span className="font-mono text-xs" aria-label={`${t("models.apiKey.key")} ${redactedKey}`}>
{redactedKey}
</span>
{!item.active && <span className="ml-2 text-red-600">{t("shared.inactive")}</span>}
</td>
<td className="text-muted-foreground px-3 py-2 text-sm whitespace-nowrap">{item.alias}</td>
<td className="text-muted-foreground px-3 py-2 text-sm whitespace-nowrap">
{formatters.number(item._count.apiKeyLogs)}{" "}
{item._count.apiKeyLogs === 1 ? t("models.apiCall.object") : t("models.apiCall.plural")}
</td>
{apiEntities.map((entity) => {
const apiKeyEntity = apiKeyEntityById.get(entity.id);
return (
<td key={entity.id} className="text-muted-foreground px-3 py-2 text-sm whitespace-nowrap">
<div className="flex items-center">
<span className={clsx(apiKeyEntity?.create ? "text-foreground font-bold" : "text-red-400")}>C</span>
<span className={clsx(apiKeyEntity?.read ? "text-foreground font-bold" : "text-red-400")}>R</span>
<span className={clsx(apiKeyEntity?.update ? "text-foreground font-bold" : "text-red-400")}>U</span>
<span className={clsx(apiKeyEntity?.delete ? "text-foreground font-bold" : "text-red-400")}>D</span>
</div>
</td>
);
})}
<td className="text-muted-foreground px-3 py-2 text-sm whitespace-nowrap">
<time
dateTime={item.expires ? new Date(item.expires).toISOString() : undefined}
title={item.expires ? formatters.dateTime(item.expires) : undefined}
>
{item.expires === null ? <span>-</span> : <span>{formatters.relativeDate(item.expires)}</span>}
</time>
</td>
<td className="text-muted-foreground px-3 py-2 text-sm whitespace-nowrap">
<time dateTime={new Date(item.createdAt).toISOString()} title={formatters.dateTime(item.createdAt)}>
{formatters.dateTime(item.createdAt)}
</time>
</td>
<td className="text-muted-foreground px-3 py-2 text-sm whitespace-nowrap">
<span className="text-muted-foreground text-xs">{item.createdByUser.email}</span>
</td>
<td className="text-muted-foreground px-3 py-2 text-sm whitespace-nowrap">
<div className="flex items-center space-x-2">
<ButtonTertiary to={item.id}>{t("shared.edit")}</ButtonTertiary>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
})()}
</div>
);
}
@@ -0,0 +1,86 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { SubscriptionFeatureDto } from "~/application/dtos/subscriptions/SubscriptionFeatureDto";
import { ChevronUpIcon, ChevronDownIcon } from "~/components/ui/icons/vendor";
interface Props {
index: number | undefined;
items: SubscriptionFeatureDto[];
onChange: (items: SubscriptionFeatureDto[]) => void;
editable?: boolean;
}
export default function FeatureOrderButtons({ index, items, editable = true, onChange }: Props) {
const { t } = useTranslation();
function changeOrder(forward: boolean) {
if (index === undefined) {
return;
}
const currentItem = items[index];
let nextItem: SubscriptionFeatureDto | undefined = undefined;
let prevItem: SubscriptionFeatureDto | undefined = undefined;
if (forward) {
if (items.length > index + 1) {
nextItem = items[index + 1];
}
} else {
if (index - 1 >= 0) {
prevItem = items[index - 1];
}
}
const newItems = [...items];
newItems.forEach((item, idx) => {
let order = 0;
if (currentItem === item) {
order = idx + (forward ? 1 : -1) + 1;
} else if (prevItem === item) {
order = idx + (forward ? 0 : 1) + 1;
} else if (nextItem === item) {
order = idx + (forward ? -1 : 0) + 1;
} else {
order = idx + 1;
}
item.order = order;
});
onChange(newItems);
}
function isLastItem() {
return index === items.length - 1;
// const maxOrder = Math.max(...items.map((o) => o.order));
// return order === maxOrder;
}
return (
<>
{index !== undefined && (
<div className="flex items-center space-x-1 truncate">
{items[index].order}
<button
type="button"
onClick={() => changeOrder(false)}
className={clsx(
index <= 0 || !editable ? " bg-secondary/90 cursor-not-allowed text-gray-300" : "hover:text-foreground hover:bg-secondary/90",
"text-muted-foreground bg-secondary h-4 w-4 px-0.5 py-0.5 focus:outline-hidden"
)}
disabled={index <= 0 || !editable}
aria-label={t("shared.moveUp")}
>
<ChevronUpIcon className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => changeOrder(true)}
className={clsx(
isLastItem() || !editable ? " bg-secondary/90 cursor-not-allowed text-gray-300" : "hover:text-foreground hover:bg-secondary/90",
"text-muted-foreground bg-secondary h-4 w-4 px-0.5 py-0.5 focus:outline-hidden"
)}
disabled={isLastItem() || !editable}
aria-label={t("shared.moveDown")}
>
<ChevronDownIcon className="h-3 w-3" />
</button>
</div>
)}
</>
);
}
@@ -0,0 +1,278 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
import { FlatPriceDto } from "~/application/dtos/subscriptions/FlatPriceDto";
import { SubscriptionPriceDto } from "~/application/dtos/subscriptions/SubscriptionPriceDto";
import { InputType } from "~/application/enums/shared/InputType";
import { PricingModel } from "~/application/enums/subscriptions/PricingModel";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import billingPeriods from "~/application/pricing/billingPeriods";
import currencies from "~/application/pricing/currencies";
import InputCombobox from "~/components/ui/input/InputCombobox";
import InputNumber from "~/components/ui/input/InputNumber";
import TableSimple from "~/components/ui/tables/TableSimple";
import { updateItemByIdx } from "~/utils/shared/ObjectUtils";
import { useLocaleFormatters } from "~/locale/format";
interface Props {
model: PricingModel;
prices: SubscriptionPriceDto[];
setPrices: React.Dispatch<React.SetStateAction<SubscriptionPriceDto[]>>;
disabled: boolean;
isPortalPlan?: boolean;
}
export default function FlatPrices({ model, prices, setPrices, disabled, isPortalPlan }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const [headers, setHeaders] = useState<RowHeaderDisplayDto<FlatPriceDto>[]>([]);
const [flatPrices, setFlatPrices] = useState<FlatPriceDto[]>([]);
const [selectedBillingPeriods, setSelectedBillingPeriods] = useState<SubscriptionBillingPeriod[]>([
SubscriptionBillingPeriod.MONTHLY,
SubscriptionBillingPeriod.YEARLY,
]);
const [trialPeriodDays, setTrialPeriodDays] = useState<number | undefined>(0);
// Set initial flat prices
useEffect(() => {
const flatPrices: FlatPriceDto[] = [];
currencies
.filter((f) => !f.disabled)
.forEach((currency) => {
const currencyPrices = prices.filter((f) => f.currency === currency.value);
const oneTimePrice = currencyPrices.find((f) => f.billingPeriod === SubscriptionBillingPeriod.ONCE);
const monthlyPrice = currencyPrices.find((f) => f.billingPeriod === SubscriptionBillingPeriod.MONTHLY);
const quarterlyPrice = currencyPrices.find((f) => f.billingPeriod === SubscriptionBillingPeriod.QUARTERLY);
const semiAnnualPrice = currencyPrices.find((f) => f.billingPeriod === SubscriptionBillingPeriod.SEMI_ANNUAL);
const yearlyPrice = currencyPrices.find((f) => f.billingPeriod === SubscriptionBillingPeriod.YEARLY);
flatPrices.push({
currency: currency.value,
monthlyPrice: monthlyPrice?.price ? Number(monthlyPrice.price) : undefined,
yearlyPrice: yearlyPrice?.price ? Number(yearlyPrice.price) : undefined,
oneTimePrice: oneTimePrice?.price ? Number(oneTimePrice.price) : undefined,
quarterlyPrice: quarterlyPrice?.price ? Number(quarterlyPrice.price) : undefined,
semiAnnualPrice: semiAnnualPrice?.price ? Number(semiAnnualPrice.price) : undefined,
});
});
if (disabled) {
let billingPeriods = prices.flatMap((item) => item.billingPeriod);
// unique
billingPeriods = billingPeriods.filter((v, i, a) => a.indexOf(v) === i);
setSelectedBillingPeriods(billingPeriods);
}
setFlatPrices(flatPrices);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update prices
useEffect(() => {
const newPrices: SubscriptionPriceDto[] = [];
flatPrices.forEach((flatPrice) => {
if (flatPrice.oneTimePrice !== undefined) {
newPrices.push({
currency: flatPrice.currency,
billingPeriod: SubscriptionBillingPeriod.ONCE,
price: flatPrice.oneTimePrice,
trialDays: trialPeriodDays,
} as SubscriptionPriceDto);
}
if (flatPrice.monthlyPrice !== undefined) {
newPrices.push({
currency: flatPrice.currency,
billingPeriod: SubscriptionBillingPeriod.MONTHLY,
price: flatPrice.monthlyPrice,
trialDays: trialPeriodDays,
} as SubscriptionPriceDto);
}
if (flatPrice.yearlyPrice !== undefined) {
newPrices.push({
currency: flatPrice.currency,
billingPeriod: SubscriptionBillingPeriod.YEARLY,
price: flatPrice.yearlyPrice,
trialDays: trialPeriodDays,
} as SubscriptionPriceDto);
}
if (flatPrice.quarterlyPrice !== undefined) {
newPrices.push({
currency: flatPrice.currency,
billingPeriod: SubscriptionBillingPeriod.QUARTERLY,
price: flatPrice.quarterlyPrice,
trialDays: trialPeriodDays,
} as SubscriptionPriceDto);
}
if (flatPrice.semiAnnualPrice !== undefined) {
newPrices.push({
currency: flatPrice.currency,
billingPeriod: SubscriptionBillingPeriod.SEMI_ANNUAL,
price: flatPrice.semiAnnualPrice,
trialDays: trialPeriodDays,
} as SubscriptionPriceDto);
}
});
setPrices(newPrices as SubscriptionPriceDto[]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flatPrices, trialPeriodDays]);
useEffect(() => {
let headers: RowHeaderDisplayDto<FlatPriceDto>[] = [
{
name: "currency",
title: t("admin.pricing.prices.fields.currency"),
value: (e) => e.currency?.toUpperCase(),
formattedValue: (e) => (
<div>
{e.currency?.toUpperCase()}
{/* currencies.find((o) => o.value === e.currency)?.name */}
</div>
),
},
];
if (model !== PricingModel.ONCE) {
headers = [
...headers,
{
name: "monthlyPrice",
title: t("admin.pricing.prices.fields.monthlyPrice"),
value: (e) => e.monthlyPrice,
type: InputType.NUMBER,
inputNumberStep: "0.01",
setValue: (e, idx) => updateItemByIdx(flatPrices, setFlatPrices, idx, { monthlyPrice: e }),
editable: () => !disabled,
inputOptional: true,
hidden: !selectedBillingPeriods.find((f) => f === SubscriptionBillingPeriod.MONTHLY),
},
{
name: "quarterlyPrice",
title: t("admin.pricing.prices.fields.quarterlyPrice"),
value: (e) => e.quarterlyPrice,
type: InputType.NUMBER,
inputNumberStep: "0.01",
setValue: (e, idx) => updateItemByIdx(flatPrices, setFlatPrices, idx, { quarterlyPrice: e }),
editable: () => !disabled,
inputOptional: true,
hidden: !selectedBillingPeriods.find((f) => f === SubscriptionBillingPeriod.QUARTERLY),
},
{
name: "semiAnnualPrice",
title: t("admin.pricing.prices.fields.semiAnnualPrice"),
value: (e) => e.semiAnnualPrice,
type: InputType.NUMBER,
inputNumberStep: "0.01",
setValue: (e, idx) => updateItemByIdx(flatPrices, setFlatPrices, idx, { semiAnnualPrice: e }),
editable: () => !disabled,
inputOptional: true,
hidden: !selectedBillingPeriods.find((f) => f === SubscriptionBillingPeriod.SEMI_ANNUAL),
},
{
name: "yearlyPrice",
title: t("admin.pricing.prices.fields.yearlyPrice"),
value: (e) => e.yearlyPrice,
type: InputType.NUMBER,
inputNumberStep: "0.01",
setValue: (e, idx) => updateItemByIdx(flatPrices, setFlatPrices, idx, { yearlyPrice: e }),
editable: () => !disabled,
inputOptional: true,
hidden: !selectedBillingPeriods.find((f) => f === SubscriptionBillingPeriod.YEARLY),
},
{
name: "discount",
title: t("admin.pricing.prices.fields.yearlyDiscount"),
className: "text-center",
value: (e) => getYearlyDiscount(e),
formattedValue: (e) => (
<div className="flex justify-center">
{getYearlyDiscount(e) ? (
<span className="ml-1 inline-flex items-center rounded-md bg-teal-100 px-2.5 py-0.5 text-sm font-medium text-teal-800">
{getYearlyDiscount(e)}
</span>
) : (
<div className="text-muted-foreground text-xs italic">{t("shared.notApplicable")}</div>
)}
</div>
),
hidden:
!selectedBillingPeriods.find((f) => f === SubscriptionBillingPeriod.YEARLY) ||
!selectedBillingPeriods.find((f) => f === SubscriptionBillingPeriod.MONTHLY),
},
];
} else {
headers = [
...headers,
{
name: "oneTimePrice",
title: t("admin.pricing.prices.fields.oneTimePrice"),
value: (e) => e.oneTimePrice,
type: InputType.NUMBER,
inputNumberStep: "0.01",
setValue: (e, idx) => updateItemByIdx(flatPrices, setFlatPrices, idx, { oneTimePrice: e }),
editable: () => !disabled,
inputOptional: true,
},
];
}
setHeaders(headers);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prices, model, selectedBillingPeriods]);
useEffect(() => {
const priceWithTrialDays = prices.find((f) => f.trialDays > 0);
setTrialPeriodDays(priceWithTrialDays?.trialDays ?? 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model]);
function getYearlyDiscount(item: FlatPriceDto): string | undefined {
if (item.yearlyPrice && item.monthlyPrice) {
const discount = 100 - (item.yearlyPrice * 100) / (item.monthlyPrice * 12);
if (discount > 0) {
return t("admin.pricing.prices.discountOff", { percent: formatters.percent(discount / 100, { maximumFractionDigits: 0 }) });
}
}
return undefined;
}
return (
<>
<div className="space-y-2 divide-gray-300">
{model !== PricingModel.ONCE && (
<div className="w-72">
<InputCombobox
withSearch={false}
disabled={disabled}
name="selectedBillingPeriods"
title={t("admin.pricing.prices.fields.billingPeriods")}
value={selectedBillingPeriods}
onChange={(e) => {
setSelectedBillingPeriods(e as number[]);
}}
options={billingPeriods
.filter((f) => !f.disabled && f.recurring)
.map((item) => {
return {
name: t("pricing." + SubscriptionBillingPeriod[item.value]),
value: item.value,
};
})}
minDisplayCount={5}
/>
</div>
)}
<TableSimple items={flatPrices} headers={headers} actions={[]} />
{model === PricingModel.FLAT_RATE && !isPortalPlan && (
<div className="w-32">
<InputNumber disabled={disabled} name="trialPeriodDays" title={t("admin.pricing.prices.fields.trialPeriodDays")} value={trialPeriodDays} setValue={setTrialPeriodDays} />
</div>
)}
{prices.map((item, idx) => {
return (
<div key={idx} className=" ">
<input hidden readOnly type="text" id="prices[]" name="prices[]" value={JSON.stringify(item)} />
</div>
);
})}
</div>
</>
);
}
@@ -0,0 +1,183 @@
import { Entity } from "@toolctrl/db/prisma-browser";
import { useTranslation } from "react-i18next";
import { SubscriptionFeatureDto } from "~/application/dtos/subscriptions/SubscriptionFeatureDto";
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
import InputNumber from "~/components/ui/input/InputNumber";
import InputSelect from "~/components/ui/input/InputSelect";
import InputText from "~/components/ui/input/InputText";
import CollapsibleRow from "~/components/ui/tables/CollapsibleRow";
import { updateItemByIdx } from "~/utils/shared/ObjectUtils";
import { useLocaleFormatters } from "~/locale/format";
import { CheckCircleIcon, XCircleIcon, PlusIcon } from "~/components/ui/icons/vendor";
interface Props {
items: SubscriptionFeatureDto[];
setItems: React.Dispatch<React.SetStateAction<SubscriptionFeatureDto[]>>;
entities: Entity[];
}
export default function PricingFeaturesForm({ items, setItems, entities }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
function addFeature() {
const maxOrder = Math.max(...items.map((o) => o.order));
setItems([
...items,
{
order: maxOrder + 1,
title: "",
name: "",
type: SubscriptionFeatureLimitType.NOT_INCLUDED,
value: 0,
accumulate: false,
},
]);
}
// useEffect(() => {
// for (let idx = 0; idx < items.length; idx++) {
// const item = items[idx];
// if (item.type !== SubscriptionFeatureLimitType.MAX && item.type !== SubscriptionFeatureLimitType.MONTHLY) {
// updateItemByIdx(items, setItems, idx, {
// value: 0,
// });
// }
// }
// }, [items]);
return (
<>
<div className=" border-border divide-border divide-y border-b">
{items.map((item, idx) => {
return (
<div key={item.name} className=" ">
<input hidden readOnly type="text" id="features[]" name="features[]" value={JSON.stringify(item)} />
<CollapsibleRow
title={t("admin.pricing.features.editor.featureDetails", { number: idx + 1 })}
value={
<div className="flex space-x-1">
{item.title ? (
<>
<div className="flex space-x-2">
{item.type !== SubscriptionFeatureLimitType.NOT_INCLUDED ? (
<CheckCircleIcon className="text-theme-500 h-5 w-5" aria-hidden="true" />
) : (
<XCircleIcon className="h-5 w-5 text-gray-300" />
)}
</div>
{item.type === SubscriptionFeatureLimitType.MONTHLY && <span>{t("admin.pricing.features.limitTypes.monthly")}</span>}
{item.type === SubscriptionFeatureLimitType.MAX && <span>{t("admin.pricing.features.limitTypes.max")}</span>}
{item.type === SubscriptionFeatureLimitType.UNLIMITED && <span>{t("admin.pricing.features.limitTypes.unlimited")}</span>}
<div>{t(item.title)}</div>
{(item.type === SubscriptionFeatureLimitType.MONTHLY || item.type === SubscriptionFeatureLimitType.MAX) && <div>{formatters.number(item.value)}</div>}
</>
) : (
<>{t("shared.notSet")}</>
)}
</div>
}
onRemove={() => {
setItems(items.filter((_x, i) => i !== idx));
}}
>
<div className="relative grid grid-cols-12 items-center gap-2 py-3">
<div className="col-span-6">
<InputText
borderless
name="feature-title"
title={t("admin.pricing.features.fields.title")}
required
value={item.title}
className="text-xs sm:text-xs"
setValue={(title) =>
updateItemByIdx(items, setItems, idx, {
title,
})
}
withTranslation={true}
translationParams={[formatters.number(item.value)]}
/>
</div>
<div className="col-span-6">
<InputText
borderless
name="feature-name"
title={t("admin.pricing.features.fields.name")}
required
value={item.name}
className="text-xs sm:text-xs"
setValue={(name) =>
updateItemByIdx(items, setItems, idx, {
name,
})
}
help={t("admin.pricing.features.help.internalId")}
/>
</div>
<div className="col-span-6">
<InputSelect
borderless
className="text-xs sm:text-xs"
name="feature-type"
title={t("admin.pricing.features.fields.type")}
value={item.type}
setValue={(type) =>
updateItemByIdx(items, setItems, idx, {
type,
})
}
options={[
{
name: t("admin.pricing.features.limitTypes.notIncluded"),
value: SubscriptionFeatureLimitType.NOT_INCLUDED,
},
{
name: t("admin.pricing.features.limitTypes.included"),
value: SubscriptionFeatureLimitType.INCLUDED,
},
{
name: t("admin.pricing.features.limitTypes.monthly"),
value: SubscriptionFeatureLimitType.MONTHLY,
},
{
name: t("admin.pricing.features.limitTypes.max"),
value: SubscriptionFeatureLimitType.MAX,
},
{
name: t("admin.pricing.features.limitTypes.unlimited"),
value: SubscriptionFeatureLimitType.UNLIMITED,
},
]}
/>
</div>
<div className="col-span-6">
<InputNumber
borderless
name="feature-value"
title={t("admin.pricing.features.fields.limit")}
value={item.value}
className="text-xs sm:text-xs"
setValue={(value) =>
updateItemByIdx(items, setItems, idx, {
value,
})
}
/>
</div>
</div>
</CollapsibleRow>
</div>
);
})}
</div>
<button
type="button"
onClick={addFeature}
className="border-border text-muted-foreground focus:text-foreground bg-background hover:bg-secondary/90 mt-2 flex items-center space-x-1 rounded-md border px-2 py-1 text-xs focus:ring-3 focus:ring-gray-300 focus:ring-offset-1"
>
<PlusIcon className="h-5 w-5" />
<span className="font-medium uppercase">{t("shared.add")}</span>
</button>
</>
);
}
@@ -0,0 +1,300 @@
import { Menu } from "@headlessui/react";
import clsx from "clsx";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { SubscriptionFeatureDto } from "~/application/dtos/subscriptions/SubscriptionFeatureDto";
import { SubscriptionProductDto } from "~/application/dtos/subscriptions/SubscriptionProductDto";
import { InputType } from "~/application/enums/shared/InputType";
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
import Dropdown from "~/components/ui/dropdowns/Dropdown";
import DocumentDuplicateIcon from "~/components/ui/icons/DocumentDuplicateIcon";
import TrashEmptyIcon from "~/components/ui/icons/TrashEmptyIcon";
import TableSimple from "~/components/ui/tables/TableSimple";
import { updateItemByIdx } from "~/utils/shared/ObjectUtils";
import { useLocaleFormatters } from "~/locale/format";
import { ChevronUpIcon, ChevronDownIcon, PlusIcon } from "~/components/ui/icons/vendor";
interface Props {
plans: SubscriptionProductDto[] | undefined;
items: SubscriptionFeatureDto[];
setItems: React.Dispatch<React.SetStateAction<SubscriptionFeatureDto[]>>;
}
export default function PricingFeaturesTable({ plans, items, setItems }: Props) {
const { t } = useTranslation();
function onAddFeature() {
const order = items.length === 0 ? 1 : Math.max(...items.map((o) => o.order)) + 1;
setItems([
...items,
{
order,
title: "",
name: "",
type: SubscriptionFeatureLimitType.NOT_INCLUDED,
value: 0,
accumulate: false,
},
]);
}
function changeOrder(item: SubscriptionFeatureDto, index: number, direction: "up" | "down") {
const newItems = [...items];
const newIndex = direction === "up" ? index - 1 : index + 1;
newItems.splice(index, 1);
newItems.splice(newIndex, 0, item);
for (let i = 0; i < newItems.length; i++) {
newItems[i].order = i + 1;
}
setItems(newItems);
}
function isLastItem(index: number) {
return index === items.length - 1;
}
return (
<div className="space-y-2">
<Buttons plans={plans} items={items} setItems={setItems} onAddFeature={onAddFeature} />
<div className="">
<TableSimple
headers={[
{
title: "",
name: "feature-order",
className: "w-10",
// value: (item) => item.order,
// type: InputType.NUMBER,
// setValue: (order, idx) =>
// updateItemByIdx(items, setItems, idx, {
// order,
// }),
// inputBorderless: true,
value: (item, index) => (
<div className="w-10">
<div className="flex items-center space-x-1 truncate">
<button
title={t("shared.moveUp")}
type="button"
onClick={() => changeOrder(item, index, "up")}
className={clsx(
index <= 0 ? " bg-secondary/90 cursor-not-allowed text-gray-300" : "hover:text-foreground hover:bg-secondary/90",
"text-muted-foreground bg-secondary h-4 w-4 px-0.5 py-0.5 focus:outline-hidden"
)}
disabled={index <= 0}
>
<ChevronUpIcon className="h-3 w-3" />
</button>
<button
title={t("shared.moveDown")}
type="button"
onClick={() => changeOrder(item, index, "down")}
className={clsx(
isLastItem(index) ? " bg-secondary/90 cursor-not-allowed text-gray-300" : "hover:text-foreground hover:bg-secondary/90",
"text-muted-foreground bg-secondary h-4 w-4 px-0.5 py-0.5 focus:outline-hidden"
)}
disabled={isLastItem(index)}
>
<ChevronDownIcon className="h-3 w-3" />
</button>
</div>
</div>
),
},
{
title: t("shared.title"),
name: "feature-title",
className: "w-32",
value: (item) => item.title,
setValue: (title, idx) =>
updateItemByIdx(items, setItems, idx, {
title,
}),
inputBorderless: true,
},
{
title: t("shared.type"),
name: "feature-type",
type: InputType.SELECT,
value: (item) => item.type,
className: "w-32",
options: [
{
name: t("admin.pricing.features.limitTypes.notIncluded"),
value: SubscriptionFeatureLimitType.NOT_INCLUDED,
},
{
name: t("admin.pricing.features.limitTypes.included"),
value: SubscriptionFeatureLimitType.INCLUDED,
},
{
name: t("admin.pricing.features.limitTypes.monthly"),
value: SubscriptionFeatureLimitType.MONTHLY,
},
{
name: t("admin.pricing.features.limitTypes.max"),
value: SubscriptionFeatureLimitType.MAX,
},
{
name: t("admin.pricing.features.limitTypes.unlimited"),
value: SubscriptionFeatureLimitType.UNLIMITED,
},
],
setValue: (type, idx) => {
let value = items[idx].value;
if (Number(type) !== SubscriptionFeatureLimitType.MAX && Number(type) !== SubscriptionFeatureLimitType.MONTHLY) {
value = 0;
}
updateItemByIdx(items, setItems, idx, {
type,
value,
});
},
inputBorderless: true,
},
{
title: t("shared.name"),
name: "feature-name",
className: "w-32",
inputOptional: true,
value: (item) => item.name,
setValue: (name, idx) =>
updateItemByIdx(items, setItems, idx, {
name,
}),
inputBorderless: true,
},
{
title: t("admin.pricing.features.fields.limit"),
name: "feature-value",
type: InputType.NUMBER,
className: "w-32",
value: (item) => item.value,
editable: (item) => item.type === SubscriptionFeatureLimitType.MAX || item.type === SubscriptionFeatureLimitType.MONTHLY,
setValue: (value, idx) =>
updateItemByIdx(items, setItems, idx, {
value,
}),
inputBorderless: true,
},
{
title: t("admin.pricing.features.fields.url"),
name: "feature-href",
type: InputType.TEXT,
className: "w-32",
inputOptional: true,
value: (item) => item.href,
setValue: (href, idx) =>
updateItemByIdx(items, setItems, idx, {
href,
}),
inputBorderless: true,
},
{
title: t("admin.pricing.features.fields.badge"),
name: "feature-badge",
type: InputType.TEXT,
className: "w-32",
inputOptional: true,
value: (item) => item.badge,
setValue: (badge, idx) =>
updateItemByIdx(items, setItems, idx, {
badge,
}),
inputBorderless: true,
},
]}
items={items}
actions={[
{
title: t("shared.delete"),
onClick: (idx) => setItems(items.filter((_x, i) => i !== idx)),
},
]}
></TableSimple>
{items.map((item, idx) => {
return (
<div key={idx} className=" ">
<input readOnly hidden type="text" id="features[]" name="features[]" value={JSON.stringify(item)} />
</div>
);
})}
</div>
{items.length > 10 && <Buttons plans={plans} items={items} setItems={setItems} onAddFeature={onAddFeature} />}
</div>
);
}
function Buttons({
plans,
items,
setItems,
onAddFeature,
}: {
plans: SubscriptionProductDto[] | undefined;
items: SubscriptionFeatureDto[];
setItems: React.Dispatch<React.SetStateAction<SubscriptionFeatureDto[]>>;
onAddFeature: () => void;
}) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<div className="flex items-center space-x-2">
<button
type="button"
onClick={onAddFeature}
className="border-border text-muted-foreground focus:text-foreground bg-background hover:bg-secondary/90 mt-2 flex items-center space-x-1 rounded-md border px-2 py-1 text-xs focus:ring-3 focus:ring-gray-300 focus:ring-offset-1"
>
<PlusIcon className="h-4 w-4" />
<span className="font-medium uppercase">{t("shared.add")}</span>
</button>
<Dropdown
right={true}
// onClick={() => alert("Dropdown click")}
button={
<Fragment>
<DocumentDuplicateIcon className="h-4 w-4" />
<span className="font-medium uppercase">{t("admin.pricing.features.actions.copyFromProduct")}</span>
</Fragment>
}
btnClassName="mt-2 flex items-center space-x-1 rounded-md border border-border bg-background px-2 py-1 text-xs text-muted-foreground hover:bg-secondary/90 focus:text-foreground focus:ring-3 focus:ring-gray-300 focus:ring-offset-1"
options={
<div className="h-64 overflow-auto">
{plans?.map((product, idx) => {
return (
<Menu.Item key={idx}>
{({ active }) => (
<button
type="button"
onClick={() => {
let uniqueFeatures = product.features.filter((feature) => !items.find((item) => item.name === feature.name));
setItems([...items, ...uniqueFeatures]);
}}
className={clsx("w-full text-left", active ? "text-foreground bg-secondary/90" : "text-foreground/80", "block px-4 py-2 text-sm")}
>
{t(product.title)} ({formatters.number(product.features.length)})
</button>
)}
</Menu.Item>
);
})}
</div>
}
></Dropdown>
<button
type="button"
onClick={() => {
setItems([]);
}}
disabled={items.length === 0}
className={clsx(
"border-border text-muted-foreground focus:text-foreground mt-2 flex items-center space-x-1 rounded-md border px-2 py-1 text-xs focus:ring-3 focus:ring-gray-300 focus:ring-offset-1",
items.length === 0 ? "cursor-not-allowed opacity-80" : "bg-background hover:bg-secondary/90"
)}
>
<TrashEmptyIcon className="h-4 w-4" />
<span className="font-medium uppercase">{t("shared.clear")}</span>
</button>
</div>
);
}
@@ -0,0 +1,482 @@
import { Fragment, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Form, useNavigate, useNavigation, useSubmit } from "react-router";
import { SubscriptionFeatureDto } from "~/application/dtos/subscriptions/SubscriptionFeatureDto";
import { SubscriptionProductDto } from "~/application/dtos/subscriptions/SubscriptionProductDto";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputCheckboxInline from "~/components/ui/input/InputCheckboxInline";
import InputNumber from "~/components/ui/input/InputNumber";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import Plan from "../settings/subscription/Plan";
import ToggleBillingPeriod from "../settings/subscription/ToggleBillingPeriod";
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
import PricingFeaturesTable from "./PricingFeaturesTable";
import { PricingModel } from "~/application/enums/subscriptions/PricingModel";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import { DefaultFeatures } from "~/application/dtos/shared/DefaultFeatures";
import FlatPrices from "./FlatPrices";
import UsageBasedPrices from "./UsageBasedPrices";
import CurrencyToggle from "~/components/ui/toggles/CurrencyToggle";
import currencies from "~/application/pricing/currencies";
import InputSelect from "~/components/ui/input/InputSelect";
import { SubscriptionPriceDto } from "~/application/dtos/subscriptions/SubscriptionPriceDto";
import { SubscriptionUsageBasedPriceDto } from "~/application/dtos/subscriptions/SubscriptionUsageBasedPriceDto";
import billingPeriods from "~/application/pricing/billingPeriods";
import { getSortedBillingPeriods, getSortedCurrencies } from "~/utils/helpers/PricingHelper";
import SettingSection from "~/components/ui/sections/SettingSection";
import { Button } from "~/components/ui/button";
import { TrashIcon } from "~/components/ui/icons/vendor";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
import { useLocaleFormatters } from "~/locale/format";
interface Props {
plans?: SubscriptionProductDto[];
item?: SubscriptionProductDto | undefined;
canUpdate?: boolean;
canDelete?: boolean;
isPortalPlan?: boolean;
cancelPath?: string;
}
export default function PricingPlanForm({ plans, item, canUpdate = true, canDelete, isPortalPlan, cancelPath }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const navigation = useNavigation();
const loading = navigation.state === "submitting";
const navigate = useNavigate();
const submit = useSubmit();
let pricingModels = [
{
name: t("pricing." + PricingModel[PricingModel.FLAT_RATE]),
value: PricingModel.FLAT_RATE,
},
{
name: t("pricing." + PricingModel[PricingModel.PER_SEAT]),
value: PricingModel.PER_SEAT,
},
{
name: t("pricing." + PricingModel[PricingModel.USAGE_BASED]),
value: PricingModel.USAGE_BASED,
},
{
name: t("pricing." + PricingModel[PricingModel.FLAT_RATE_USAGE_BASED]),
value: PricingModel.FLAT_RATE_USAGE_BASED,
},
{
name: t("pricing." + PricingModel[PricingModel.ONCE]),
value: PricingModel.ONCE,
},
];
if (isPortalPlan) {
pricingModels = pricingModels.filter(
(f) => f.value !== PricingModel.USAGE_BASED && f.value !== PricingModel.FLAT_RATE_USAGE_BASED && f.value !== PricingModel.PER_SEAT
);
}
const inputTitle = useRef<RefInputText>(null);
const confirmRemove = useRef<RefConfirmModal>(null);
const [order, setOrder] = useState<number | undefined>(item?.order ?? getNextOrder());
const [title, setTitle] = useState(item?.title ?? "");
const [description, setDescription] = useState(item?.description ?? "");
const [groupTitle, setGroupTitle] = useState(item?.groupTitle ?? "");
const [groupDescription, setGroupDescription] = useState(item?.groupDescription ?? "");
const [model, setModel] = useState(item?.model ?? PricingModel.FLAT_RATE);
const [badge, setBadge] = useState(item?.badge ?? "");
const [isPublic, setIsPublic] = useState(item ? item.public : true);
const [features, setFeatures] = useState<SubscriptionFeatureDto[]>([]);
const [billingAddressCollection, setBillingAddressCollection] = useState(item?.billingAddressCollection === "required" ? true : false);
const [hasQuantity, setHasQuantity] = useState(item?.hasQuantity ?? false);
const [canBuyAgain, setCanBuyAgain] = useState(item?.canBuyAgain ?? false);
const [prices, setPrices] = useState<SubscriptionPriceDto[]>(item?.prices ?? []);
const [usageBasedPrices, setUsageBasedPrices] = useState<SubscriptionUsageBasedPriceDto[]>(item?.usageBasedPrices ?? []);
const [billingPeriod, setBillingPeriod] = useState<SubscriptionBillingPeriod>(
billingPeriods.find((f) => f.default)?.value ?? SubscriptionBillingPeriod.MONTHLY
);
const [currency, setCurrency] = useState(currencies.find((f) => f.default)?.value ?? "");
useEffect(() => {
inputTitle.current?.input.current?.focus();
inputTitle.current?.input.current?.select();
if (item) {
setFeatures(item.features);
} else {
if (!isPortalPlan) {
const features: SubscriptionFeatureDto[] = [
{
order: 1,
title: "pricing.features.users.one",
name: DefaultFeatures.Users,
type: SubscriptionFeatureLimitType.MAX,
value: 1,
accumulate: false,
},
];
// appOrAdminData.entities
// .filter((f) => f.active && (f.type === DefaultEntityTypes.AppOnly || f.type === DefaultEntityTypes.All))
// .forEach((entity) => {
// features.push({
// order: features.length + 1,
// title: "100 " + t(entity.titlePlural).toLowerCase() + "/month",
// name: entity.name,
// type: SubscriptionFeatureLimitType.MONTHLY,
// value: 100,
// accumulate: false,
// });
// });
features.push({
order: features.length + 1,
title: t("pricing.planForm.defaultFeatures.prioritySupport"),
name: DefaultFeatures.PrioritySupport,
type: SubscriptionFeatureLimitType.NOT_INCLUDED,
value: 0,
accumulate: false,
});
setFeatures(features);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (model === PricingModel.ONCE) {
setBillingPeriod(SubscriptionBillingPeriod.ONCE);
} else {
setBillingPeriod(SubscriptionBillingPeriod.MONTHLY);
}
}, [model, item]);
useEffect(() => {
const possibleBillingPeriods = prices.filter((f) => f.price).flatMap((f) => f.billingPeriod);
if (possibleBillingPeriods.includes(SubscriptionBillingPeriod.MONTHLY)) {
setBillingPeriod(SubscriptionBillingPeriod.MONTHLY);
} else {
let found = -1;
billingPeriods
.filter((f) => !f.disabled)
.forEach((billingPeriod) => {
if (found === -1 && possibleBillingPeriods.includes(billingPeriod.value)) {
found = billingPeriod.value;
}
});
if (found !== -1) {
setBillingPeriod(found);
}
}
const possibleCurrencies = prices.filter((f) => f.price).flatMap((f) => f.currency);
const defaultCurrency = currencies.find((f) => f.default)?.value ?? "usd";
if (possibleCurrencies.includes(defaultCurrency)) {
setCurrency(defaultCurrency);
} else if (possibleCurrencies.length > 0) {
setCurrency(possibleCurrencies[0]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function close() {
navigate(cancelPath ?? adminPath("settings/pricing", { mode: "host-aware" }), { replace: true });
}
function remove() {
confirmRemove.current?.show(t("shared.confirmDelete"), t("shared.delete"), t("shared.cancel"), t("shared.warningCannotUndo"));
}
function getNextOrder() {
if (!plans || plans?.length === 0) {
return 1;
}
return Math.max(...plans.map((o) => o.order)) + 1;
}
function getYearlyDiscount(): string | undefined {
const priceYearly = prices.find((f) => f.currency === currency && f.billingPeriod === SubscriptionBillingPeriod.YEARLY);
const priceMonthly = prices.find((f) => f.currency === currency && f.billingPeriod === SubscriptionBillingPeriod.MONTHLY);
if (priceYearly && priceMonthly) {
const discount = 100 - (Number(priceYearly.price) * 100) / (Number(priceMonthly.price) * 12);
if (discount !== 0) {
return formatters.percent(-discount / 100, { maximumFractionDigits: 0 });
}
}
return undefined;
}
function yesRemove() {
const form = new FormData();
form.set("action", "delete");
submit(form, {
method: "post",
});
}
const possibleBillingPeriods = () => {
if (model === PricingModel.ONCE) {
return [SubscriptionBillingPeriod.ONCE];
}
let periods = prices.filter((f) => f.price).flatMap((f) => f.billingPeriod);
// remove duplicates
periods = periods.filter((v, i, a) => a.indexOf(v) === i);
return getSortedBillingPeriods(periods);
};
const possibleCurrencies = () => {
let currencies = prices.filter((f) => f.price).flatMap((f) => f.currency);
// remove duplicates
currencies = currencies.filter((v, i, a) => a.indexOf(v) === i);
return getSortedCurrencies(currencies);
};
return (
<>
<Form method="post">
<input hidden readOnly name="action" value={item ? "edit" : "create"} />
<div className="col-span-2 mx-auto">
<div>
<SettingSection title={t("pricing.planForm.sections.productDetails.title")} description={t("pricing.planForm.sections.productDetails.description")}>
<div className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-12">
<div className="sm:col-span-3">
<InputNumber
name="order"
title={t("models.subscriptionProduct.order")}
value={order}
setValue={setOrder}
disabled={loading}
required={true}
/>
</div>
<div className="sm:col-span-6">
<InputText
ref={inputTitle}
name="title"
title={t("models.subscriptionProduct.title")}
value={title}
setValue={setTitle}
disabled={loading}
autoComplete="off"
minLength={1}
maxLength={99}
required={true}
withTranslation={true}
help={t("pricing.planForm.fields.titleTranslationHelp")}
/>
</div>
<div className="sm:col-span-3">
<InputText
name="badge"
title={t("models.subscriptionProduct.badge")}
value={badge}
setValue={setBadge}
disabled={loading}
autoComplete="off"
withTranslation={true}
/>
</div>
{!isPortalPlan && (
<Fragment>
<div className="sm:col-span-4">
<InputText
name="group-title"
title={t("models.subscriptionProduct.groupTitle")}
value={groupTitle}
setValue={setGroupTitle}
disabled={loading}
autoComplete="off"
withTranslation={true}
/>
</div>
<div className="sm:col-span-8">
<InputText
name="group-description"
title={t("models.subscriptionProduct.groupDescription")}
value={groupDescription}
setValue={setGroupDescription}
disabled={loading}
autoComplete="off"
withTranslation={true}
/>
</div>
</Fragment>
)}
<div className="sm:col-span-12">
<InputText
name="description"
title={t("models.subscriptionProduct.description")}
value={description}
setValue={setDescription}
disabled={loading}
minLength={1}
maxLength={999}
autoComplete="off"
withTranslation={true}
/>
</div>
<div className="sm:col-span-12">
<InputSelect
name="model"
title={t("models.subscriptionProduct.model")}
value={model}
setValue={(e) => {
setModel(Number(e));
}}
options={pricingModels}
disabled={item !== undefined}
/>
</div>
<div className="space-y-2 sm:col-span-12">
<InputCheckboxInline
name="is-public"
title={t("models.subscriptionProduct.public")}
value={isPublic}
setValue={setIsPublic}
description={
<>
<span className="text-muted-foreground font-normal">: {t("pricing.planForm.fields.publicDescription")}</span>
</>
}
/>
</div>
<div className="space-y-2 sm:col-span-12">
<InputCheckboxInline
name="is-billing-required"
title={t("models.subscriptionProduct.billingAddressCollection")}
value={billingAddressCollection}
setValue={setBillingAddressCollection}
description={
<>
<span className="text-muted-foreground font-normal">: {t("pricing.planForm.fields.billingAddressCollectionDescription")}</span>
</>
}
/>
</div>
{model === PricingModel.ONCE && (
<div className="space-y-2 sm:col-span-12">
<InputCheckboxInline
name="has-quantity"
title={t("pricing.planForm.fields.hasQuantity")}
value={hasQuantity}
setValue={setHasQuantity}
description={
<>
<span className="text-muted-foreground font-normal">: {t("pricing.planForm.fields.hasQuantityDescription")}</span>
</>
}
/>
</div>
)}
{model === PricingModel.ONCE && (
<div className="space-y-2 sm:col-span-12">
<InputCheckboxInline
name="can-buy-again"
title={t("pricing.planForm.fields.canBuyAgain")}
value={canBuyAgain}
setValue={setCanBuyAgain}
description={
<>
<span className="text-muted-foreground font-normal">: {t("pricing.planForm.fields.canBuyAgainDescription")}</span>
</>
}
/>
</div>
)}
</div>
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-5">
<div className=""></div>
</div>
</div>
{[PricingModel.FLAT_RATE, PricingModel.PER_SEAT, PricingModel.FLAT_RATE_USAGE_BASED, PricingModel.ONCE].includes(model) && (
<SettingSection title={t("pricing.planForm.sections.flatPrices.title")} description={t("pricing.planForm.sections.flatPrices.description")} className="">
<FlatPrices model={model} prices={prices} setPrices={setPrices} disabled={item !== undefined} isPortalPlan={isPortalPlan} />
</SettingSection>
)}
{(model === PricingModel.FLAT_RATE_USAGE_BASED || model === PricingModel.USAGE_BASED) && (
<SettingSection title={t("pricing.planForm.sections.usageBasedPrices.title")} description={t("pricing.planForm.sections.usageBasedPrices.description")} className="">
<UsageBasedPrices initial={usageBasedPrices} onUpdate={(items) => setUsageBasedPrices(items)} disabled={item !== undefined} />
</SettingSection>
)}
{/*Separator */}
<div className="block">
<div className="py-5">
<div className=""></div>
</div>
</div>
<SettingSection title={t("models.subscriptionFeature.plural")} description={t("pricing.planForm.sections.features.description")} className="">
<PricingFeaturesTable plans={plans} items={features} setItems={setFeatures} />
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-5">
<div className=""></div>
</div>
</div>
<SettingSection title={t("shared.preview")} description={t("pricing.planForm.sections.preview.description")}>
<div className="flex justify-between space-x-2">
<CurrencyToggle value={currency} setValue={setCurrency} possibleCurrencies={possibleCurrencies()} />
<ToggleBillingPeriod
size="sm"
currency={currency}
products={plans ?? []}
// className="mt-10"
billingPeriod={billingPeriod}
onChange={setBillingPeriod}
yearlyDiscount={getYearlyDiscount()}
possibleBillingPeriods={possibleBillingPeriods()}
/>
</div>
<div className="mt-6">
<Plan
title={title}
description={description}
badge={badge}
features={features}
billingPeriod={billingPeriod}
currency={currency}
model={model}
prices={prices}
usageBasedPrices={usageBasedPrices}
stripeCoupon={null}
isPreview={true}
/>
</div>
</SettingSection>
</div>
<div className="flex justify-end space-x-2 py-5">
<div>
{item && (
<Button type="button" variant="ghost" disabled={loading || !canDelete} onClick={remove}>
<TrashIcon className="text-muted-foreground size-4" />
</Button>
)}
</div>
<div className="flex items-center space-x-2">
<Button type="button" variant="outline" disabled={loading} onClick={close}>
<div>{t("shared.cancel")}</div>
</Button>
<LoadingButton type="submit" disabled={loading || !canUpdate}>
{t("shared.save")}
</LoadingButton>
</div>
</div>
</div>
</Form>
<ConfirmModal ref={confirmRemove} onYes={yesRemove} destructive />
</>
);
}
@@ -0,0 +1,204 @@
import { useTranslation } from "react-i18next";
import { SubscriptionUsageBasedPriceDto } from "~/application/dtos/subscriptions/SubscriptionUsageBasedPriceDto";
import { useEffect, useState } from "react";
import clsx from "clsx";
import { SubscriptionUsageBasedUnitDto } from "~/application/dtos/subscriptions/SubscriptionUsageBasedUnitDto";
import UsageBasedPricesUnit from "./UsageBasedPricesUnit";
import CollapsibleRow from "~/components/ui/tables/CollapsibleRow";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import { PlusIcon } from "~/components/ui/icons/vendor";
interface Props {
initial: SubscriptionUsageBasedPriceDto[];
onUpdate: (items: SubscriptionUsageBasedPriceDto[]) => void;
disabled: boolean;
}
export default function UsageBasedPrices({ initial, onUpdate, disabled }: Props) {
const { t } = useTranslation();
const [units, setUnits] = useState<SubscriptionUsageBasedUnitDto[]>([]);
const [usageBasedPrices, setUsageBasedPrices] = useState<SubscriptionUsageBasedPriceDto[]>(initial);
useEffect(() => {
const units: SubscriptionUsageBasedUnitDto[] = [];
usageBasedPrices.forEach((usageBasedPrice) => {
const unit = units.find((f) => f.unit === usageBasedPrice.unit);
if (!unit) {
units.push({
unit: usageBasedPrice.unit,
unitTitle: usageBasedPrice.unitTitle,
unitTitlePlural: usageBasedPrice.unitTitlePlural,
usageType: usageBasedPrice.usageType,
aggregateUsage: usageBasedPrice.aggregateUsage,
tiersMode: usageBasedPrice.tiersMode,
billingScheme: usageBasedPrice.billingScheme,
tiers: [],
prices: [],
});
}
});
if (units.length === 0) {
units.push({
unit: "",
unitTitle: "",
unitTitlePlural: "",
usageType: "metered",
tiersMode: "volume",
aggregateUsage: "sum",
billingScheme: "tiered",
tiers: [],
prices: [],
});
}
units.forEach((unit) => {
const prices = usageBasedPrices.filter((f) => f.unit === unit.unit);
prices.forEach((price) => {
price.tiers.forEach((tier) => {
const existingTier = unit.tiers.find((f) => f.from === tier.from && (f.to === tier.to || (!f.to && !tier.to)));
if (!existingTier) {
unit.tiers.push({
from: tier.from,
to: tier.to ? Number(tier.to) : undefined,
});
}
unit.prices.push({
currency: price.currency,
from: tier.from ?? undefined,
to: tier.to ?? undefined,
perUnitPrice: tier.perUnitPrice ? Number(tier.perUnitPrice) : undefined,
flatFeePrice: tier.flatFeePrice ? Number(tier.flatFeePrice) : undefined,
});
});
});
});
setUnits(units);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const usageBasedPrices: SubscriptionUsageBasedPriceDto[] = [];
units.forEach((unit) => {
unit.prices
.sort((a, b) => a.from - b.from)
.forEach((price) => {
const unitTier = {
id: "",
subscriptionUsageBasedPriceId: "",
from: price.from,
to: price.to,
perUnitPrice: price.perUnitPrice,
flatFeePrice: price.flatFeePrice,
};
const existing = usageBasedPrices.find((f) => f.unit === unit.unit && f.currency === price.currency);
if (existing) {
existing.tiers.push(unitTier);
} else {
usageBasedPrices.push({
id: "",
subscriptionProductId: "",
stripeId: "",
billingPeriod: SubscriptionBillingPeriod.MONTHLY,
currency: price.currency,
unit: unit.unit,
unitTitle: unit.unitTitle,
unitTitlePlural: unit.unitTitlePlural,
usageType: unit.usageType,
aggregateUsage: unit.aggregateUsage,
tiersMode: unit.tiersMode,
billingScheme: unit.billingScheme,
tiers: [unitTier],
});
}
});
});
setUsageBasedPrices(usageBasedPrices);
}, [units]);
useEffect(() => {
onUpdate(usageBasedPrices);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [usageBasedPrices]);
function addUnit() {
setUnits([
...units,
{
unit: "",
unitTitle: "",
unitTitlePlural: "",
usageType: "metered",
aggregateUsage: "sum",
tiersMode: "volume",
billingScheme: "tiered",
tiers: [],
prices: [],
},
]);
}
function removeUnit(item: SubscriptionUsageBasedUnitDto) {
setUnits(units.filter((f) => f !== item));
}
function updateUnit(idx: number, item: SubscriptionUsageBasedUnitDto) {
const newUnits = [...units];
newUnits[idx] = item;
setUnits(newUnits);
}
return (
<div className="space-y-2">
{units.map((item, idx) => {
return (
<div key={idx} className="space-y-3">
<CollapsibleRow
initial={!disabled}
className="bg-secondary"
key={idx}
value={
<div className="flex flex-col truncate">
<h3 className="text-foreground text-sm font-bold">
{item.unitTitle} ({item.unit})
</h3>
<div className="text-muted-foreground truncate text-xs italic">
{t("pricing.usageBased.unit")} #{idx + 1}
</div>
</div>
}
title={`${t("pricing.usageBased.unit")} #${idx + 1}`}
onRemove={() => removeUnit(item)}
disabled={disabled}
>
<UsageBasedPricesUnit key={idx} item={item} onUpdate={(item) => updateUnit(idx, item)} disabled={disabled} />
</CollapsibleRow>
</div>
);
})}
<button
type="button"
onClick={addUnit}
disabled={disabled}
className={clsx(
"border-border text-muted-foreground focus:text-foreground bg-background flex items-center space-x-1 rounded-md border px-2 py-1 text-xs focus:ring-3 focus:ring-gray-300 focus:ring-offset-1",
disabled ? "bg-secondary cursor-not-allowed" : "hover:bg-secondary/90"
)}
>
<PlusIcon className="h-5 w-5" />
<span className="font-medium uppercase">{t("shared.add")}</span>
</button>
{/* {JSON.stringify(usageBasedPrices)} */}
{usageBasedPrices.map((item, idx) => {
return (
<div key={idx}>
<input readOnly hidden type="text" id="usage-based-prices[]" name="usage-based-prices[]" value={JSON.stringify(item)} />
</div>
);
})}
{/* <UsageBasedPricesUnitForm ref={usageBasedPriceUnitForm} onAdded={set} /> */}
</div>
);
}
@@ -0,0 +1,391 @@
import { useEffect, useMemo, useState } from "react";
import ButtonTertiary from "~/components/ui/buttons/ButtonTertiary";
import { useTranslation } from "react-i18next";
import InputSelect from "~/components/ui/input/InputSelect";
import { SubscriptionUsageBasedUnitDto } from "~/application/dtos/subscriptions/SubscriptionUsageBasedUnitDto";
import { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
import { InputType } from "~/application/enums/shared/InputType";
import currencies from "~/application/pricing/currencies";
import TableSimple from "~/components/ui/tables/TableSimple";
import TrashIcon from "~/components/ui/icons/TrashIcon";
import InputText from "~/components/ui/input/InputText";
import { createLocaleFormatters } from "~/locale/format";
const INCREMENT_BY = 100;
interface Props {
item: SubscriptionUsageBasedUnitDto;
onUpdate: (item: SubscriptionUsageBasedUnitDto) => void;
disabled: boolean;
}
export default function UsageBasedPricesUnit({ item, onUpdate, disabled }: Props) {
const { i18n, t } = useTranslation();
const formatters = useMemo(() => createLocaleFormatters(i18n.language), [i18n.language]);
const [unit, setUnit] = useState(item.unit);
const [unitTitle, setUnitTitle] = useState(item.unitTitle);
const [unitTitlePlural, setUnitTitlePlural] = useState(item.unitTitlePlural);
const [usageType, setUsageType] = useState(item.usageType);
const [aggregateUsage, setAggregateUsage] = useState(item.aggregateUsage);
const [tiersMode, setTiersMode] = useState(item.tiersMode);
const [billingScheme, setBillingScheme] = useState(item.billingScheme);
const [allTiers, setAllTiers] = useState<{ from: number; to?: number }[]>(item.tiers);
const [prices, setPrices] = useState(item.prices);
const [perUnitHeaders, setPerUnitHeaders] = useState<RowHeaderDisplayDto<{ from: number; to?: number }>[]>([]);
const [flatFeeHeaders, setFlatFeeHeaders] = useState<RowHeaderDisplayDto<{ from: number; to?: number }>[]>([]);
// useEffect(() => {
// const usageBasedUnit = usageBasedUnits.find((f) => f.name === unit);
// if (usageBasedUnit) {
// setUnitTitle(usageBasedUnit.title);
// setUnitTitlePlural(usageBasedUnit.titlePlural);
// }
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [unit]);
useEffect(() => {
onUpdate({
unit,
unitTitle,
unitTitlePlural,
usageType,
aggregateUsage,
tiersMode,
billingScheme,
tiers: allTiers,
prices: prices,
});
}, [unit, unitTitle, unitTitlePlural, usageType, aggregateUsage, tiersMode, billingScheme, prices]);
useEffect(() => {
const allTiers: { from: number; to?: number }[] = [];
if (item) {
item.tiers.forEach((tier) => {
if (!allTiers.find((f) => f.from === tier.from && f.to === tier.to)) {
allTiers.push({ from: tier.from, to: tier.to ?? undefined });
}
});
} else {
const generateTiers = 3;
for (let idx = 0; idx < generateTiers; idx++) {
if (idx === 0) {
allTiers.push({ from: 0, to: INCREMENT_BY });
} else {
const to = idx === generateTiers - 1 ? undefined : idx * INCREMENT_BY + INCREMENT_BY;
allTiers.push({ from: idx * INCREMENT_BY + 1, to });
}
}
}
updateNewTierLimits(allTiers);
setAllTiers(allTiers);
}, [item]);
useEffect(() => {
let commonHeaders: RowHeaderDisplayDto<{ from: number; to?: number }>[] = [];
commonHeaders = [
{
name: "fromTitle",
title: "",
value: (_) => "",
formattedValue: (_, idx) => (
<>{tiersMode === "graduated" ? <div>{idx === 0 ? <div>{t("pricing.usageBased.forTheFirst")}</div> : <div>{t("pricing.usageBased.forTheNext")}</div>}</div> : <div>{t("pricing.usageBased.totalUnits")}</div>}</>
),
},
{
name: "from",
title: t("pricing.usageBased.from"),
value: (e) => e.from,
type: InputType.NUMBER,
setValue: (e, idx) => updateTier(idx, Number(e)),
editable: (_, idx) => !disabled || (idx !== undefined && idx > 0),
className: "w-24",
},
{
name: "to",
title: t("pricing.usageBased.to"),
value: (e) => (e.to ? e.to : "∞"),
formattedValue: (e) => (
<div className="text-muted-foreground flex justify-center font-medium">{e.to ? <span>{formatters.number(e.to, { maximumFractionDigits: 0 })}</span> : <span>∞</span>}</div>
),
className: "w-24",
},
];
let perUnitHeaders: RowHeaderDisplayDto<{ from: number; to?: number }>[] = [...commonHeaders];
let flatFeeHeaders: RowHeaderDisplayDto<{ from: number; to?: number }>[] = [...commonHeaders];
currencies
.filter((f) => !f.disabled)
.forEach((currency) => {
perUnitHeaders.push({
name: currency.name,
title: currency.value.toUpperCase(),
value: (_, idx) => getCurrencyPrice(idx, currency.value)?.perUnitPrice,
type: InputType.NUMBER,
inputNumberStep: "0.000001",
setValue: (e, idx) => setPerUnitPrice(idx, currency.value, Number(e)),
editable: () => !disabled,
className: "w-24",
});
});
currencies
.filter((f) => !f.disabled)
.forEach((currency) => {
flatFeeHeaders.push({
name: currency.name,
title: currency.value.toUpperCase(),
value: (_, idx) => getCurrencyPrice(idx, currency.value)?.flatFeePrice,
type: InputType.NUMBER,
inputNumberStep: "1",
setValue: (e, idx) => setFlatFeePrice(idx, currency.value, Number(e)),
editable: () => !disabled,
className: "w-24",
});
});
setPerUnitHeaders(perUnitHeaders);
setFlatFeeHeaders(flatFeeHeaders);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item, tiersMode, allTiers, prices, formatters, t]);
function setPerUnitPrice(idx: number, currency: string, perUnitPrice: number) {
const newPrices = [...prices];
const existingPrice = getCurrencyPrice(idx, currency);
if (existingPrice) {
existingPrice.perUnitPrice = perUnitPrice;
} else {
newPrices.push({
currency,
from: allTiers[idx].from,
to: allTiers[idx].to,
perUnitPrice,
});
}
setPrices(newPrices);
}
function setFlatFeePrice(idx: number, currency: string, flatFeePrice: number) {
const newPrices = [...prices];
const existingPrice = getCurrencyPrice(idx, currency);
if (existingPrice) {
existingPrice.flatFeePrice = flatFeePrice;
} else {
newPrices.push({
currency,
from: allTiers[idx].from,
to: allTiers[idx].to,
flatFeePrice,
});
}
setPrices(newPrices);
}
function getCurrencyPrice(idx: number, currency: string) {
if (allTiers.length > idx) {
const { from, to } = allTiers[idx];
const existingPrice = prices.find((f) => f.currency === currency && f.from === from && f.to === to);
return existingPrice;
}
}
function updateTier(idx: number, from: number) {
const newTiers = [...allTiers];
newTiers[idx].from = from;
setAllTiers(updateNewTierLimits(newTiers));
setPrices([]);
}
function updateNewTierLimits(tiers: { from: number; to?: number }[]) {
for (let idx = 0; idx < tiers.length; idx++) {
const tier = tiers[idx];
if (idx === 0) {
tier.from = 0;
}
if (idx === tiers.length - 1) {
tier.to = undefined;
}
if (idx > 0) {
const previousTier = tiers[idx - 1];
previousTier.to = tier.from - 1;
}
}
return tiers;
}
function removeTier(idx: number) {
if (allTiers.length > 1) {
setAllTiers(updateNewTierLimits(allTiers.filter((o, i) => i !== idx)));
setPrices([]);
}
}
function addTier() {
if (allTiers.length === 0) {
setAllTiers(updateNewTierLimits([{ from: 0, to: undefined }]));
} else {
const lastTier = allTiers[allTiers.length - 1];
setAllTiers(
updateNewTierLimits([
...allTiers,
{
from: lastTier.from + INCREMENT_BY,
to: undefined,
},
])
);
}
setPrices([]);
}
return (
<div className="border-border mt-2 border-t p-2">
<div className="space-y-/2">
<div className="mb-2 mt-2 flex items-center justify-between space-x-1">
<div className="text-sm font-bold">{t("pricing.usageBased.usageBasedUnit")}</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{/* <InputSelect
className="col-span-2 sm:col-span-1"
disabled={disabled}
name="unit"
title={t("pricing.usageBased.unit")}
options={[
...usageBasedUnits.map((item) => {
return {
name: `${t(item.title)} (${item.name})`,
value: item.name,
};
}),
]}
setValue={(e) => {
setUnit(e?.toString() ?? "");
}}
value={unit}
/> */}
<InputText
className="col-span-2 sm:col-span-1"
disabled={disabled}
name="unit"
title={t("pricing.usageBased.unit")}
setValue={(e) => {
setUnit(e?.toString() ?? "");
}}
value={unit}
required
placeholder={t("pricing.usageBased.placeholders.unit")}
/>
<InputText
className="col-span-2 sm:col-span-1"
disabled={disabled}
name="unitTitle"
title={t("shared.title")}
setValue={(e) => setUnitTitle(e?.toString() ?? "")}
value={unitTitle}
required
placeholder={t("pricing.usageBased.placeholders.unitTitle")}
/>
<InputText
className="col-span-2 sm:col-span-2"
disabled={disabled}
name="unitTitlePlural"
title={t("shared.titlePlural")}
setValue={(e) => setUnitTitlePlural(e?.toString() ?? "")}
value={unitTitlePlural}
required
placeholder={t("pricing.usageBased.placeholders.unitTitlePlural")}
/>
<InputSelect
disabled={disabled}
name="usageType"
title={t("pricing.usageBased.usageType")}
options={[
{ name: t("pricing.usageBased.usageTypes.licensed"), value: "licensed" },
{ name: t("pricing.usageBased.usageTypes.metered"), value: "metered" },
]}
value={usageType}
setValue={(e) => setUsageType(e?.toString() ?? "")}
/>
<InputSelect
disabled={disabled}
name="tiersMode"
title={t("pricing.usageBased.tiersMode")}
options={[
{ name: t("pricing.usageBased.tiersModes.volume"), value: "volume" },
{ name: t("pricing.usageBased.tiersModes.graduated"), value: "graduated" },
]}
value={tiersMode}
setValue={(e) => setTiersMode(e?.toString() ?? "")}
/>
<InputSelect
disabled={disabled}
name="aggregateUsage"
title={t("pricing.usageBased.aggregateUsage")}
options={[
{ name: t("pricing.usageBased.aggregateUsageOptions.sum"), value: "sum" },
{ name: t("pricing.usageBased.aggregateUsageOptions.max"), value: "max", disabled: true },
{ name: t("pricing.usageBased.aggregateUsageOptions.lastEver"), value: "last_ever", disabled: true },
{ name: t("pricing.usageBased.aggregateUsageOptions.lastDuringPeriod"), value: "last_during_period", disabled: true },
]}
value={aggregateUsage}
setValue={(e) => setAggregateUsage(e?.toString() ?? "")}
/>
<InputSelect
disabled={disabled}
name="billingScheme"
title={t("pricing.usageBased.billingScheme")}
options={[
{ name: t("pricing.usageBased.billingSchemes.perUnit"), value: "per_unit", disabled: true },
{ name: t("pricing.usageBased.billingSchemes.tiered"), value: "tiered" },
]}
value={billingScheme}
setValue={(e) => setBillingScheme(e?.toString() ?? "")}
/>
</div>
<div className="mb-1 mt-2 flex items-center justify-between space-x-1">
<div className="text-sm font-bold">{t("pricing.usageBased.perUnitPrices")}</div>
{!disabled && <ButtonTertiary onClick={addTier}>{t("pricing.usageBased.addTier")}</ButtonTertiary>}
</div>
<TableSimple
items={allTiers}
headers={perUnitHeaders}
actions={[
{
title: (
<div>
<TrashIcon className="text-muted-foreground h-4 w-4" />
</div>
),
onClick: (idx, i) => removeTier(idx),
disabled: () => disabled || allTiers.length === 1,
},
]}
/>
<div className="mb-1 mt-2 flex items-center justify-between space-x-1">
<div className="text-sm font-bold">{t("pricing.usageBased.flatFeePrices")}</div>
{!disabled && <ButtonTertiary onClick={addTier}>{t("pricing.usageBased.addTier")}</ButtonTertiary>}
</div>
<TableSimple
items={allTiers}
headers={flatFeeHeaders}
actions={[
{
title: (
<div>
<TrashIcon className="text-muted-foreground h-4 w-4" />
</div>
),
onClick: (idx, i) => removeTier(idx),
disabled: () => disabled || allTiers.length === 1,
},
]}
/>
</div>
</div>
);
}
@@ -0,0 +1,14 @@
import type { Group } from "@toolctrl/db/prisma-browser";
import ColorBadge from "~/components/ui/badges/ColorBadge";
interface Props {
item: Group;
}
export default function GroupBadge({ item }: Props) {
return (
<div className="flex items-center space-x-2">
<ColorBadge color={item.color} />
<div>{item.name}</div>
</div>
);
}
@@ -0,0 +1,116 @@
import { useRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Colors } from "~/application/enums/shared/Colors";
import ColorBadge from "~/components/ui/badges/ColorBadge";
import FormGroup from "~/components/ui/forms/FormGroup";
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
import InputSelector from "~/components/ui/input/InputSelector";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import { GroupWithDetails } from "~/utils/db/permissions/groups.db.server";
import { TenantUserWithUser } from "~/utils/db/tenants.db.server";
import { getColors } from "~/utils/shared/ColorUtils";
import UserBadge from "../users/UserBadge";
interface Props {
item?: GroupWithDetails | null;
allUsers: TenantUserWithUser[];
canUpdate?: boolean;
canDelete?: boolean;
}
export default function GroupForm({ item, allUsers, canUpdate = true, canDelete }: Props) {
const { t } = useTranslation();
const inputName = useRef<RefInputText>(null);
const [color, setColor] = useState<string | number | undefined>(item?.color);
const [users, setUsers] = useState(item?.users?.map((f) => f.userId) ?? []);
useEffect(() => {
setTimeout(() => {
inputName.current?.input.current?.focus();
}, 100);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function setUser(user: TenantUserWithUser, add: any) {
if (add) {
setUsers([...users, user.userId]);
} else {
setUsers(users.filter((f) => f !== user.userId));
}
}
return (
<FormGroup id={item?.id} editing={true} canUpdate={canUpdate} canDelete={canDelete}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<InputText
disabled={!canUpdate}
ref={inputName}
className="col-span-7"
name="name"
title={t("models.group.name")}
defaultValue={item?.name}
required
autoComplete="off"
/>
<InputSelector
className="col-span-5"
name="color"
title={t("models.group.color")}
withSearch={false}
value={color}
setValue={setColor}
options={
getColors().map((color) => {
return {
name: (
<div className="flex items-center space-x-2">
<ColorBadge color={color} />
<div>{t("app.shared.colors." + Colors[color])}</div>
</div>
),
value: color,
};
}) ?? []
}
></InputSelector>
<InputText
disabled={!canUpdate}
className="col-span-12"
name="description"
title={t("models.group.description")}
defaultValue={item?.description}
autoComplete="off"
/>
<div className="col-span-12 mt-2">
{/* Group caption labels a set of checkboxes, so it is a role=group caption, not a <label> for a single control. */}
<div id="group-users-label" className="text-muted-foreground flex items-center justify-between space-x-2 truncate text-xs font-medium">
<span className="text-foreground text-sm font-bold">{t("models.user.plural")}</span>
</div>
<div className="mt-1" role="group" aria-labelledby="group-users-label">
{users.map((user) => {
return <input key={user} type="hidden" name="users[]" value={user} />;
})}
{allUsers.map((user, idx) => {
return (
<InputCheckboxWithDescription
disabled={!canUpdate}
name={user.userId}
title={<UserBadge item={user.user} />}
description={""}
value={users.includes(user.userId)}
setValue={(e) => setUser(user, e)}
key={idx}
/>
);
})}
</div>
</div>
</div>
</FormGroup>
);
}
@@ -0,0 +1,83 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import InputSearch from "~/components/ui/input/InputSearch";
import TableSimple from "~/components/ui/tables/TableSimple";
import { GroupWithDetails } from "~/utils/db/permissions/groups.db.server";
import GroupBadge from "./GroupBadge";
import { Link } from "react-router";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import PlusIcon from "~/components/ui/icons/PlusIcon";
interface Props {
items: GroupWithDetails[];
onNewRoute: string;
}
export default function GroupsTable({ items, onNewRoute }: Props) {
const { t } = useTranslation();
const [searchInput, setSearchInput] = useState("");
const filteredItems = () => {
if (!items) {
return [];
}
return items.filter(
(f) =>
f.name?.toString().toUpperCase().includes(searchInput.toUpperCase()) ||
f.description?.toString().toUpperCase().includes(searchInput.toUpperCase()) ||
f.users.find(
(f) =>
f.user.email.toUpperCase().includes(searchInput.toUpperCase()) ||
f.user.firstName.toUpperCase().includes(searchInput.toUpperCase()) ||
f.user.lastName.toUpperCase().includes(searchInput.toUpperCase())
)
);
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between space-x-2">
<div className="grow">
<InputSearch value={searchInput} setValue={setSearchInput} />
</div>
<ButtonSecondary to={onNewRoute}>
<div className="flex items-center space-x-1">
<PlusIcon className="h-3 w-3" />
<div>{t("shared.add")}</div>
</div>
</ButtonSecondary>
</div>
<TableSimple
items={filteredItems()}
headers={[
{
name: "name",
title: t("models.group.name"),
value: (i) => i.name,
formattedValue: (i) => <GroupBadge item={i} />,
},
{
name: "description",
title: t("models.group.description"),
value: (i) => i.description,
},
{
name: "users",
title: t("models.user.plural"),
value: (i) => i.users.map((f) => `${f.user.firstName} ${f.user.lastName}`).join(", "),
className: "w-full",
},
{
name: "actions",
title: "",
value: (i) => (
<Link to={`groups/${i.id}`} className="hover:underline">
{t("shared.edit")}
</Link>
),
},
]}
/>
</div>
);
}
@@ -0,0 +1,67 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PermissionForm from "./PermissionForm";
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
global.ResizeObserver = ResizeObserverStub;
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (value: string) => value, i18n: { exists: () => true } }),
}));
vi.mock("~/components/ui/input/InputSearch", () => ({
default: () => <input aria-label="search" />,
}));
vi.mock("~/components/ui/forms/FormGroup", () => ({
default: ({ children }: { children: React.ReactNode }) => <form>{children}</form>,
}));
vi.mock("~/utils/data/useAppOrAdminData", () => ({
useAppOrAdminData: () => ({ isSuperAdmin: true }),
}));
const now = new Date("2026-01-01T00:00:00.000Z");
const role = {
id: "role_1",
name: "SuperAdmin",
description: "Has all admin permissions",
type: "admin",
order: 1,
isDefault: true,
assignToNewUsers: false,
createdAt: now,
updatedAt: now,
permissions: [],
users: [],
};
describe("PermissionForm display labels", () => {
it("uses permission field labels for the permission editor", () => {
render(<PermissionForm roles={[]} onCancel={vi.fn()} item={undefined} canUpdate />);
expect(screen.getByText("models.permission.name")).toBeInTheDocument();
expect(screen.getByText("models.permission.description")).toBeInTheDocument();
expect(screen.queryByText("models.role.name")).not.toBeInTheDocument();
expect(screen.queryByText("models.role.description")).not.toBeInTheDocument();
});
it("localizes visible default role descriptions while keeping hidden role values raw", () => {
render(<PermissionForm roles={[role] as Parameters<typeof PermissionForm>[0]["roles"]} onCancel={vi.fn()} item={undefined} canUpdate />);
expect(screen.getByText("models.role.defaultRoles.names.superAdmin")).toBeInTheDocument();
expect(screen.getByText("models.role.defaultRoles.descriptions.allAdminPermissions")).toBeInTheDocument();
expect(screen.queryByText("Has all admin permissions")).not.toBeInTheDocument();
expect(document.querySelector('input[type="hidden"][name="roles[]"]')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("checkbox", { name: /models.role.defaultRoles.names.superAdmin/u }));
expect(document.querySelector('input[type="hidden"][name="roles[]"]')).toHaveValue("SuperAdmin");
});
});
@@ -0,0 +1,140 @@
import InputText from "~/components/ui/input/InputText";
import { useTranslation } from "react-i18next";
import { RoleWithPermissions } from "~/utils/db/permissions/roles.db.server";
import { PermissionWithRoles } from "~/utils/db/permissions/permissions.db.server";
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
import { useEffect, useState } from "react";
import FormGroup from "~/components/ui/forms/FormGroup";
import RoleBadge from "./RoleBadge";
import InputSearch from "~/components/ui/input/InputSearch";
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
import InputSelect from "~/components/ui/input/InputSelect";
import { Button } from "~/components/ui/button";
import { roleDisplayDescription } from "./roleDisplay";
interface Props {
item?: PermissionWithRoles;
roles: RoleWithPermissions[];
onCancel: () => void;
canUpdate?: boolean;
canDelete?: boolean;
}
export default function PermissionForm({ item, roles, onCancel, canUpdate = true, canDelete }: Props) {
const { t } = useTranslation();
const appOrAdminData = useAppOrAdminData();
const [permissionRoles, setRoles] = useState<string[]>([]);
const [type, setType] = useState<string | number | undefined>(item?.type ?? "admin");
const [searchInput, setSearchInput] = useState("");
useEffect(() => {
setRoles([]);
}, [type]);
useEffect(() => {
const permissionRoles: string[] = [];
if (item) {
item?.inRoles.forEach((item) => {
permissionRoles.push(item.role.name);
});
}
setRoles(permissionRoles);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function hasRole(role: RoleWithPermissions) {
return permissionRoles.includes(role.name);
}
function setPermission(role: RoleWithPermissions, add: any) {
if (add) {
setRoles([...permissionRoles, role.name]);
} else {
setRoles(permissionRoles.filter((f) => f !== role.name));
}
}
const filteredItems = () => {
if (!roles.filter((f) => f.type === type)) {
return [];
}
return roles
.filter((f) => f.type === type)
.filter(
(f) =>
f.name?.toString().toUpperCase().includes(searchInput.toUpperCase()) || f.description?.toString().toUpperCase().includes(searchInput.toUpperCase())
);
};
return (
<FormGroup
canDelete={(!item?.isDefault || appOrAdminData.isSuperAdmin) && canDelete}
editing={canUpdate}
id={item?.id}
className="space-y-3 px-4 pb-4"
onCancel={onCancel}
>
<InputText disabled={!canUpdate} required name="name" title={t("models.permission.name")} defaultValue={item?.name} />
<InputText disabled={!canUpdate} required name="description" title={t("models.permission.description")} defaultValue={item?.description} />
<InputSelect
disabled={!canUpdate}
name="type"
title={t("models.permission.type")}
value={type}
setValue={setType}
options={[
{
name: t("models.permission.typeOptions.admin"),
value: "admin",
},
{
name: t("models.permission.typeOptions.app"),
value: "app",
},
]}
/>
<div className="space-y-1">
<label className="text-muted-foreground flex justify-between space-x-2 truncate text-xs font-medium">
<div className="flex items-center justify-between space-x-1">
<div className="text-foreground text-lg font-bold">{t("models.role.plural")}</div>
</div>
<div>
{filteredItems().length === permissionRoles.length ? (
<Button type="button" variant="ghost" size="sm" disabled={!canUpdate} onClick={() => setRoles([])}>
{t("shared.clear")}
</Button>
) : (
<Button type="button" variant="ghost" size="sm" disabled={!canUpdate} onClick={() => setRoles(filteredItems().map((f) => f.name))}>
{t("shared.selectAll")}
</Button>
)}
</div>
</label>
<InputSearch value={searchInput} setValue={setSearchInput} />
<div>
{permissionRoles.map((role) => {
return <input key={role} type="hidden" name="roles[]" value={role} />;
})}
{filteredItems().map((role, idx) => {
return (
<InputCheckboxWithDescription
disabled={!canUpdate}
name={role.order + " " + role.name}
title={<RoleBadge item={role} />}
description={roleDisplayDescription(role, t)}
value={hasRole(role)}
setValue={(e) => setPermission(role, e)}
key={idx}
/>
);
})}
</div>
</div>
<div className="border-border shrink-0 border-t px-4 py-2 sm:px-6"></div>
</FormGroup>
);
}
@@ -0,0 +1,89 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
import TableSimple from "~/components/ui/tables/TableSimple";
import { PermissionWithRoles } from "~/utils/db/permissions/permissions.db.server";
import RoleBadge from "./RoleBadge";
import OrderListButtons from "~/components/ui/sort/OrderListButtons";
import { formatRoleDisplayList, permissionDisplayDescription, permissionDisplayName } from "./roleDisplay";
interface Props {
items: PermissionWithRoles[];
className?: string;
canCreate: boolean;
canUpdate: boolean;
tenantId?: string | null;
canReorder?: boolean;
}
export default function PermissionsTable({ items, className, canCreate, canUpdate = true, tenantId, canReorder }: Props) {
const { t } = useTranslation();
const [actions, setActions] = useState<any[]>([]);
const [headers, setHeaders] = useState<RowHeaderDisplayDto<PermissionWithRoles>[]>([]);
useEffect(() => {
if (canUpdate) {
setActions([
{
title: t("shared.edit"),
onClickRoute: (_: any, item: any) => item.id,
},
]);
}
const headers: RowHeaderDisplayDto<PermissionWithRoles>[] = [
{
name: "name",
title: t("models.permission.name"),
value: (i) => permissionDisplayName(i, t),
formattedValue: (i) => <RoleBadge item={i} kind="permission" />,
className: "max-w-xs truncate",
},
{
name: "description",
title: t("models.permission.description"),
value: (i) => permissionDisplayDescription(i, t) ?? "",
className: "max-w-xs truncate",
},
{
name: "roles",
title: t("models.permission.inRoles"),
value: (i) => i.inRoles.length,
formattedValue: (i) => (
<div className="w-64 truncate">
<span className="max-w-sm truncate text-sm italic">
{formatRoleDisplayList(
i.inRoles.sort((a, b) => a.role.order - b.role.order).map((f) => f.role),
t
)}
</span>
</div>
),
className: canUpdate ? "max-w-xs truncate" : "",
},
];
if (canReorder) {
headers.unshift({
name: "order",
title: t("shared.order"),
value: (_item, idx) => (
<div>
{_item.order}
<OrderListButtons index={idx} items={items.map((f) => ({ ...f, order: f.order ?? 0 }))} editable={true} />
</div>
),
});
}
setHeaders(headers);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canUpdate]);
return (
<div data-ui-id="ui.settings.rolesAndPermissions.permissions.table">
<TableSimple actions={actions} headers={headers} items={items} />
</div>
);
}
@@ -0,0 +1,25 @@
import type { Permission, Role } from "@toolctrl/db/prisma-browser";
import { useTranslation } from "react-i18next";
import { Colors } from "~/application/enums/shared/Colors";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import LockClosedIcon from "~/components/ui/icons/LockClosedIcon";
import { permissionDisplayName, roleDisplayName } from "./roleDisplay";
interface Props {
item: Role | Permission;
color?: Colors;
kind?: "role" | "permission";
}
export default function RoleBadge({ item, color = Colors.INDIGO, kind = "role" }: Props) {
const { t } = useTranslation();
const label = kind === "permission" ? permissionDisplayName(item, t) : roleDisplayName(item, t);
const typeLabel = t(kind === "permission" ? `models.permission.typeLabels.${item.type}` : `models.role.typeLabels.${item.type}`);
return (
<div className="flex items-center space-x-1">
<div>{label}</div>
{item.isDefault && <LockClosedIcon className="h-3 w-3 text-gray-300" />}
{item.type === "admin" && <SimpleBadge title={typeLabel} color={color} />}
</div>
);
}
@@ -0,0 +1,104 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import RoleForm from "./RoleForm";
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
global.ResizeObserver = ResizeObserverStub;
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (value: string) => value }),
}));
vi.mock("~/components/ui/forms/FormGroup", () => ({
default: ({ children }: { children: React.ReactNode }) => <form>{children}</form>,
}));
const now = new Date("2026-01-01T00:00:00.000Z");
const appPermission = {
id: "perm_app_1",
name: "app.settings.members.view",
type: "app",
description: "View workspace members",
order: 1,
inRoles: [],
createdAt: now,
updatedAt: now,
entityId: null,
isDefault: false,
};
const seededAppPermission = {
...appPermission,
id: "perm_app_seeded",
description: "Default permission app.settings.members.view",
isDefault: true,
};
const adminPermission = {
id: "perm_admin_1",
name: "admin.roles.manage",
type: "admin",
description: "Manage admin roles",
order: 2,
inRoles: [],
createdAt: now,
updatedAt: now,
entityId: null,
isDefault: false,
};
describe("RoleForm tenant type lock", () => {
it("submits the default app type through a hidden field without showing the type selector", () => {
render(
<RoleForm permissions={[appPermission, adminPermission] as Parameters<typeof RoleForm>[0]["permissions"]} defaultType="app" lockType onCancel={vi.fn()} />
);
const hiddenTypeField = document.querySelector('input[type="hidden"][name="type"]');
expect(hiddenTypeField).toHaveValue("app");
expect(screen.queryByText("models.role.type")).not.toBeInTheDocument();
expect(screen.queryByText("models.role.typeOptions.admin")).not.toBeInTheDocument();
expect(screen.queryByText("models.role.typeOptions.app")).not.toBeInTheDocument();
expect(screen.getByText("View workspace members")).toBeInTheDocument();
expect(screen.queryByText("app.settings.members.view")).not.toBeInTheDocument();
expect(screen.queryByText("Manage admin roles")).not.toBeInTheDocument();
});
it("shows display-safe seeded permission labels while keeping hidden values raw", () => {
render(<RoleForm permissions={[seededAppPermission] as Parameters<typeof RoleForm>[0]["permissions"]} defaultType="app" lockType onCancel={vi.fn()} />);
expect(screen.getByText("models.permission.defaultPermissions.title")).toBeInTheDocument();
expect(screen.getByText("models.permission.defaultPermissions.description")).toBeInTheDocument();
expect(screen.queryByText("Default permission app.settings.members.view")).not.toBeInTheDocument();
expect(document.querySelector('input[type="hidden"][name="permissions[]"]')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("checkbox", { name: /models.permission.defaultPermissions.title/u }));
expect(document.querySelector('input[type="hidden"][name="permissions[]"]')).toHaveValue("app.settings.members.view");
});
});
describe("RoleForm admin type selector", () => {
it("keeps the role type selector visible and usable when not type-locked", () => {
render(<RoleForm permissions={[appPermission, adminPermission] as Parameters<typeof RoleForm>[0]["permissions"]} onCancel={vi.fn()} />);
const typeField = screen.getByRole("combobox");
expect(screen.getByText("models.role.type")).toBeInTheDocument();
expect(screen.getAllByText("models.role.typeOptions.admin").length).toBeGreaterThan(0);
expect(screen.getAllByText("models.role.typeOptions.app").length).toBeGreaterThan(0);
expect(typeField).not.toBeDisabled();
expect(document.querySelector('input[type="hidden"][name="type"]')).not.toBeInTheDocument();
expect(screen.getByText("Manage admin roles")).toBeInTheDocument();
expect(screen.queryByText("admin.roles.manage")).not.toBeInTheDocument();
expect(screen.queryByText("View workspace members")).not.toBeInTheDocument();
fireEvent.click(typeField);
expect(screen.getAllByText("models.role.typeOptions.app").length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,146 @@
import InputText from "~/components/ui/input/InputText";
import { useTranslation } from "react-i18next";
import { RoleWithPermissions } from "~/utils/db/permissions/roles.db.server";
import { PermissionWithRoles } from "~/utils/db/permissions/permissions.db.server";
import InputCheckboxInline from "~/components/ui/input/InputCheckboxInline";
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
import { useEffect, useState } from "react";
import FormGroup from "~/components/ui/forms/FormGroup";
import InputSearch from "~/components/ui/input/InputSearch";
import InputSelect from "~/components/ui/input/InputSelect";
import { Button } from "~/components/ui/button";
import { permissionDisplayDescription, permissionDisplayName } from "./roleDisplay";
interface Props {
item?: RoleWithPermissions;
permissions: PermissionWithRoles[];
onCancel: () => void;
canUpdate?: boolean;
canDelete?: boolean;
defaultType?: "admin" | "app";
lockType?: boolean;
}
export default function RoleForm({ item, permissions, onCancel, canUpdate = true, canDelete, defaultType = "admin", lockType = false }: Props) {
const { t } = useTranslation();
const [rolePermissions, setRolePermissions] = useState<string[]>([]);
const [type, setType] = useState<string | number | undefined>(item?.type ?? defaultType);
const [assignToNewUsers, setAssignToNewUsers] = useState(item?.assignToNewUsers ?? false);
const [searchInput, setSearchInput] = useState("");
useEffect(() => {
setRolePermissions([]);
}, [type]);
useEffect(() => {
const rolePermissions: string[] = [];
if (item) {
item?.permissions.forEach((item) => {
rolePermissions.push(item.permission.name);
});
}
setRolePermissions(rolePermissions);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function hasPermission(permission: PermissionWithRoles) {
return rolePermissions.includes(permission.name);
}
function setPermission(permission: PermissionWithRoles, add: any) {
if (add) {
setRolePermissions([...rolePermissions, permission.name]);
} else {
setRolePermissions(rolePermissions.filter((f) => f !== permission.name));
}
}
const filteredItems = () => {
if (!permissions.filter((f) => f.type === type)) {
return [];
}
return permissions
.filter((f) => f.type === type)
.filter(
(f) =>
f.name?.toString().toUpperCase().includes(searchInput.toUpperCase()) || f.description?.toString().toUpperCase().includes(searchInput.toUpperCase())
);
};
return (
<FormGroup canDelete={!item?.isDefault && canDelete} editing={canUpdate} id={item?.id} className="space-y-3 px-4 pb-4" onCancel={onCancel}>
<InputText disabled={!canUpdate} required name="name" title={t("models.role.name")} defaultValue={item?.name} />
<InputText disabled={!canUpdate} required name="description" title={t("models.role.description")} defaultValue={item?.description} />
<InputCheckboxInline
disabled={!canUpdate}
description={<span className="text-muted-foreground pl-1 font-light"> - {t("models.role.assignToNewUsersDescription")}</span>}
name="assign-to-new-users"
title={t("models.role.assignToNewUsers")}
value={assignToNewUsers}
setValue={setAssignToNewUsers}
/>
{lockType ? (
<input type="hidden" name="type" value={type} readOnly />
) : (
<InputSelect
name="type"
title={t("models.role.type")}
value={type}
setValue={setType}
options={[
{
name: t("models.role.typeOptions.admin"),
value: "admin",
},
{
name: t("models.role.typeOptions.app"),
value: "app",
},
]}
/>
)}
<div className="space-y-1">
<label className="text-muted-foreground flex justify-between space-x-2 truncate text-xs font-medium">
<div className="flex items-center justify-between space-x-1">
<div className="text-foreground text-lg font-bold">{t("models.role.permissions")}</div>
</div>
<div>
{filteredItems().filter((f) => f.type === type).length === rolePermissions.length ? (
<Button type="button" variant="ghost" size="sm" disabled={!canUpdate} onClick={() => setRolePermissions([])}>
{t("shared.clear")}
</Button>
) : (
<Button type="button" variant="ghost" size="sm" disabled={!canUpdate} onClick={() => setRolePermissions(filteredItems().map((f) => f.name))}>
{t("shared.selectAll")}
</Button>
)}
</div>
</label>
<InputSearch value={searchInput} setValue={setSearchInput} />
<div>
{rolePermissions.map((permission) => {
return <input key={permission} type="hidden" name="permissions[]" value={permission} />;
})}
{filteredItems().map((permission, idx) => {
return (
<InputCheckboxWithDescription
disabled={!canUpdate}
name={permission.order + " " + permission.name}
title={permissionDisplayName(permission, t)}
description={permissionDisplayDescription(permission, t)}
value={hasPermission(permission)}
setValue={(e) => setPermission(permission, e)}
key={idx}
/>
);
})}
</div>
</div>
<div className="border-border shrink-0 border-t px-4 py-2 sm:px-6"></div>
</FormGroup>
);
}
@@ -0,0 +1,55 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import RolesAndPermissionsMatrix from "./RolesAndPermissionsMatrix";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (value: string) => value, i18n: { exists: () => true } }),
}));
vi.mock("~/components/ui/input/InputSearch", () => ({
default: () => <input aria-label="search" />,
}));
const now = new Date("2026-01-01T00:00:00.000Z");
const permission = {
id: "perm_1",
name: "app.settings.roles.view",
description: "Default permission app.settings.roles.view",
type: "app",
order: 1,
isDefault: true,
entityId: null,
createdAt: now,
updatedAt: now,
};
const role = {
id: "role_1",
name: "SuperUser",
description: "Has all app permissions",
type: "app",
order: 1,
isDefault: true,
assignToNewUsers: false,
createdAt: now,
updatedAt: now,
users: [],
permissions: [{ permission, permissionId: permission.id, roleId: "role_1", createdAt: now }],
};
describe("RolesAndPermissionsMatrix", () => {
it("renders display-safe permission and role labels", () => {
render(
<MemoryRouter>
<RolesAndPermissionsMatrix roles={[role] as Parameters<typeof RolesAndPermissionsMatrix>[0]["roles"]} permissions={[permission]} />
</MemoryRouter>
);
expect(screen.getByText("models.permission.defaultPermissions.title")).toBeInTheDocument();
expect(screen.getByText("models.permission.defaultPermissions.description")).toBeInTheDocument();
expect(screen.getByText("models.permission.matrix.rolePermissionCount")).toBeInTheDocument();
expect(screen.queryByText("Default permission app.settings.roles.view")).not.toBeInTheDocument();
expect(screen.queryByText("SuperUser (1)")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,75 @@
import type { Permission } from "@toolctrl/db/prisma-browser";
import clsx from "clsx";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
import CheckIcon from "~/components/ui/icons/CheckIcon";
import XIcon from "~/components/ui/icons/XIcon";
import InputSearch from "~/components/ui/input/InputSearch";
import TableSimple from "~/components/ui/tables/TableSimple";
import type { RoleWithPermissions } from "~/utils/db/permissions/roles.db.server";
import { permissionDisplayDescription, permissionDisplayName, roleDisplayName } from "./roleDisplay";
interface Props {
roles: RoleWithPermissions[];
permissions: Permission[];
className?: string;
}
export default function RolesAndPermissionsMatrix({ roles, permissions, className }: Props) {
const { t } = useTranslation();
const [searchInput, setSearchInput] = useState("");
const normalizedSearch = searchInput.trim().toLowerCase();
const rolePermissionNames = useMemo(
() => new Map(roles.map((role) => [role.name, new Set(role.permissions.map((permission) => permission.permission.name))])),
[roles]
);
const filteredPermissions = useMemo(() => {
if (!normalizedSearch) return permissions;
return permissions.filter((permission) => {
return permission.name.toLowerCase().includes(normalizedSearch) || permission.description.toLowerCase().includes(normalizedSearch);
});
}, [normalizedSearch, permissions]);
function getHeaders() {
const headers: RowHeaderDisplayDto<Permission>[] = [];
headers.push({
name: "permission",
title: t("models.permission.object"),
className: "w-full",
value: (item) => (
<div className="max-w-xs truncate">
<div>{permissionDisplayName(item, t)}</div>
<div className="text-muted-foreground truncate text-sm">{permissionDisplayDescription(item, t)}</div>
</div>
),
});
roles.forEach((role) => {
headers.push({
name: role.name,
title: t("models.permission.matrix.rolePermissionCount", { role: roleDisplayName(role, t), count: role.permissions.length }),
align: "center",
value: (permission) => {
const existing = rolePermissionNames.get(role.name)?.has(permission.name);
return (
<div className="flex justify-center">
<div
className={clsx("flex h-6 w-6 items-center justify-center rounded-full", existing ? "bg-background text-foreground" : "text-muted-foreground")}
>
{existing ? <CheckIcon className="h-4 w-4 text-green-500" /> : <XIcon className="text-muted-foreground h-4 w-4" />}
</div>
</div>
);
},
});
});
return headers;
}
return (
<div className="space-y-2">
<InputSearch value={searchInput} setValue={setSearchInput} />
<div className={className}>
<TableSimple items={filteredPermissions} headers={getHeaders()} />
</div>
</div>
);
}
@@ -0,0 +1,136 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocaleFormatters } from "~/locale/format";
import ButtonTertiary from "~/components/ui/buttons/ButtonTertiary";
import CheckIcon from "~/components/ui/icons/CheckIcon";
import XIcon from "~/components/ui/icons/XIcon";
import Modal from "~/components/ui/modals/Modal";
import TableSimple from "~/components/ui/tables/TableSimple";
import { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
import { RoleWithPermissionsAndUsers } from "~/utils/db/permissions/roles.db.server";
import RoleBadge from "./RoleBadge";
import { permissionDisplayDescription, permissionDisplayName, roleDisplayDescription, roleDisplayName } from "./roleDisplay";
interface Props {
items: RoleWithPermissionsAndUsers[];
className?: string;
canUpdate: boolean;
tenantId?: string | null;
}
export default function RolesTable({ items, canUpdate = true, tenantId = null }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const [actions, setActions] = useState<any[]>([]);
const [headers, setHeaders] = useState<RowHeaderDisplayDto<RoleWithPermissionsAndUsers>[]>([]);
const [permissionsModalOpen, setPermissionsModalOpen] = useState(false);
const [selectedRole, setSelectedRow] = useState<RoleWithPermissionsAndUsers>();
useEffect(() => {
if (canUpdate) {
setActions([
{
title: t("shared.edit"),
onClickRoute: (_: any, item: any) => item.id,
},
]);
}
const headers: RowHeaderDisplayDto<RoleWithPermissionsAndUsers>[] = [
{
name: "name",
title: t("models.role.name"),
value: (i) => roleDisplayName(i, t),
formattedValue: (i) => <RoleBadge item={i} />,
className: "max-w-xs truncate",
},
{
name: "description",
title: t("models.role.description"),
value: (i) => roleDisplayDescription(i, t) ?? "",
className: "max-w-xs truncate",
},
{
name: "permissions",
title: t("models.role.permissions"),
value: (i) => i.permissions.length,
formattedValue: (i) => (
<div className="w-40 truncate">
{tenantId ? (
<ButtonTertiary onClick={() => setSelectedRow(i)}>{t("models.role.viewPermissions", { count: i.permissions.length })}</ButtonTertiary>
) : (
<div>
{i.permissions.length}{" "}
<span className="text-muted-foreground max-w-sm text-xs italic">
({i.permissions.map((f) => permissionDisplayName(f.permission, t)).join(", ")})
</span>{" "}
</div>
)}
</div>
),
className: "max-w-xs truncate",
},
{
name: "users",
title: t("models.user.plural"),
value: (i) => i.users.filter((f) => f.tenantId === tenantId).length,
},
];
if (!tenantId) {
headers.push({
name: "assignToNewUsers",
title: t("models.role.assignToNewUsers"),
value: (i) => i.assignToNewUsers,
formattedValue: (i) => (i.assignToNewUsers ? <CheckIcon className="h-4 w-4 text-teal-500" /> : <XIcon className="h-4 w-4 text-gray-300" />),
});
}
setHeaders(headers);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canUpdate]);
useEffect(() => {
setPermissionsModalOpen(selectedRole !== undefined);
}, [selectedRole]);
useEffect(() => {
if (!permissionsModalOpen) {
setSelectedRow(undefined);
}
}, [permissionsModalOpen]);
return (
<div>
<TableSimple actions={actions} headers={headers} items={items} />
<Modal open={permissionsModalOpen} setOpen={setPermissionsModalOpen}>
<div className="flex items-baseline justify-between space-x-2">
<h4 className="text-lg font-bold">{selectedRole ? roleDisplayName(selectedRole, t) : undefined}</h4>
<p className="text-muted-foreground text-sm">
{t("models.permission.count", {
count: selectedRole?.permissions.length ?? 0,
formattedCount: formatters.number(selectedRole?.permissions.length ?? 0),
})}
</p>
</div>
<TableSimple
headers={[
{
name: "name",
title: t("models.permission.name"),
value: (i) => permissionDisplayName(i.permission, t),
formattedValue: (i) => <RoleBadge item={i.permission} kind="permission" />,
},
{
name: "description",
title: t("models.permission.description"),
value: (i) => permissionDisplayDescription(i.permission, t) ?? "",
},
]}
items={selectedRole?.permissions ?? []}
/>
</Modal>
</div>
);
}
@@ -0,0 +1,183 @@
import { useTranslation } from "react-i18next";
import EmptyState from "~/components/ui/emptyState/EmptyState";
import { RoleWithPermissions } from "~/utils/db/permissions/roles.db.server";
import { UserWithRoles } from "~/utils/db/users.db.server";
import UserBadge from "../users/UserBadge";
import clsx from "clsx";
import CheckIcon from "~/components/ui/icons/CheckIcon";
import XIcon from "~/components/ui/icons/XIcon";
import { Link } from "react-router";
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
import { roleDisplayName } from "./roleDisplay";
interface Props {
items: UserWithRoles[];
roles: RoleWithPermissions[];
className?: string;
onChange: (item: UserWithRoles, role: RoleWithPermissions, add: any) => void;
tenantId?: string | null;
disabled?: boolean;
onRoleClick?: (role: RoleWithPermissions) => void;
actions?: {
onEditRoute?: (item: UserWithRoles) => string;
onImpersonate?: (item: UserWithRoles) => void;
};
}
export default function UserRolesTable({ items, roles, className, onChange, tenantId = null, disabled, onRoleClick, actions }: Props) {
const { t } = useTranslation();
const appOrAdminData = useAppOrAdminData();
return (
<div>
{(() => {
if (items.length === 0) {
return (
<div>
<EmptyState
className="bg-background"
captions={{
thereAreNo: t("app.users.empty"),
}}
/>
</div>
);
} else {
return (
<div>
<div>
<div className="flex flex-col">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<div className="border-border overflow-hidden border shadow-xs sm:rounded-lg">
<table className="divide-border min-w-full divide-y">
<thead className="bg-secondary">
<tr>
<th
scope="col"
className="text-muted-foreground w-64 select-none truncate px-1 py-1 pl-4 text-left text-xs font-medium tracking-wider"
>
<div className="text-muted-foreground flex items-center space-x-1">
<div>{t("models.user.plural")}</div>
</div>
</th>
{roles.map((role, idx) => {
return (
<th
key={idx}
scope="col"
className="text-muted-foreground select-none truncate px-1 py-1 text-center text-xs font-medium tracking-wider"
>
<div className="text-muted-foreground flex items-center justify-center space-x-1">
{onRoleClick ? (
<button type="button" onClick={() => onRoleClick(role)} className="hover:underline">
{roleDisplayName(role, t)}
</button>
) : (
<div>{roleDisplayName(role, t)}</div>
)}
</div>
</th>
);
})}
{actions && (
<td className="text-muted-foreground select-none truncate px-1 py-1 text-center text-xs font-medium tracking-wider"></td>
)}
</tr>
</thead>
<tbody className="divide-border bg-background divide-y">
{items.map((item, idx) => {
return (
<tr key={idx}>
<td className="text-muted-foreground whitespace-nowrap px-1 py-1 pl-4 text-sm">
<UserBadge
item={item}
withAvatar={true}
admin={item.admin}
href={!actions?.onEditRoute ? undefined : actions.onEditRoute(item)}
roles={item.roles}
/>
</td>
{roles.map((role) => {
const existing = item.roles.find((f) => f.roleId === role.id && f.tenantId === tenantId) !== undefined;
return (
<td key={role.name} className="text-muted-foreground whitespace-nowrap px-1 py-1 text-center text-sm">
<div className="flex justify-center">
<button
type="button"
data-ui-id="ui.settings.rolesAndPermissions.users.roleToggle"
data-user-id={item.id}
data-role-id={role.id}
aria-label={t(
existing
? "settings.rolesAndPermissions.actions.unassignRoleFromUser"
: "settings.rolesAndPermissions.actions.assignRoleToUser",
{ role: roleDisplayName(role, t), user: item.email }
)}
disabled={disabled}
onClick={() => onChange(item, role, !existing)}
className={clsx(
"flex h-6 w-6 items-center justify-center rounded-full",
existing ? "bg-background text-foreground" : "text-muted-foreground",
!disabled &&
existing &&
"hover:bg-green-200 hover:text-green-600 dark:hover:bg-green-900 dark:hover:text-green-50",
!disabled && !existing && "hover:text-muted-foreground dark:hover:bg-secondary hover:bg-gray-200"
)}
>
{existing ? <CheckIcon className="h-4 w-4 text-green-500" /> : <XIcon className="text-muted-foreground h-4 w-4" />}
</button>
</div>
</td>
);
})}
{/* {onEditRoute && (
<td className="whitespace-nowrap px-1 py-1 text-center text-sm text-muted-foreground">
<Link to={onEditRoute(item)} className="hover:underline">
{t("shared.edit")}
</Link>
</td>
)} */}
{actions && (
<td className="text-muted-foreground whitespace-nowrap px-1 py-1 text-center text-sm">
<div className="flex items-center space-x-2">
{actions.onImpersonate && (
<button
type="button"
disabled={item.id === appOrAdminData.user.id}
onClick={() => (actions?.onImpersonate ? actions?.onImpersonate(item) : {})}
className={clsx(
"text-muted-foreground",
item.id !== appOrAdminData.user.id ? "hover:underline" : "cursor-not-allowed"
)}
>
{t("models.user.impersonate")}
</button>
)}
{actions.onEditRoute && (
<Link to={actions?.onEditRoute(item)} className="text-muted-foreground hover:underline">
{t("shared.edit")}
</Link>
)}
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
})()}
</div>
);
}
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { readRepoFile } from "~/utils/testing/repoPaths";
import { formatRoleDisplayList, permissionDisplayDescription, permissionDisplayName, roleDisplayDescription, roleDisplayName } from "./roleDisplay";
const t = (key: string) => `translated:${key}`;
function readDefaultPermissionSeeds() {
const source = readRepoFile("packages/db/src/permissions-seed.server.ts");
const match = source.match(/export const defaultPermissionSeeds = \[([\s\S]*?)\] as const;/u);
if (!match) {
throw new Error("defaultPermissionSeeds export not found");
}
return [...match[1].matchAll(/"([^"]+)"/gu)].map((seed) => seed[1]);
}
function getDefaultPermissionTranslation(source: Record<string, any>, key: string) {
const prefix = "models.permission.defaultPermissions.";
if (!key.startsWith(prefix)) {
return undefined;
}
const relativeKey = key.slice(prefix.length);
if (relativeKey.startsWith("scopes.")) {
return source.models?.permission?.defaultPermissions?.scopes?.[relativeKey.slice("scopes.".length)];
}
if (relativeKey.startsWith("actions.")) {
return source.models?.permission?.defaultPermissions?.actions?.[relativeKey.slice("actions.".length)];
}
return undefined;
}
describe("roleDisplay", () => {
it("localizes seeded default role names without changing custom roles", () => {
expect(roleDisplayName({ name: "SuperAdmin", description: "Has all admin permissions" }, t)).toBe("translated:models.role.defaultRoles.names.superAdmin");
expect(roleDisplayName({ name: "Billing Admin", description: "Has subscription permissions" }, t)).toBe(
"translated:models.role.defaultRoles.names.billingAdmin"
);
expect(roleDisplayName({ name: "Regional Admin", description: "Custom tenant role" }, t)).toBe("Regional Admin");
});
it("localizes seeded default role descriptions without changing custom descriptions", () => {
expect(roleDisplayDescription({ name: "Admin", description: "Has all app permissions but account deletion" }, t)).toBe(
"translated:models.role.defaultRoles.descriptions.allAppPermissionsButAccountDeletion"
);
expect(roleDisplayDescription({ name: "Approver", description: "Can approve invoices" }, t)).toBe("Can approve invoices");
expect(roleDisplayDescription({ name: "Approver", description: null })).toBeUndefined();
});
it("resolves seeded i18n-key descriptions (e.g. SHK roles) via the namespaced key", () => {
// Seeds wie die SHK-Rollen (R-P2b) speichern einen i18n-Key in description statt Klartext.
const tNs = (key: string, options?: Record<string, unknown>) =>
key === "settings:shkRoles.monteur.description" ? "Feld/mobil: eigene Aufträge und Aufgaben." : ((options?.defaultValue as string | undefined) ?? key);
// Punkt-Format (so wie der Seed es ablegt) wird namespace-normalisiert aufgelöst.
expect(roleDisplayDescription({ name: "Monteur", description: "settings.shkRoles.monteur.description" }, tNs)).toBe(
"Feld/mobil: eigene Aufträge und Aufgaben."
);
// Doppelpunkt-Format (kanonisch) funktioniert ebenfalls.
expect(roleDisplayDescription({ name: "Monteur", description: "settings:shkRoles.monteur.description" }, tNs)).toBe(
"Feld/mobil: eigene Aufträge und Aufgaben."
);
// Nicht existierender Key -> roher Wert bleibt stehen (kein nackter Key durch Erfindung).
expect(roleDisplayDescription({ name: "Ghost", description: "settings.shkRoles.unknown.description" }, tNs)).toBe(
"settings.shkRoles.unknown.description"
);
// Reiner Klartext (kein bekannter Namespace) wird unverändert durchgereicht.
expect(roleDisplayDescription({ name: "Approver", description: "Genehmigt Rechnungen." }, tNs)).toBe("Genehmigt Rechnungen.");
});
it("keeps the raw database value when no translator is provided", () => {
expect(roleDisplayName({ name: "SuperUser", description: "Has all app permissions" })).toBe("SuperUser");
expect(roleDisplayDescription({ name: "SuperUser", description: "Has all app permissions" })).toBe("Has all app permissions");
});
it("localizes default permission seed descriptions into readable labels", () => {
expect(permissionDisplayName({ name: "app.settings.members.view", description: "Default permission app.settings.members.view" }, t)).toBe(
"translated:models.permission.defaultPermissions.title"
);
expect(permissionDisplayName({ name: "admin.roles.set", description: "Default permission admin.roles.set" }, t)).toBe(
"translated:models.permission.defaultPermissions.title"
);
expect(permissionDisplayDescription({ name: "app.settings.members.view", description: "Default permission app.settings.members.view" })).toBe(
"Default permission for App Settings Members"
);
expect(permissionDisplayDescription({ name: "app.settings.members.view", description: "Default permission app.settings.members.view" }, t)).toBe(
"translated:models.permission.defaultPermissions.description"
);
});
it("has translation coverage for every default permission seed scope and action", () => {
const permissions = readDefaultPermissionSeeds();
const translationSpy = (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key);
const locales = ["en", "de"].map((locale) => ({
locale,
translations: JSON.parse(readRepoFile(`apps/web/public/locales/${locale}/translations.json`)) as Record<string, any>,
}));
for (const permission of permissions) {
const result = permissionDisplayName({ name: permission, description: `Default permission ${permission}` }, translationSpy);
const serializedOptions = result.slice(result.indexOf(":") + 1);
const options = JSON.parse(serializedOptions ?? "{}") as { scope?: string; action?: string };
expect(options.scope, `${permission} should use a translated scope`).toMatch(/^models\.permission\.defaultPermissions\.scopes\./u);
expect(options.action, `${permission} should use a translated action`).toMatch(/^models\.permission\.defaultPermissions\.actions\./u);
for (const { locale, translations } of locales) {
expect(getDefaultPermissionTranslation(translations, options.scope!), `${locale} missing ${options.scope}`).toBeTruthy();
expect(getDefaultPermissionTranslation(translations, options.action!), `${locale} missing ${options.action}`).toBeTruthy();
}
}
});
it("formats visible role lists while keeping raw values outside display helpers", () => {
expect(formatRoleDisplayList([{ name: "SuperUser", description: "Has all app permissions" }], t)).toBe(
"translated:models.role.defaultRoles.names.superUser"
);
});
});
@@ -0,0 +1,228 @@
import type { Permission, Role } from "@toolctrl/db/prisma-browser";
import { i18nNamespaces } from "~/locale/i18n";
type DisplayItem = Pick<Permission | Role, "name" | "description">;
type Translate = (key: string, options?: Record<string, unknown>) => string;
const I18N_NAMESPACE_SET = new Set<string>(i18nNamespaces);
// Sentinel, das mit keinem realen Übersetzungstext kollidiert -> erkennt einen i18next-Miss.
const I18N_MISS = "__i18n_miss__";
// Manche Tenant-Seeds (z.B. die SHK-Standardrollen, R-P2b) legen in `description` einen i18n-Key
// statt Klartext ab, damit kein Hardcoded-String im Seed steht (Hardcoded-String-Disziplin). Der
// Rollen-Editor rendert ihn via t(). i18next adressiert ausgelagerte Namespaces mit ":"
// (`settings:foo.bar`); im Seed steht der Key historisch mit "." (`settings.foo.bar`), was sonst
// im defaultNS `translations` landet und als nackter Key erscheint. Diese Funktion erkennt einen
// namespaced Key (Punkt ODER Doppelpunkt) und löst ihn auf; bei reinem Klartext oder einem nicht
// existierenden Key gibt sie null zurück (dann bleibt der rohe Wert stehen).
function resolveStoredDescriptionKey(value: string, t: Translate): string | null {
let namespacedKey: string | null = null;
const colonIndex = value.indexOf(":");
if (colonIndex > 0 && I18N_NAMESPACE_SET.has(value.slice(0, colonIndex))) {
namespacedKey = value;
} else {
const dotIndex = value.indexOf(".");
if (dotIndex > 0 && I18N_NAMESPACE_SET.has(value.slice(0, dotIndex))) {
namespacedKey = `${value.slice(0, dotIndex)}:${value.slice(dotIndex + 1)}`;
}
}
if (!namespacedKey) {
return null;
}
const translated = t(namespacedKey, { defaultValue: I18N_MISS });
return translated === I18N_MISS ? null : translated;
}
const ACRONYMS: Record<string, string> = {
ai: "AI",
api: "API",
csv: "CSV",
id: "ID",
url: "URL",
};
const DEFAULT_ROLE_NAME_KEYS: Record<string, string> = {
Admin: "admin",
"Billing Admin": "billingAdmin",
SuperAdmin: "superAdmin",
SuperUser: "superUser",
User: "user",
};
const DEFAULT_ROLE_DESCRIPTION_KEYS: Record<string, string> = {
"Has all admin permissions": "allAdminPermissions",
"Has all app permissions": "allAppPermissions",
"Has all app permissions but account deletion": "allAppPermissionsButAccountDeletion",
"Has subscription permissions": "subscriptionPermissions",
"Has regular permissions": "regularPermissions",
};
const DEFAULT_PERMISSION_ACTION_KEYS: Record<string, string> = {
view: "view",
create: "create",
update: "update",
delete: "delete",
full: "full",
set: "set",
manage: "manage",
reset: "reset",
subscription: "subscription",
use: "use",
users: "users",
impersonate: "impersonate",
changePassword: "changePassword",
};
const DEFAULT_PERMISSION_SCOPE_KEYS: Record<string, string> = {
"app.dashboard": "app.dashboard",
"app.settings.members": "app.settings.members",
"app.settings.roles": "app.settings.roles",
"app.settings.groups": "app.settings.groups",
"app.settings.account": "app.settings.account",
"app.settings.accounts": "app.settings.accounts",
"app.settings.apiKeys": "app.settings.apiKeys",
"app.settings.auditTrails": "app.settings.auditTrails",
"app.settings.subscription": "app.settings.subscription",
"app.settings.subscription.invoices": "app.settings.subscription.invoices",
"app.plugins": "app.plugins",
"admin.account": "admin.account",
"admin.account.settings": "admin.account.settings",
"admin.accountTypes": "admin.accountTypes",
"admin.accounts": "admin.accounts",
"admin.analytics": "admin.analytics",
"admin.apiKeys": "admin.apiKeys",
"admin.auditTrails": "admin.auditTrails",
"admin.blacklist": "admin.blacklist",
"admin.dashboard": "admin.dashboard",
"admin.emails": "admin.emails",
"admin.entities": "admin.entities",
"admin.events": "admin.events",
"admin.featureFlags": "admin.featureFlags",
"admin.formulas": "admin.formulas",
"admin.kb": "admin.kb",
"admin.marketing.funnels": "admin.marketing.funnels",
"admin.marketing.integrations": "admin.marketing.integrations",
"admin.marketing.leads": "admin.marketing.leads",
"admin.metrics": "admin.metrics",
"admin.notifications": "admin.notifications",
"admin.onboarding": "admin.onboarding",
"admin.pricing": "admin.pricing",
"admin.prompts": "admin.prompts",
"admin.relationships": "admin.relationships",
"admin.roles": "admin.roles",
"admin.settings.analytics": "admin.settings.analytics",
"admin.settings.authentication": "admin.settings.authentication",
"admin.settings.cookies": "admin.settings.cookies",
"admin.settings.danger": "admin.settings.danger",
"admin.settings.general": "admin.settings.general",
"admin.settings.internationalization": "admin.settings.internationalization",
"admin.settings.seo": "admin.settings.seo",
"admin.tenantIpAddress": "admin.tenantIpAddress",
"admin.users": "admin.users",
};
function humanizeIdentifier(value: string) {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.split(/[._\-\s]+/)
.filter(Boolean)
.map((part) => {
const lower = part.toLowerCase();
return ACRONYMS[lower] ?? lower.charAt(0).toUpperCase() + lower.slice(1);
})
.join(" ");
}
function parseDefaultPermissionName(name: string) {
const parts = name.split(".");
if (parts.length < 2) {
return null;
}
const action = parts.at(-1);
if (!action) {
return null;
}
const scope = parts.slice(0, -1).join(".");
return { scope, action };
}
function defaultPermissionDisplayName(name: string, t?: Translate) {
const parsed = parseDefaultPermissionName(name);
if (!parsed) {
return humanizeIdentifier(name);
}
const actionKey = DEFAULT_PERMISSION_ACTION_KEYS[parsed.action];
const scopeKey = DEFAULT_PERMISSION_SCOPE_KEYS[parsed.scope];
const action = actionKey
? t
? t(`models.permission.defaultPermissions.actions.${actionKey}`)
: humanizeIdentifier(parsed.action)
: humanizeIdentifier(parsed.action);
const scope = scopeKey
? t
? t(`models.permission.defaultPermissions.scopes.${scopeKey}`)
: humanizeIdentifier(parsed.scope)
: humanizeIdentifier(parsed.scope);
return t ? t("models.permission.defaultPermissions.title", { scope, action }) : `${scope}: ${action}`;
}
function defaultPermissionDisplayDescription(name: string, t?: Translate) {
const parsed = parseDefaultPermissionName(name);
if (!parsed) {
return undefined;
}
const scopeKey = DEFAULT_PERMISSION_SCOPE_KEYS[parsed.scope];
const scope = scopeKey
? t
? t(`models.permission.defaultPermissions.scopes.${scopeKey}`)
: humanizeIdentifier(parsed.scope)
: humanizeIdentifier(parsed.scope);
return t ? t("models.permission.defaultPermissions.description", { scope }) : `Default permission for ${scope}`;
}
function isDefaultPermissionDescription(item: DisplayItem) {
return item.description?.trim() === `Default permission ${item.name}`;
}
export function roleDisplayName(item: DisplayItem, t?: Translate) {
const key = DEFAULT_ROLE_NAME_KEYS[item.name];
return key && t ? t(`models.role.defaultRoles.names.${key}`) : item.name;
}
export function roleDisplayDescription(item: DisplayItem, t?: Translate) {
const description = item.description?.trim();
if (!description) {
return undefined;
}
const key = DEFAULT_ROLE_DESCRIPTION_KEYS[description];
if (key && t) {
return t(`models.role.defaultRoles.descriptions.${key}`);
}
if (t) {
const resolved = resolveStoredDescriptionKey(description, t);
if (resolved !== null) {
return resolved;
}
}
return description;
}
export function permissionDisplayName(item: DisplayItem, t?: Translate) {
if (isDefaultPermissionDescription(item)) {
return defaultPermissionDisplayName(item.name, t);
}
return item.description?.trim() || humanizeIdentifier(item.name);
}
export function permissionDisplayDescription(item: DisplayItem, t?: Translate) {
if (isDefaultPermissionDescription(item)) {
return defaultPermissionDisplayDescription(item.name, t);
}
const readableName = humanizeIdentifier(item.name);
return item.description?.trim() && item.description.trim() !== readableName ? readableName : undefined;
}
export function formatRoleDisplayList(items: readonly DisplayItem[], t?: Translate) {
return items.map((item) => roleDisplayName(item, t)).join(", ");
}
@@ -0,0 +1,154 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import clsx from "~/utils/shared/ClassesUtils";
import type { TenantUserInvitation } from "@toolctrl/db/prisma-browser";
import { useSubmit } from "react-router";
import ButtonTertiary from "~/components/ui/buttons/ButtonTertiary";
import UserBadge from "../../users/UserBadge";
import { useRootData } from "~/utils/data/useRootData";
import { ChevronUpIcon, ChevronDownIcon } from "~/components/ui/icons/vendor";
interface Props {
items: TenantUserInvitation[];
canDelete: boolean;
}
export default function MemberInvitationsListAndTable({ items, canDelete }: Props) {
const { t } = useTranslation();
const { appConfiguration, csrf } = useRootData();
const submit = useSubmit();
const [sortByColumn, setSortByColumn] = useState("");
const [sortDirection, setSortDirection] = useState(1);
const headers = [
// {
// name: "role",
// title: t("settings.profile.type"),
// },
{
name: "email",
title: t("account.shared.email"),
},
{
name: "link",
title: t("settings.members.invitations.link"),
},
];
// function getUserRole(item: TenantUserInvitation) {
// return t("settings.profile.types." + TenantUserType[item.type]);
// }
// function getUserRoleClass(item: TenantUserInvitation) {
// switch (item.type as TenantUserType) {
// case TenantUserType.OWNER:
// return "bg-secondary text-foreground border border-border";
// case TenantUserType.ADMIN:
// return "bg-rose-50 border border-rose-200";
// case TenantUserType.MEMBER:
// return "bg-blue-50 border border-blue-200";
// }
// }
function sortBy(column: string | undefined) {
if (column) {
setSortDirection(sortDirection === -1 ? 1 : -1);
setSortByColumn(column);
}
}
function deleteUserInvitation(invitation: TenantUserInvitation) {
const form = new FormData();
if (csrf) {
form.set("csrf", csrf);
}
form.set("action", "delete-invitation");
form.set("invitation-id", invitation.id);
submit(form, {
method: "post",
});
}
function getInvitationLink(invitation: TenantUserInvitation) {
return appConfiguration.app.url + `/invitation/${invitation.id}`;
}
return (
<div>
<div className="flex flex-col">
<div className="overflow-x-auto">
<div className="inline-block min-w-full py-2 align-middle">
<div className="border-border overflow-hidden border shadow-xs sm:rounded-lg">
<table className="divide-border min-w-full divide-y">
<thead className="bg-secondary">
<tr>
{headers.map((header, idx) => {
return (
<th
key={idx}
onClick={() => sortBy(header.name)}
scope="col"
className={clsx(
"text-muted-foreground select-none truncate px-2 py-1 text-left text-xs font-medium tracking-wider",
header.name && "hover:text-foreground/80 cursor-pointer"
)}
>
<div className="text-muted-foreground flex items-center space-x-1">
<div>{header.title}</div>
<div className={clsx((!header.name || sortByColumn !== header.name) && "invisible")}>
{(() => {
if (sortDirection === -1) {
return <ChevronUpIcon className="h-5 w-5" />;
} else {
return <ChevronDownIcon className="h-5 w-5" />;
}
})()}
</div>
</div>
</th>
);
})}
</tr>
</thead>
<tbody className="divide-border bg-background divide-y">
{items.map((item, idx) => {
return (
<tr key={idx}>
{/* <td className="whitespace-nowrap px-2 py-1 text-sm text-muted-foreground">
<span
className={clsx("inline-flex w-28 items-center justify-center rounded-sm px-1 py-1 text-xs font-medium", getUserRoleClass(item))}
>
{getUserRole(item)}
</span>
</td> */}
<td className="text-muted-foreground whitespace-nowrap px-2 py-1 text-sm">
<UserBadge
item={{
id: "",
email: item.email,
firstName: item.firstName,
lastName: item.lastName,
}}
/>
</td>
<td className="text-muted-foreground whitespace-nowrap px-2 py-1 text-sm">
<a href={getInvitationLink(item)} target="_blank" type="button" rel="noreferrer" className="truncate font-medium italic underline">
{t("settings.members.invitations.openLink")}
</a>
</td>
<td className="text-muted-foreground w-20 whitespace-nowrap px-2 py-2 text-sm">
<div className="flex items-center space-x-2">
<ButtonTertiary destructive disabled={!canDelete} type="button" onClick={() => deleteUserInvitation(item)}>
{t("shared.delete")}
</ButtonTertiary>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
import type { TenantUserWithUser } from "~/utils/db/tenants.db.server";
import type { TenantUser } from "@toolctrl/db/prisma-browser";
import { useLocaleFormatters } from "~/locale/format";
import UserBadge from "../../users/UserBadge";
import TableSimple from "~/components/ui/tables/TableSimple";
import { EditIcon } from "~/components/ui/icons/vendor";
interface Props {
items: TenantUserWithUser[];
}
export default function MembersListAndTable({ items }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
function getUserRole(item: TenantUser) {
return t("settings.profile.types." + TenantUserType[item.type]);
}
function getUserStatus(item: TenantUser) {
return t("settings.profile.status." + TenantUserStatus[item.status]);
}
return (
<div>
<TableSimple
items={items}
headers={[
{
name: "user",
title: t("models.user.object"),
value: (item) => <UserBadge item={item.user} withAvatar={true} admin={item.user.admin} href={"edit/" + item.id} />,
},
{
name: "role",
title: t("settings.profile.type"),
value: (item) => <div>{getUserRole(item)}</div>,
},
{
name: "status",
title: t("shared.status"),
value: (item) => <div>{getUserStatus(item)}</div>,
},
{
name: "createdAt",
title: t("shared.createdAt"),
value: (item) => (
<time dateTime={new Date(item.user.createdAt).toISOString()} title={formatters.dateTime(item.user.createdAt, { timeStyle: "medium" })}>
{formatters.relativeDate(item.user.createdAt)}
</time>
),
},
{
name: "actions",
title: t("shared.actions"),
value: (item) => (
<div className="flex items-center space-x-2">
<Link to={"edit/" + item.id} className="flex items-center space-x-2 text-theme-600 hover:text-theme-900">
<EditIcon className="h-4 w-4" />
<div>{t("shared.edit")}</div>
</Link>
</div>
),
},
]}
/>
</div>
);
}
@@ -0,0 +1,175 @@
import clsx from "clsx";
import { useRef, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Form, useActionData, useLocation, useNavigate, useNavigation, useParams } from "react-router";
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
import { NewMemberActionData } from "~/routes/app.$tenant/settings/members/new";
import UrlUtils from "~/utils/app/UrlUtils";
import { useEscapeKeypress } from "~/utils/shared/KeypressUtils";
import CheckPlanFeatureLimit from "../subscription/CheckPlanFeatureLimit";
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
import toast from "react-hot-toast";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
import { useRootData } from "~/utils/data/useRootData";
interface Props {
featurePlanUsage: PlanFeatureUsageDto | undefined;
}
export default function NewMember({ featurePlanUsage }: Props) {
const params = useParams();
const location = useLocation();
const actionData = useActionData<NewMemberActionData>();
const { t } = useTranslation();
const navigate = useNavigate();
const navigation = useNavigation();
const rootData = useRootData();
const loading = navigation.state === "submitting";
const inputEmail = useRef<HTMLInputElement>(null);
const [sendEmail, setSendEmail] = useState<boolean>(false);
useEffect(() => {
if (actionData?.success) {
toast.success(actionData.success);
} else if (actionData?.error) {
toast.error(actionData.error);
}
}, [actionData]);
useEffect(() => {
// nextTick(() => {
if (inputEmail.current) {
inputEmail.current?.focus();
inputEmail.current?.select();
}
// });
}, []);
function close() {
if (params.tenant) {
navigate(UrlUtils.currentTenantUrl(params, "settings/members"), { replace: true });
} else {
navigate(adminPath("accounts/users", { mode: "host-aware" }), { replace: true });
}
}
useEscapeKeypress(close);
return (
<CheckPlanFeatureLimit item={featurePlanUsage}>
<Form method="post" className="space-y-4" data-ui-id="ui.settings.members.new.form">
{rootData.csrf && <input type="hidden" name="csrf" value={rootData.csrf} />}
<div className="grid grid-cols-2 gap-2">
{/*Email */}
<div className="col-span-2">
<label htmlFor="email" className="text-foreground/80 flex space-x-1 truncate text-xs font-medium">
<span>{t("models.user.email")}</span>
<span className="ml-1 text-red-500">*</span>
</label>
<div className="mt-1 flex w-full rounded-md shadow-2xs">
<input
type="email"
ref={inputEmail}
name="email"
id="email"
autoComplete="off"
required
defaultValue={actionData?.fields?.email}
disabled={loading}
className={clsx(
"focus:border-theme-500 focus:ring-ring border-border block w-full min-w-0 flex-1 rounded-md lowercase sm:text-sm",
loading && "bg-secondary/90 cursor-not-allowed"
)}
/>
</div>
</div>
{/*Email: End */}
{/*User First Name */}
<div>
<label htmlFor="first-name" className="text-foreground/80 flex space-x-1 truncate text-xs font-medium">
<span>{t("models.user.firstName")}</span>
<span className="ml-1 text-red-500">*</span>
</label>
<div className="mt-1 flex w-full rounded-md shadow-2xs">
<input
type="text"
id="first-name"
name="first-name"
autoComplete="off"
required
defaultValue={actionData?.fields?.firstName}
className={clsx(
"focus:border-theme-500 focus:ring-ring border-border block w-full min-w-0 flex-1 rounded-md sm:text-sm",
loading && "bg-secondary/90 cursor-not-allowed"
)}
/>
</div>
</div>
{/*User First Name: End */}
{/*User Last Name */}
<div>
<label htmlFor="last-name" className="text-foreground/80 block truncate text-xs font-medium">
{t("models.user.lastName")}
</label>
<div className="mt-1 flex w-full rounded-md shadow-2xs">
<input
type="text"
id="last-name"
name="last-name"
autoComplete="off"
defaultValue={actionData?.fields?.lastName}
className={clsx(
"focus:border-theme-500 focus:ring-ring border-border block w-full min-w-0 flex-1 rounded-md sm:text-sm",
loading && "bg-secondary/90 cursor-not-allowed"
)}
/>
</div>
</div>
{/*User Last Name: End */}
<div className="col-span-2">
<InputCheckboxWithDescription
name="send-invitation-email"
title={t("settings.members.sendInvitationEmail.title")}
description={t("settings.members.sendInvitationEmail.description")}
value={sendEmail}
setValue={setSendEmail}
/>
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="text-theme-700 text-sm">{loading && <div>{t("shared.loading")}...</div>}</div>
<div className="flex items-center space-x-2">
<button
disabled={loading}
className={clsx(
"focus:ring-ring hover:bg-secondary border-border bg-background text-foreground/80 inline-flex items-center space-x-2 border px-3 py-2 font-medium shadow-2xs focus:outline-hidden focus:ring-2 focus:ring-offset-2 sm:rounded-md sm:text-sm",
loading && "bg-secondary/90 cursor-not-allowed"
)}
type="button"
onClick={close}
>
<div>{t("shared.cancel")}</div>
</button>
<button
disabled={loading}
className={clsx(
"bg-primary hover:bg-primary/90 focus:ring-primary inline-flex items-center space-x-2 border border-transparent px-3 py-2 font-medium text-white shadow-2xs focus:outline-hidden focus:ring-2 focus:ring-offset-2 sm:rounded-md sm:text-sm",
loading && "cursor-not-allowed opacity-50"
)}
type="submit"
>
<div>{t("shared.invite")}</div>
</button>
</div>
</div>
</Form>
</CheckPlanFeatureLimit>
);
}
@@ -0,0 +1,28 @@
// @vitest-environment node
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const componentPaths = [
"apps/web/app/components/core/settings/subscription/TenantProduct.tsx",
"apps/web/app/components/core/settings/subscription/MyPlanFeatureUsage.tsx",
"apps/web/app/components/core/settings/subscription/PlanFeatureDescription.tsx",
];
function readComponent(relativePath: string): string {
return fs.readFileSync(path.join(root, relativePath), "utf8");
}
describe("active subscription settings locale formatting", () => {
it("uses the central locale formatters for visible prices, counts, and dates", () => {
const source = componentPaths.map(readComponent).join("\n");
expect(source).toContain('from "~/locale/format"');
expect(source).toContain("useLocaleFormatters()");
expect(source).toContain("formatters.");
expect(source).not.toContain('from "~/utils/shared/DateUtils"');
expect(source).not.toContain('from "~/utils/shared/NumberUtils"');
expect(source).not.toContain('from "~/utils/helpers/PricingHelper"');
});
});
@@ -0,0 +1,37 @@
// @vitest-environment node
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const componentPaths = [
"apps/web/app/components/core/settings/subscription/MyInvoices.tsx",
"apps/web/app/components/core/settings/subscription/MyPayments.tsx",
"apps/web/app/components/core/settings/subscription/MyUpcomingInvoice.tsx",
];
function readComponent(relativePath: string): string {
return fs.readFileSync(path.join(root, relativePath), "utf8");
}
describe("subscription billing tables locale formatting", () => {
it("uses the central locale formatters for visible money and dates", () => {
for (const relativePath of componentPaths) {
const source = readComponent(relativePath);
expect(source).toContain('import { useLocaleFormatters } from "~/locale/format";');
expect(source).toContain("useLocaleFormatters()");
expect(source).toContain("formatters.");
expect(source).not.toContain('from "~/utils/shared/DateUtils"');
expect(source).not.toContain('from "~/utils/shared/NumberUtils"');
expect(source).not.toContain('from "~/utils/helpers/PricingHelper"');
}
});
it("does not render a hardcoded dollar prefix in payment amounts", () => {
const source = readComponent("apps/web/app/components/core/settings/subscription/MyPayments.tsx");
expect(source).toContain("formatters.currency(i.amount / 100, i.currency.toUpperCase())");
expect(source).not.toContain("$");
});
});
@@ -0,0 +1,248 @@
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import clsx from "~/utils/shared/ClassesUtils";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigation, useSubmit } from "react-router";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import { getAllSubscriptionProducts } from "~/utils/db/subscriptionProducts.db.server";
import { SubscriptionProductDto } from "~/application/dtos/subscriptions/SubscriptionProductDto";
import { SubscriptionPriceDto } from "~/application/dtos/subscriptions/SubscriptionPriceDto";
import { PricingModel } from "~/application/enums/subscriptions/PricingModel";
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
import InputNumber from "~/components/ui/input/InputNumber";
import { TenantSubscriptionWithDetails } from "~/utils/db/tenantSubscriptions.db.server";
import Modal from "~/components/ui/modals/Modal";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import { useLocaleFormatters } from "~/locale/format";
import SubscriptionUtils from "~/utils/app/SubscriptionUtils";
import { CheckCircleIcon, XCircleIcon } from "~/components/ui/icons/vendor";
interface Props {
current: TenantSubscriptionWithDetails | null;
items: Awaited<ReturnType<typeof getAllSubscriptionProducts>>;
billingPeriod: SubscriptionBillingPeriod;
currency: string;
canSubscribe: boolean;
}
export default function ChangeSubscription({ items, current, billingPeriod, currency, canSubscribe }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const navigation = useNavigation();
const loading = navigation.state === "submitting";
const submit = useSubmit();
const confirmModal = useRef<RefConfirmModal>(null);
const [showQuantityModal, setShowQuantityModal] = useState(false);
const [products] = useState(items.filter((f) => f.public));
const [quantity, setQuantity] = useState<number>(1);
const [selectedPlan, setSelectedPlan] = useState<SubscriptionProductDto>();
useEffect(() => {
setQuantity(1);
}, [current]);
function getPrice(product: SubscriptionProductDto): SubscriptionPriceDto | undefined {
const prices = product.prices.find(
(f) => (f.billingPeriod === billingPeriod || f.billingPeriod === SubscriptionBillingPeriod.ONCE) && f.currency === currency && f.active
);
return prices;
}
function getPriceAmount(product: SubscriptionProductDto, quantity = 1) {
const price = getPrice(product);
return formatters.currency(Number(price?.price ?? 0) * quantity, (price?.currency ?? currency).toUpperCase(), { maximumFractionDigits: 0 });
}
function getPricePeriodSuffix(product: SubscriptionProductDto) {
const price = getPrice(product);
if (!price) {
return null;
}
return <span className="text-muted-foreground lowercase">/{SubscriptionUtils.getBillingPeriodShortDescription(t, price.billingPeriod)}</span>;
}
function selectPrice(product: SubscriptionProductDto) {
const price = getPrice(product);
if (!price || !price.id) {
return;
}
setSelectedPlan(product);
if (product.model === PricingModel.PER_SEAT) {
setShowQuantityModal(true);
} else if (!isCurrent(product)) {
const form = new FormData();
form.set("action", "subscribe");
form.set("product-id", product?.id?.toString() ?? "");
form.set("billing-period", billingPeriod.toString());
form.set("currency", currency);
submit(form, {
method: "post",
});
} else {
cancel();
}
}
function confirmedQuantity() {
if (!selectedPlan) {
return;
}
const price = getPrice(selectedPlan);
if (!price || !price.id) {
return;
}
const form = new FormData();
form.set("action", "subscribe");
form.set("quantity", quantity.toString());
form.set("price-id", price.id);
submit(form, {
method: "post",
});
}
function isCurrent(plan: SubscriptionProductDto) {
return current?.products.find((f) => f.subscriptionProductId === plan.id) !== undefined;
}
function getButtonTitle(plan: SubscriptionProductDto, withSeats: boolean = false) {
if (isCurrent(plan)) {
return t("shared.subscribed");
}
const subscriptionPrice = getPrice(plan);
if (withSeats && plan.model === PricingModel.PER_SEAT && subscriptionPrice) {
return t("pricing.subscribePlanAmountPeriod", {
plan: t(plan.title),
amount: getPriceAmount(plan, quantity),
period: SubscriptionUtils.getBillingPeriodShortDescription(t, subscriptionPrice.billingPeriod),
});
}
// if (current?.subscriptionPrice) {
// return isUpgrade(plan) ? t("shared.upgrade") : t("shared.downgrade");
// }
return t("pricing.subscribe");
}
// function isDowngrade(plan: SubscriptionProductDto) {
// if (current) {
// return plan.order < (current.subscriptionPrice?.subscriptionProduct.order ?? 0);
// }
// return false;
// }
// function isUpgrade(plan: SubscriptionProductDto) {
// if (!current) {
// return true;
// }
// return plan.order > (current.subscriptionPrice?.subscriptionProduct.order ?? 0);
// }
function cancel() {
confirmModal.current?.show(t("settings.subscription.confirmCancel"));
}
function confirmCancel() {
const form = new FormData();
form.set("action", "cancel");
submit(form, {
method: "post",
});
}
return (
<div>
<div className="container mx-auto mt-6 antialiased">
<main className="">
<div className="space-y-2">
<div className={clsx("mt-6 grid gap-4 lg:gap-2", products.length === 2 && "lg:grid-cols-2", products.length > 2 && "lg:grid-cols-3")}>
{products
.filter((f) => f.public)
.map((plan, index) => {
return (
<div key={index}>
<section
className={clsx(
"relative flex w-full flex-col rounded-md p-6 shadow-2xs",
!isCurrent(plan) && "border border-teal-800",
isCurrent(plan) && "border-2 border-teal-600"
)}
>
{isCurrent(plan) && (
<div className="absolute top-0 -translate-y-1/2 transform rounded-full bg-teal-500 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-white">
{t("shared.current")}
</div>
)}
<div className="flex-1 space-y-2">
{/* Price */}
<div className="shrink-0 truncate">
<span className="text-2xl font-medium tracking-tight">{getPriceAmount(plan)}</span>
{plan.model === PricingModel.PER_SEAT && <span className="text-muted-foreground">/{t("pricing.seat").toLowerCase()}</span>}
{getPricePeriodSuffix(plan)}
</div>
{/* Badge */}
<div className="shrink-0 space-y-2 border-b pb-3">
<h2 className="text-lg font-normal">{t(plan.title)}</h2>
</div>
{/* Features */}
<ul className="flex-1 space-y-1">
{plan.features
.sort((a, b) => (a.order > b.order ? 1 : -1))
.map((feature, idxFeature) => {
return (
<li key={idxFeature}>
<div className="flex items-center">
{feature.type !== SubscriptionFeatureLimitType.NOT_INCLUDED ? (
<CheckCircleIcon className="text-theme-500 h-4 w-4 opacity-70" aria-hidden="true" />
) : (
<XCircleIcon className="h-4 w-4 text-gray-300 opacity-70" />
)}
<span className="ml-3 truncate text-sm">
<span>{t(feature.title, { 0: feature.value })}</span>
</span>
</div>
</li>
);
})}
</ul>
{/* Button */}
<div className="shrink-0 space-y-2 pt-4">
<button
disabled={loading || isCurrent(plan) || !getPrice(plan)?.stripeId || !canSubscribe}
type="button"
onClick={() => selectPrice(plan)}
className={clsx(
"inline-flex w-full items-center justify-center rounded-lg border px-4 py-2 text-sm transition-colors focus:outline-hidden focus:ring-2 focus:ring-offset-2",
loading || isCurrent(plan) || !getPrice(plan)?.stripeId || !canSubscribe
? "cursor-not-allowed opacity-80"
: "hover:bg-secondary/90 border-slate-800"
// isUpgrade(plan) && canSubscribe && "hover:text-teal-600",
// isDowngrade(plan) && canSubscribe && "hover:text-red-600"
)}
>
{getButtonTitle(plan)}
</button>
</div>
</div>
</section>
</div>
);
})}
</div>
</div>
</main>
</div>
<Modal className="sm:max-w-xs" open={showQuantityModal} setOpen={setShowQuantityModal}>
<InputNumber name="quantity" title={t("pricing.seats")} value={quantity} setValue={(e) => setQuantity(Number(e))} required min={1} />
{selectedPlan && (
<LoadingButton disabled={quantity <= 0} className="mt-2 w-full text-center" type="button" onClick={confirmedQuantity}>
{getButtonTitle(selectedPlan, true)}
</LoadingButton>
)}
</Modal>
<ConfirmModal ref={confirmModal} onYes={confirmCancel} />
</div>
);
}
@@ -0,0 +1,40 @@
import { ReactNode } from "react";
import { useParams } from "react-router";
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
import WarningBanner from "~/components/ui/banners/WarningBanner";
import UrlUtils from "~/utils/app/UrlUtils";
import { useTranslation } from "react-i18next";
interface Props {
item: PlanFeatureUsageDto | undefined;
children: ReactNode;
hideContent?: boolean;
showRemaining?: boolean;
}
export default function CheckPlanFeatureLimit({ item, children, hideContent = true, showRemaining }: Props) {
const params = useParams();
const { t } = useTranslation();
function getTitle() {
if (item?.entity) {
return t(item.entity.titlePlural);
}
return item?.name ?? "";
}
return (
<div>
{item && !item.enabled ? (
<div className="space-y-2">
<WarningBanner redirect={UrlUtils.currentTenantUrl(params, `settings/subscription`)} title={getTitle().toUpperCase()} text={``}>
<div className="mt-2">
<span>{t(item.message)}</span>
</div>
</WarningBanner>
{!hideContent && children}
</div>
) : (
children
)}
</div>
);
}
@@ -0,0 +1,25 @@
import { ExternalLinkIcon } from "~/components/ui/icons/vendor";
import { useTranslation } from "react-i18next";
interface Props {
onClick: () => void;
}
export default function MyBillingSession({ onClick }: Props) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<div className="mt-3">
<button
type="button"
onClick={onClick}
className="border-border hover:border-border relative block w-full rounded-lg border-2 border-dashed p-4 text-center focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
data-ui-id="ui.subscription.billing.portal"
>
<ExternalLinkIcon className="text-muted-foreground mx-auto h-4" />
<span className="text-foreground mt-2 block text-sm font-medium">{t("app.subscription.billing.openPortal")}</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import { useTranslation } from "react-i18next";
import type Stripe from "stripe";
import { Colors } from "~/application/enums/shared/Colors";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import DownloadIcon from "~/components/ui/icons/DownloadIcon";
import TableSimple from "~/components/ui/tables/TableSimple";
import { useLocaleFormatters } from "~/locale/format";
interface Props {
items: Stripe.Invoice[];
}
export default function MyInvoices({ items }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<div className="space-y-2">
<div className="text-sm font-medium">{t("app.subscription.invoices.title")}</div>
{items.length === 0 ? (
<div className="text-muted-foreground text-sm italic">{t("shared.noRecords")}</div>
) : (
<TableSimple
items={items}
headers={[
{
name: "date",
title: t("shared.createdAt"),
value: (i) => formatters.date(new Date(i.created * 1000), { dateStyle: "medium" }),
formattedValue: (item) => (
<div className="flex flex-col">
<div>{formatters.date(new Date(item.created * 1000), { dateStyle: "medium" })}</div>
<div className="text-xs">{formatters.relativeDate(new Date(item.created * 1000))}</div>
</div>
),
},
{
name: "amount",
title: t("app.subscription.invoices.amount"),
value: (i) => (
<div className="flex flex-col">
<div>{formatters.currency(Number(i.amount_paid) / 100, i.currency.toUpperCase())}</div>
<div className="text-muted-foreground text-xs uppercase">{i.currency}</div>
</div>
),
},
{
name: "status",
title: t("shared.status"),
value: (i) => (
<div>
<SimpleBadge title={t("app.subscription.invoices.status." + i.status)} color={i.status === "paid" ? Colors.GREEN : Colors.YELLOW} />
</div>
),
},
{
className: "w-full",
name: "items",
title: t("app.subscription.invoices.items"),
value: (i) => (
<div className="flex flex-col">
{i.lines.data.map((lineItem, idx) => {
return (
<div key={idx}>
{lineItem.description}
</div>
);
})}
</div>
),
},
]}
actions={[
{
title: (
<div className="flex justify-center">
<DownloadIcon className="h-4 w-4" />
</div>
),
onClickRoute: (_, item) => item.invoice_pdf ?? "",
disabled: (item) => !item.invoice_pdf,
onClickRouteTarget: "_blank",
firstColumn: true,
},
]}
/>
)}
</div>
);
}
@@ -0,0 +1,88 @@
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import type Stripe from "stripe";
import PlusIcon from "~/components/ui/icons/PlusIcon";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import TableSimple from "~/components/ui/tables/TableSimple";
interface Props {
items: Stripe.PaymentMethod[];
onAdd: () => void;
onDelete: (id: string) => void;
}
export default function MyPaymentMethods({ items, onAdd, onDelete }: Props) {
const { t } = useTranslation();
const confirmModal = useRef<RefConfirmModal>(null);
function deletePaymentMethod(id: string) {
confirmModal.current?.setValue(id);
confirmModal.current?.show(t("app.subscription.paymentMethods.delete"));
}
function confirmedCancel(id: string) {
if (onDelete) {
onDelete(id);
}
}
return (
<div className="space-y-2">
<div className="flex items-baseline justify-between space-x-2">
<div className="text-sm font-medium">{t("app.subscription.paymentMethods.cards")}</div>
{/* <button className="text-sm hover:underline text-theme-600 hover:text-theme-700 font-medium" type="button" onClick={onAdd}>
{t("shared.add")}
</button> */}
</div>
{items.length > 0 ? (
<TableSimple
items={items}
actions={[
{
title: t("shared.delete"),
onClick: (_, i) => deletePaymentMethod(i.id),
destructive: true,
},
]}
headers={[
{
name: "brand",
title: t("app.subscription.paymentMethods.brand"),
value: (i) => <div className="flex flex-col uppercase">{i.card?.brand}</div>,
},
{
name: "country",
title: t("app.subscription.paymentMethods.country"),
value: (i) => <div className="flex flex-col">{i.card?.country}</div>,
},
{
name: "expiration",
title: t("app.subscription.paymentMethods.expiration"),
value: (i) => (
<div className="flex flex-col">
{i.card?.exp_month.toString().padStart(2, "0")}/{i.card?.exp_year}
</div>
),
},
{
name: "last4",
title: t("app.subscription.paymentMethods.last4"),
value: (i) => <div className="flex flex-col">**** **** **** {i.card?.last4}</div>,
},
]}
/>
) : (
<div className="mt-3">
<button
type="button"
onClick={onAdd}
className="border-border hover:border-border relative block w-full rounded-lg border-2 border-dashed p-4 text-center focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<PlusIcon className="text-muted-foreground mx-auto h-4" />
<span className="text-foreground mt-2 block text-sm font-medium">
{t("shared.add")} {t("app.subscription.paymentMethods.card").toLowerCase()}
</span>
</button>
</div>
)}
<ConfirmModal ref={confirmModal} onYes={confirmedCancel} destructive />
</div>
);
}
@@ -0,0 +1,87 @@
import { useTranslation } from "react-i18next";
import type Stripe from "stripe";
import { Colors } from "~/application/enums/shared/Colors";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import TableSimple from "~/components/ui/tables/TableSimple";
import { useLocaleFormatters } from "~/locale/format";
interface Props {
items: Stripe.PaymentIntent[];
}
export default function MyPayments({ items }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<div className="space-y-2">
<div className="text-sm font-medium">{t("app.subscription.payments.title")}</div>
{items.length === 0 ? (
<div className="text-muted-foreground text-sm italic">{t("shared.noRecords")}</div>
) : (
<TableSimple
items={items}
headers={[
{
name: "paidAt",
title: t("app.subscription.invoices.paidAt"),
value: (i) => (
<div>
<div className="flex flex-col">
<div>{formatters.date(new Date(i.created * 1000), { dateStyle: "medium" })}</div>
<div className="text-xs">{formatters.relativeDate(new Date(i.created * 1000))}</div>
</div>
</div>
),
},
{
name: "amount",
title: t("app.subscription.invoices.amount"),
value: (i) => (
<div className="flex flex-col">
<div>{formatters.currency(i.amount / 100, i.currency.toUpperCase())}</div>
<div className="text-muted-foreground text-xs uppercase">{i.currency}</div>
</div>
),
},
{
name: "status",
title: t("shared.status"),
value: (i) => (
<div>
<SimpleBadge title={t("app.subscription.payments.status." + i.status)} color={i.status === "succeeded" ? Colors.GREEN : Colors.GRAY} />
</div>
),
},
{
className: "w-full",
name: "date",
title: t("shared.createdAt"),
value: (i) => formatters.date(new Date(i.created * 1000), { dateStyle: "medium" }),
formattedValue: (item) => (
<div className="flex flex-col">
<div>{formatters.date(new Date(item.created * 1000), { dateStyle: "medium" })}</div>
<div className="text-xs">{formatters.relativeDate(new Date(item.created * 1000))}</div>
</div>
),
},
]}
actions={
[
// {
// title: (
// <div className="flex justify-center">
// <DownloadIcon className="h-4 w-4" />
// </div>
// ),
// onClickRoute: (_, item) => item.charges?.data.find((f) => f.receipt_url)?.receipt_url ?? "",
// disabled: (item) => !item.charges?.data.find((f) => f.receipt_url)?.receipt_url,
// onClickRouteTarget: "_blank",
// firstColumn: true,
// },
]
}
/>
)}
</div>
);
}
@@ -0,0 +1,63 @@
import { useTranslation } from "react-i18next";
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
import { useLocaleFormatters } from "~/locale/format";
interface Props {
item: PlanFeatureUsageDto | undefined;
}
export default function MyPlanFeatureUsage({ item }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<div>
{item ? (
<div>
{/* {JSON.stringify(item)} */}
<div className="flex items-baseline space-x-1 font-medium">
{item.type === SubscriptionFeatureLimitType.INCLUDED && (
<span className="text-foreground flex items-center space-x-1 truncate text-sm">
{/* <CheckIcon className={clsx(" text-center h-5 w-5")} /> */}
<span>{t("pricing.included")}</span>
</span>
)}
{item.type === SubscriptionFeatureLimitType.NOT_INCLUDED && (
<span className="text-foreground flex items-center space-x-1 truncate text-sm">
{/* <XIcon className={clsx(" text-center h-5 w-5")} /> */}
<span>{t("pricing.notIncluded")}</span>
</span>
)}
{item.type === SubscriptionFeatureLimitType.MONTHLY && (
<span className="text-foreground flex items-baseline space-x-1 text-sm lowercase">
<span>
{item.used}/{item.value}
</span>
<span className="text-muted-foreground text-xs font-normal lowercase">
{item.period?.firstDay && item.period.lastDay ? (
<span>
({formatters.date(item.period.firstDay, { dateStyle: "medium" })} - {formatters.date(item.period.lastDay, { dateStyle: "medium" })})
</span>
) : (
<span>({t("pricing.thisMonth")})</span>
)}
</span>
</span>
)}
{item.type === SubscriptionFeatureLimitType.MAX && (
<span className="text-foreground lowercase">
{item.used}/{item.value}
</span>
)}
{item.type === SubscriptionFeatureLimitType.UNLIMITED && (
<span className="text-foreground flex items-center space-x-1 truncate text-sm">{t("shared.unlimited")}</span>
)}
</div>
{/* <span className="mt-2 text-red-700 text-xs font-normal">{item.message ?? ""}</span> */}
</div>
) : (
<span className=" text-muted-foreground">-</span>
)}
</div>
);
}
@@ -0,0 +1,86 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import WarningBanner from "~/components/ui/banners/WarningBanner";
import { Link } from "react-router";
import TenantProduct from "./TenantProduct";
import { TenantSubscriptionProductWithDetails } from "~/utils/db/subscriptions/tenantSubscriptionProducts.db.server";
import { subscriptionCheckoutPath } from "~/utils/app/SubscriptionUrlUtils";
interface Props {
currentTenant: { slug: string };
items: TenantSubscriptionProductWithDetails[];
onCancel?: (item: TenantSubscriptionProductWithDetails) => void;
}
/**
* "Active" = no endsAt yet, OR endsAt is in the future (cancelled-but-still-in-grace).
* "History" = endsAt is in the past (sub fully ended).
*/
function splitItems(items: TenantSubscriptionProductWithDetails[]) {
const now = Date.now();
const active: TenantSubscriptionProductWithDetails[] = [];
const history: TenantSubscriptionProductWithDetails[] = [];
for (const item of items) {
const endsAt = item.endsAt ? new Date(item.endsAt).getTime() : null;
if (endsAt !== null && endsAt <= now) {
history.push(item);
} else {
active.push(item);
}
}
// History most-recent first.
history.sort((a, b) => {
const ba = b.endsAt ? new Date(b.endsAt).getTime() : 0;
const aa = a.endsAt ? new Date(a.endsAt).getTime() : 0;
return ba - aa;
});
return { active, history };
}
export default function MyProducts({ currentTenant, items, onCancel }: Props) {
const { t } = useTranslation();
const { active, history } = splitItems(items);
const [showHistory, setShowHistory] = useState(false);
return (
<div className="space-y-4" data-ui-id="ui.subscription.products">
{active.length === 0 ? (
<WarningBanner title={t("settings.subscription.noSubscription")} text={""}>
<Link to={subscriptionCheckoutPath(currentTenant.slug)} className="underline" data-ui-id="ui.subscription.checkout.link">
{t("settings.subscription.viewAllProducts")}.
</Link>
</WarningBanner>
) : (
<div className="grid gap-2" data-ui-id="ui.subscription.product.active">
{active.map((item, idx) => {
return <TenantProduct key={item.id ?? idx} item={item} onCancel={onCancel} />;
})}
</div>
)}
{history.length > 0 && (
<div className="border-border/40 border-t pt-3">
<button
type="button"
onClick={() => setShowHistory((v) => !v)}
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs font-medium"
aria-expanded={showHistory}
data-ui-id="ui.subscription.product.history.toggle"
>
<span>{showHistory ? "▾" : "▸"}</span>
<span>
{t("settings.subscription.history")} ({history.length})
</span>
</button>
{showHistory && (
<div className="mt-3 grid gap-2 opacity-80" data-ui-id="ui.subscription.product.history">
{history.map((item, idx) => (
<TenantProduct key={item.id ?? idx} item={item} onCancel={onCancel} />
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import MyPlanFeatureUsage from "./MyPlanFeatureUsage";
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
interface Props {
features: PlanFeatureUsageDto[];
className?: string;
withCurrentPlan: boolean;
}
export default function MySubscriptionFeatures({
features,
className = "",
withCurrentPlan = false,
}: Props) {
const { t } = useTranslation();
function getTitle(item: PlanFeatureUsageDto) {
if (item?.entity) {
return t(item.entity.titlePlural);
}
return item?.name ?? "";
}
return (
<div className={className} data-ui-id="ui.subscription.features">
{features.length === 0 ? (
<div className="text-muted-foreground text-sm italic">{t("settings.subscription.noSubscription")}</div>
) : (
<>
<dl
className={clsx(
"grid gap-2",
withCurrentPlan && "mt-2 ",
features.length === 1 && "grid-cols-1 md:grid-cols-1 xl:grid-cols-1",
features.length === 2 && "grid-cols-1 md:grid-cols-1 xl:grid-cols-2",
features.length === 3 && "grid-cols-1 md:grid-cols-1 xl:grid-cols-1",
features.length === 4 && "grid-cols-1 md:grid-cols-1 xl:grid-cols-2",
features.length === 5 && "grid-cols-1 md:grid-cols-1 xl:grid-cols-1",
features.length === 6 && "grid-cols-1 md:grid-cols-3 xl:grid-cols-2"
)}
>
{features.map((item, idx) => {
return (
<div key={idx} className="border-border bg-background rounded-lg border p-4 shadow-2xs">
<dt className="text-muted-foreground truncate text-xs uppercase tracking-wide">{getTitle(item)}</dt>
<dd className="mt-1">
<span>{<MyPlanFeatureUsage item={item} />}</span>
</dd>
</div>
);
})}
</dl>
</>
)}
</div>
);
}
@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import type Stripe from "stripe";
import { useLocaleFormatters } from "~/locale/format";
interface Props {
items: Stripe.Invoice[];
}
export default function MyUpcomingInvoice({ items }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<div>
{items.length > 0 && (
<div className="space-y-2">
<div className="flex justify-between space-x-2">
<div className="text-sm font-medium">{t("app.subscription.invoices.upcoming")}</div>
</div>
{items.map((item, idx) => (
<div key={idx} className="border-border bg-secondary flex flex-col rounded-md border border-dashed p-3">
<div>
<span className="font-medium">
{formatters.currency(item.amount_due / 100, item.currency.toUpperCase())}
</span>{" "}
<span className="text-muted-foreground text-sm uppercase">{item.currency}</span>
<div className="text-muted-foreground text-sm">
{item.next_payment_attempt && formatters.date(new Date(item.next_payment_attempt * 1000), { dateStyle: "medium" })}
</div>
</div>
{item.lines.data.map((lineItem, idx) => {
return (
<div key={idx} className="text-sm">
{lineItem.description}
</div>
);
})}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,549 @@
import clsx from "clsx";
import { SubscriptionFeatureDto } from "~/application/dtos/subscriptions/SubscriptionFeatureDto";
import { PricingModel } from "~/application/enums/subscriptions/PricingModel";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import { SubscriptionPriceDto } from "~/application/dtos/subscriptions/SubscriptionPriceDto";
import { SubscriptionUsageBasedPriceDto } from "~/application/dtos/subscriptions/SubscriptionUsageBasedPriceDto";
import { SubscriptionProductDto } from "~/application/dtos/subscriptions/SubscriptionProductDto";
import { useLocation, useMatches, useNavigation, useSearchParams, useSubmit } from "react-router";
import { Fragment, useState } from "react";
import { useRootData } from "~/utils/data/useRootData";
import AnalyticsHelper from "~/utils/helpers/AnalyticsHelper";
import { useTranslation } from "react-i18next";
import type Stripe from "stripe";
import PlanFeatureDescription from "./PlanFeatureDescription";
import WarningBanner from "~/components/ui/banners/WarningBanner";
import InputNumber from "~/components/ui/input/InputNumber";
import PlanChangePreviewModal from "./PlanChangePreviewModal";
import { useLocaleFormatters } from "~/locale/format";
import SubscriptionUtils from "~/utils/app/SubscriptionUtils";
export interface CurrentPlanInfo {
title: string;
priceAmount: number | null;
currency: string;
currentPeriodStart: Date | null;
currentPeriodEnd: Date | null;
}
interface Props {
product?: SubscriptionProductDto;
title: string;
description?: string;
badge?: string;
features: SubscriptionFeatureDto[];
billingPeriod: SubscriptionBillingPeriod;
currency: string;
model: PricingModel;
prices: SubscriptionPriceDto[];
usageBasedPrices?: SubscriptionUsageBasedPriceDto[];
className?: string;
alreadyOwned?: boolean;
canSubmit?: boolean;
isUpgrade?: boolean;
isDowngrade?: boolean;
stripeCoupon: Stripe.Coupon | null;
isPreview?: boolean;
onClickFeature?: (name: string) => void;
/** When set, the user has an existing active subscription on a *different* plan. Click opens a proration preview before submitting. */
currentPlanInfo?: CurrentPlanInfo | null;
}
export default function Plan({
product,
title,
description,
badge,
features,
billingPeriod,
model,
currency,
prices,
usageBasedPrices,
className,
alreadyOwned,
canSubmit,
isUpgrade,
isDowngrade,
stripeCoupon,
isPreview,
onClickFeature,
currentPlanInfo,
}: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const [searchParams] = useSearchParams();
const submit = useSubmit();
const navigation = useNavigation();
const isSubscribing = navigation.state === "submitting" && navigation.formData?.get("action") === "subscribe";
const isLoading = isSubscribing && navigation.formData?.get("product-id") === product?.id;
const matches = useMatches();
let location = useLocation();
const rootData = useRootData();
const [quantity, setQuantity] = useState(1);
const [showPreview, setShowPreview] = useState(false);
const [pendingForm, setPendingForm] = useState<FormData | null>(null);
function getCurrency() {
return currency.toUpperCase();
}
function getFlatPrice(): SubscriptionPriceDto | undefined {
if (model !== PricingModel.ONCE) {
return prices.find((f) => f.currency === currency && f.billingPeriod === billingPeriod);
}
return prices.find((f) => f.currency === currency && f.billingPeriod === SubscriptionBillingPeriod.ONCE);
}
function getFormattedPrice() {
const price = getFlatPrice();
if (!price) {
return "?";
}
let total = applyDiscount(Number(price.price || 0));
return formatters.currency(total, price.currency.toUpperCase(), { maximumFractionDigits: 0 });
}
function getBeforePrice() {
const price = getFlatPrice();
if (!price) {
return "?";
}
return formatters.currency(Number(price.price || 0), price.currency.toUpperCase(), { maximumFractionDigits: 0 });
}
function getPricePeriodSuffix(price?: SubscriptionPriceDto) {
if (!price || price.billingPeriod === SubscriptionBillingPeriod.ONCE) {
return null;
}
return <span className="text-muted-foreground truncate">/{SubscriptionUtils.getBillingPeriodShortDescription(t, price.billingPeriod)}</span>;
}
function formatUsageTierLimit(value?: number | null) {
return value === null || value === undefined ? "∞" : formatters.number(value, { maximumFractionDigits: 0 });
}
function formatUsagePrice(value: unknown, currency: string, maximumFractionDigits: number) {
if (value === null || value === undefined) {
return "-";
}
return formatters.currency(Number(value), currency.toUpperCase(), { maximumFractionDigits });
}
async function onClick() {
const safeQuantity = Number.isFinite(quantity) ? Math.min(99, Math.max(1, Math.trunc(quantity))) : 1;
const form = new FormData();
form.set("action", "subscribe");
form.set("product-id", product?.id?.toString() ?? "");
form.set("billing-period", billingPeriod.toString());
form.set("currency", currency);
form.set("quantity", safeQuantity.toString());
form.set("csrf", rootData.csrf ?? "");
// Include the tenant slug as a hidden context so the action can reject
// submissions that originated from a different tenant's subscribe page
// (defence-in-depth against stale forms / multi-tab confusion).
form.set("tenant-slug-context", rootData.currentTenant?.slug ?? "");
const coupon = getCoupon();
if (coupon) {
form.set("coupon", searchParams.get("coupon")?.toString() ?? "");
}
if (isUpgrade) {
form.set("is-upgrade", "true");
} else if (isDowngrade) {
form.set("is-downgrade", "true");
}
// If the user has an existing sub on a different plan, show a proration
// preview modal before sending them to Stripe Checkout. First-subscribe
// (no currentPlanInfo) and same-plan re-buy go straight through.
const isPlanChange = (isUpgrade || isDowngrade) && !!currentPlanInfo && !alreadyOwned;
if (isPlanChange) {
setPendingForm(form);
setShowPreview(true);
return;
}
submit(form, {
method: "post",
});
const routeMatch = matches.find((m) => m.pathname == location.pathname);
AnalyticsHelper.addEvent({
url: location.pathname,
route: routeMatch?.id,
rootData,
action: "click-plan",
category: "user",
label: "pricing",
value: t(product?.title ?? ""),
});
}
function isDisabled() {
if (!canSubmit) {
return true;
}
if (model === PricingModel.ONCE) {
if (alreadyOwned && !product?.canBuyAgain) {
return true;
}
return isSubscribing || !product?.stripeId;
}
return isSubscribing || !product?.stripeId;
}
const getCoupon = () => {
if (!stripeCoupon) {
return null;
}
if (stripeCoupon.applies_to) {
if (stripeCoupon.applies_to.products.includes(product?.stripeId ?? "")) {
if (!stripeCoupon.currency || stripeCoupon.currency === currency) {
return stripeCoupon;
}
}
} else {
if (!stripeCoupon.currency || stripeCoupon.currency === currency) {
return stripeCoupon;
}
}
return null;
};
function applyDiscount(total: number) {
const coupon = getCoupon();
if (!coupon) {
return total;
}
if (coupon.amount_off) {
return total - coupon.amount_off / 100;
} else if (coupon.percent_off) {
return total - (total * coupon.percent_off) / 100;
}
return total;
}
function noPricesForThisCurrency() {
const flat = prices.filter((f) => f.currency === currency);
const usageBased = usageBasedPrices?.filter((f) => f.currency === currency) ?? [];
return flat.length === 0 && usageBased.length === 0;
}
function hasQuantity() {
return model === PricingModel.PER_SEAT || product?.hasQuantity;
}
return (
<>
{noPricesForThisCurrency() ? (
<Fragment>
{isPreview ? (
<WarningBanner title={t("shared.warning")}>
{t("pricing.noPriceForCurrencyPeriod", {
currency,
billingPeriod: SubscriptionBillingPeriod[billingPeriod],
})}
</WarningBanner>
) : null}
</Fragment>
) : (
<div className={className}>
<section
className={clsx("relative flex w-full flex-col rounded-lg p-7 shadow-xl", !badge && "border-secondary border", badge && "border-primary border-2")}
>
{badge && (
<div className="bg-primary text-primary-foreground absolute top-0 -translate-y-1/2 transform rounded-full px-4 py-1.5 text-xs font-semibold tracking-wide uppercase">
{t(badge)}
</div>
)}
<div className="flex-1 space-y-4">
{/* Title and Description */}
<div className="shrink-0 space-y-2">
<h2 className="text-xl font-medium">{t(title)}</h2>
{description && <p className="text-muted-foreground text-sm">{t(description)}</p>}
</div>
{/* Price */}
{model !== PricingModel.USAGE_BASED && (
<div className="shrink-0 truncate">
{getCoupon() ? (
<Fragment>
<span className="text-muted-foreground mr-1 text-2xl font-normal tracking-tight line-through">{getBeforePrice()}</span>
<span className="text-4xl font-bold tracking-tight">{getFormattedPrice()}</span>
</Fragment>
) : (
<Fragment>
<span className="text-4xl font-bold tracking-tight">{getFormattedPrice()}</span>
</Fragment>
)}
<span className="text-muted-foreground truncate uppercase"> {getCurrency()}</span>
{model === PricingModel.PER_SEAT && <span className="text-muted-foreground">/{t("pricing.seat").toLowerCase()}</span>}
{getPricePeriodSuffix(getFlatPrice())}
</div>
)}
{usageBasedPrices
?.sort((a, b) => (a.unit > b.unit ? 1 : -1))
.filter((f) => f.currency === currency)
.map((usageBasedPrice, idx) => {
return (
<div key={idx} className="flex shrink-0 flex-col">
<div className="text-sm font-medium">
<span className="">+</span> {usageBasedPrice.unitTitlePlural}
</div>
<div className="mt-3">
<div className="ring-muted -mx-4 overflow-auto ring-1 sm:-mx-6 md:mx-0 md:rounded-lg">
<table className="divide-muted min-w-full divide-y">
<thead>
<tr>
{usageBasedPrice.tiersMode === "graduated" ? (
<>
<th scope="col" className="truncate px-3 py-2 text-left text-sm font-semibold">
{usageBasedPrice.unitTitlePlural}
</th>
<th scope="col" className="truncate px-3 py-2 text-left text-sm font-semibold">
{t("pricing.usageBased.units")}
<span className="text-muted-foreground font-normal">{t("pricing.periods.monthlySuffixShort")}</span>
</th>
</>
) : (
<th scope="col" className="truncate px-3 py-2 text-left text-sm font-semibold">
{t("pricing.usageBased.units")}
<span className="text-muted-foreground font-normal">{t("pricing.periods.monthlySuffixLong")}</span>
</th>
)}
<th scope="col" className="truncate px-3 py-2 text-left text-sm font-semibold">
{t("pricing.usageBased.perUnit")}
</th>
<th scope="col" className="truncate px-3 py-2 text-left text-sm font-semibold">
{t("pricing.usageBased.flatFee")}
</th>
</tr>
</thead>
<tbody>
{usageBasedPrice.tiers.map((tier, idx) => {
return (
<tr key={tier.from}>
{usageBasedPrice.tiersMode === "graduated" && (
<td className="relative px-3 py-2 text-sm">
<div className="truncate font-medium">
<span>{idx === 0 ? t("pricing.usageBased.first") : t("pricing.usageBased.next")}</span>
</div>
</td>
)}
<td className="text-muted-foreground truncate px-3 py-2 text-sm lg:table-cell">
{formatters.number(tier.from, { maximumFractionDigits: 0 })} - {formatUsageTierLimit(tier.to)}
</td>
<td className="text-muted-foreground truncate px-3 py-2 text-sm lg:table-cell">
{formatUsagePrice(tier.perUnitPrice, usageBasedPrice.currency, 6)}
</td>
<td className="text-muted-foreground truncate px-3 py-2 text-sm lg:table-cell">
{formatUsagePrice(tier.flatFeePrice, usageBasedPrice.currency, 2)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
})}
{/* <div className="w-full border-t"></div> */}
{/* Features */}
<ul className="flex-1 space-y-1.5">
{features
.sort((a, b) => (a.order > b.order ? 1 : -1))
.map((feature, idxFeature) => {
return (
<li key={idxFeature}>
<PlanFeatureDescription feature={feature} onClickFeature={onClickFeature} />
</li>
);
})}
</ul>
{/* {canSubmit && ( */}
<div className="mt-4 space-y-2">
{(!alreadyOwned || model === PricingModel.ONCE) &&
getFlatPrice()?.price !== 0 &&
hasQuantity() &&
(model === PricingModel.PER_SEAT || model === PricingModel.ONCE) ? (
<div className="grid grid-cols-2 gap-2">
<div data-ui-id="ui.subscription.checkout.quantity">
<InputNumber
name={product?.id ? `subscription-checkout-quantity-${product.id}` : "subscription-checkout-quantity"}
title={t("pricing.seats")}
min={1}
max={99}
value={quantity}
setValue={(e) => setQuantity(Number(e))}
disabled={isDisabled()}
/>
</div>
<SubscribeOrBuyButton
product={product}
isPreview={product?.stripeId === undefined}
model={model}
price={getFlatPrice()}
badge={badge}
disabled={isDisabled()}
loading={isLoading}
onClick={onClick}
alreadyOwned={alreadyOwned}
isUpgrade={isUpgrade}
isDowngrade={isDowngrade}
title={title}
billingPeriod={billingPeriod}
currency={currency}
/>
</div>
) : (
<SubscribeOrBuyButton
product={product}
isPreview={product?.stripeId === undefined}
model={model}
price={getFlatPrice()}
badge={badge}
disabled={isDisabled()}
loading={isLoading}
onClick={onClick}
alreadyOwned={alreadyOwned}
isUpgrade={isUpgrade}
isDowngrade={isDowngrade}
title={title}
billingPeriod={billingPeriod}
currency={currency}
/>
)}
</div>
</div>
</section>
</div>
)}
{currentPlanInfo && (
<PlanChangePreviewModal
open={showPreview}
setOpen={setShowPreview}
onConfirm={() => {
if (pendingForm) {
submit(pendingForm, { method: "post" });
}
setShowPreview(false);
}}
fromPlanTitle={t(currentPlanInfo.title)}
toPlanTitle={t(title)}
fromPriceAmount={currentPlanInfo.priceAmount}
toPriceAmount={Number(getFlatPrice()?.price ?? 0)}
currency={currency}
toBillingPeriod={billingPeriod}
currentPeriodStart={currentPlanInfo.currentPeriodStart}
currentPeriodEnd={currentPlanInfo.currentPeriodEnd}
loading={isLoading}
/>
)}
</>
);
}
interface SubscribeOrBuyButtonProps {
product?: SubscriptionProductDto;
isPreview?: boolean;
model: PricingModel;
badge?: string;
disabled: boolean;
loading: boolean;
onClick: () => void;
alreadyOwned?: boolean;
price?: SubscriptionPriceDto;
isUpgrade?: boolean;
isDowngrade?: boolean;
title?: string;
billingPeriod: SubscriptionBillingPeriod;
currency: string;
}
function SubscribeOrBuyButton({
product,
isPreview,
model,
price,
badge,
disabled,
loading,
onClick,
alreadyOwned,
isUpgrade,
isDowngrade,
title,
billingPeriod,
currency,
}: SubscribeOrBuyButtonProps) {
const { t } = useTranslation();
const planLabel = title ? t(title) : (product?.title ? t(product.title) : "");
// alreadyOwned takes a distinctive non-clickable badge instead of a button —
// signals "this is your plan" rather than "click to do nothing".
if (alreadyOwned && model !== PricingModel.ONCE) {
return (
<div
className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 flex w-full items-center justify-center space-x-2 rounded-md border border-emerald-500/40 px-8 py-2 text-sm font-medium"
aria-label={t("pricing.alreadyOwned")}
>
<span>✓</span>
<span>{t("pricing.alreadyOwned")}</span>
</div>
);
}
return (
<button
type="button"
data-ui-id="ui.subscription.checkout.submit"
data-product-id={product?.id?.toString() ?? ""}
data-billing-period={billingPeriod.toString()}
data-currency={currency}
disabled={disabled}
onClick={onClick}
className={clsx(
"truncate",
loading && "base-spinner cursor-not-allowed",
badge && !disabled
? "border-primary-foreground bg-primary text-primary-foreground group flex w-full items-center justify-center space-x-2 rounded-md border px-8 py-2 text-sm font-medium"
: "text-foreground border-foreground bg-background group flex w-full items-center justify-center space-x-2 rounded-md border px-8 py-2 text-sm font-medium",
badge && !disabled && "hover:bg-primary/90",
!badge && !disabled && "hover:border-foreground hover:bg-secondary",
disabled && "cursor-not-allowed opacity-70"
)}
>
{alreadyOwned ? (
<>{t("pricing.buyAgain")}</>
) : isPreview ? (
<>{t("pricing.notCreated")}</>
) : isUpgrade ? (
<div className="flex items-center space-x-1">
<div>
{t("shared.upgrade")}
{planLabel ? <span className="ml-1 font-semibold">{planLabel}</span> : null}
</div>
<div>✨</div>
</div>
) : isDowngrade ? (
<div>
{t("shared.downgrade")}
{planLabel ? <span className="ml-1 font-semibold">{planLabel}</span> : null}
</div>
) : (
<span>
<>
{price && price.price === 0 ? (
<span>{t("pricing.getItForFree")}</span>
) : (
<span>{model === PricingModel.ONCE ? t("pricing.buy") : t("pricing.subscribe")}</span>
)}
</>
</span>
)}
</button>
);
}
@@ -0,0 +1,65 @@
// @vitest-environment node
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const componentPath = path.join(root, "apps/web/app/components/core/settings/subscription/PlanChangePreviewModal.tsx");
const source = fs.readFileSync(componentPath, "utf8");
const expectedKeys = [
"interval.month",
"interval.year",
"interval.quarter",
"interval.semiAnnual",
"title",
"upgradeDescription",
"downgradeDescription",
"subscribeDescription",
"todayProration",
"credit",
"recurring",
"nextInvoice",
"prorationNote",
"redirecting",
"continueToCheckout",
];
function readBillingLocale(locale: "de" | "en") {
const filePath = path.join(root, `apps/web/public/locales/${locale}/billing.json`);
return JSON.parse(fs.readFileSync(filePath, "utf8")) as { subscription?: { planChangePreview?: Record<string, unknown> } };
}
function getPath(value: Record<string, unknown> | undefined, key: string): unknown {
return key.split(".").reduce<unknown>((current, segment) => {
if (!current || typeof current !== "object") return undefined;
return (current as Record<string, unknown>)[segment];
}, value);
}
describe("PlanChangePreviewModal i18n", () => {
it("uses billing namespace keys instead of inline bilingual copy", () => {
expect(source).toContain("billing:subscription.planChangePreview");
for (const hardcoded of [
"Switch to ${toPlanTitle}?",
"Wechsel auf ${toPlanTitle}?",
"You're upgrading from ${fromPlanTitle} to ${toPlanTitle}.",
"Heute (geschätzte anteilige Berechnung)",
"Stripe will show the exact prorated amount",
"Continue to checkout",
"Weiter zur Bestätigung",
]) {
expect(source).not.toContain(hardcoded);
}
});
it("keeps German and English billing keys in parity", () => {
const en = readBillingLocale("en").subscription?.planChangePreview;
const de = readBillingLocale("de").subscription?.planChangePreview;
for (const key of expectedKeys) {
expect(getPath(en, key), `en billing:${key}`).toBeTruthy();
expect(getPath(de, key), `de billing:${key}`).toBeTruthy();
}
});
});
@@ -0,0 +1,163 @@
import { Dispatch, Fragment, SetStateAction, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Modal from "~/components/ui/modals/Modal";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import { useLocaleFormatters } from "~/locale/format";
interface Props {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
onConfirm: () => void;
fromPlanTitle: string;
toPlanTitle: string;
fromPriceAmount: number | null;
toPriceAmount: number;
currency: string;
toBillingPeriod: number;
currentPeriodStart: Date | null;
currentPeriodEnd: Date | null;
loading?: boolean;
}
const PLAN_CHANGE_KEY = "billing:subscription.planChangePreview";
function intervalShortKey(period: number) {
if (period === SubscriptionBillingPeriod.MONTHLY) return `${PLAN_CHANGE_KEY}.interval.month`;
if (period === SubscriptionBillingPeriod.YEARLY) return `${PLAN_CHANGE_KEY}.interval.year`;
if (period === SubscriptionBillingPeriod.QUARTERLY) return `${PLAN_CHANGE_KEY}.interval.quarter`;
if (period === SubscriptionBillingPeriod.SEMI_ANNUAL) return `${PLAN_CHANGE_KEY}.interval.semiAnnual`;
return "";
}
function nextRenewalDate(period: number) {
const d = new Date();
if (period === SubscriptionBillingPeriod.MONTHLY) d.setMonth(d.getMonth() + 1);
else if (period === SubscriptionBillingPeriod.YEARLY) d.setFullYear(d.getFullYear() + 1);
else if (period === SubscriptionBillingPeriod.QUARTERLY) d.setMonth(d.getMonth() + 3);
else if (period === SubscriptionBillingPeriod.SEMI_ANNUAL) d.setMonth(d.getMonth() + 6);
else return null;
return d;
}
export default function PlanChangePreviewModal({
open,
setOpen,
onConfirm,
fromPlanTitle,
toPlanTitle,
fromPriceAmount,
toPriceAmount,
currency,
toBillingPeriod,
currentPeriodStart,
currentPeriodEnd,
loading,
}: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
// Rough client-side proration estimate. The exact figure is what Stripe
// Checkout shows on its hosted page before final confirmation — we just
// give the user a ballpark so they aren't surprised.
const estimate = useMemo(() => {
if (fromPriceAmount === null || !currentPeriodStart || !currentPeriodEnd) {
return null;
}
const periodMs = currentPeriodEnd.getTime() - currentPeriodStart.getTime();
const remainingMs = currentPeriodEnd.getTime() - Date.now();
if (periodMs <= 0 || remainingMs <= 0) return null;
const fraction = remainingMs / periodMs;
// Unused credit from old plan ≈ oldPrice * fraction.
// Charge for new plan partial period ≈ newPrice * fraction.
// Net immediate charge ≈ (newPrice - oldPrice) * fraction.
const diff = toPriceAmount - fromPriceAmount;
const immediateCharge = diff * fraction;
return { immediateCharge, isUpgrade: diff > 0 };
}, [fromPriceAmount, toPriceAmount, currentPeriodStart, currentPeriodEnd]);
const nextRenewal = nextRenewalDate(toBillingPeriod);
const intervalTextKey = intervalShortKey(toBillingPeriod);
const intervalText = intervalTextKey ? t(intervalTextKey) : "";
const descriptionKey =
fromPriceAmount !== null && fromPriceAmount < toPriceAmount
? "upgradeDescription"
: fromPriceAmount !== null
? "downgradeDescription"
: "subscribeDescription";
return (
<Modal open={open} setOpen={setOpen} size="md">
<div className="space-y-5 p-2">
<div className="space-y-1">
<h3 className="text-foreground text-lg font-semibold">{t(`${PLAN_CHANGE_KEY}.title`, { plan: toPlanTitle })}</h3>
<p className="text-muted-foreground text-sm">
{t(`${PLAN_CHANGE_KEY}.${descriptionKey}`, {
fromPlan: fromPlanTitle,
toPlan: toPlanTitle,
plan: toPlanTitle,
})}
</p>
</div>
<div className="border-border bg-card divide-border space-y-0 divide-y rounded-lg border">
{estimate && (
<div className="flex items-baseline justify-between p-3">
<div className="text-foreground text-sm">{t(`${PLAN_CHANGE_KEY}.todayProration`)}</div>
<div
className={
estimate.immediateCharge >= 0 ? "text-foreground text-sm font-semibold" : "text-sm font-semibold text-emerald-600 dark:text-emerald-400"
}
>
{estimate.immediateCharge >= 0
? formatters.currency(estimate.immediateCharge, currency.toUpperCase())
: `−${formatters.currency(Math.abs(estimate.immediateCharge), currency.toUpperCase())} ${t(`${PLAN_CHANGE_KEY}.credit`)}`}
</div>
</div>
)}
<div className="flex items-baseline justify-between p-3">
<div className="text-foreground text-sm">{t(`${PLAN_CHANGE_KEY}.recurring`)}</div>
<div className="text-foreground text-sm font-semibold">
{formatters.currency(toPriceAmount, currency.toUpperCase())}
{intervalText ? ` / ${intervalText}` : ""}
</div>
</div>
{nextRenewal && (
<div className="flex items-baseline justify-between p-3">
<div className="text-foreground text-sm">{t(`${PLAN_CHANGE_KEY}.nextInvoice`)}</div>
<div className="text-foreground text-sm font-semibold">{formatters.date(nextRenewal, { dateStyle: "long" })}</div>
</div>
)}
</div>
{estimate && <p className="text-muted-foreground text-xs">{t(`${PLAN_CHANGE_KEY}.prorationNote`)}</p>}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setOpen(false)}
disabled={loading}
className="border-border bg-background hover:bg-secondary text-foreground rounded-md border px-4 py-2 text-sm font-medium"
>
{t("shared.cancel")}
</button>
<button
type="button"
onClick={onConfirm}
disabled={loading}
data-ui-id="ui.subscription.planChange.confirm"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{loading ? (
<Fragment>
<span className="base-spinner mr-2 inline-block h-3 w-3" />
{t(`${PLAN_CHANGE_KEY}.redirecting`)}
</Fragment>
) : (
t(`${PLAN_CHANGE_KEY}.continueToCheckout`)
)}
</button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,82 @@
import { Link } from "react-router";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { DefaultFeatures } from "~/application/dtos/shared/DefaultFeatures";
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
import { useLocaleFormatters } from "~/locale/format";
import { CreditTypes } from "~/modules/usage/dtos/CreditType";
import { CheckCircleIcon, XCircleIcon } from "~/components/ui/icons/vendor";
interface Props {
feature: { name: string; title: string; type: SubscriptionFeatureLimitType; value: number; href?: string | null; badge?: string | null };
editing?: boolean;
onClickFeature?: (name: string) => void;
}
export default function PlanFeatureDescription({ feature, editing, onClickFeature }: Props) {
return (
<div className="flex items-center">
{feature.type > 0 && feature.type !== SubscriptionFeatureLimitType.NOT_INCLUDED ? (
<CheckCircleIcon className=" text-primary h-4 w-4 shrink-0" aria-hidden="true" />
) : (
<XCircleIcon className=" h-4 w-4 shrink-0 text-gray-300" />
)}
{feature.href?.startsWith("http") ? (
<Fragment>
{editing ? (
<div className="hover:text-primary text-muted-foreground dark:text-muted-foreground ml-2 truncate text-sm font-medium underline">
<FeatureTitle feature={feature} />
</div>
) : (
<a
href={feature.href}
target="_blank"
rel="noreferrer"
className="hover:text-primary text-muted-foreground dark:text-muted-foreground ml-2 truncate text-sm font-medium underline"
>
<FeatureTitle feature={feature} />
</a>
)}
</Fragment>
) : feature.href?.startsWith("/") ? (
<Fragment>
{editing ? (
<div className="hover:text-primary text-muted-foreground dark:text-muted-foreground ml-2 truncate text-sm font-medium underline">
<FeatureTitle feature={feature} />
</div>
) : (
<Link to={feature.href} className="hover:text-primary text-muted-foreground dark:text-muted-foreground ml-2 truncate text-sm font-medium underline">
<FeatureTitle feature={feature} />
</Link>
)}
</Fragment>
) : (
<Fragment>
{feature.name === DefaultFeatures.Credits && onClickFeature && CreditTypes.length ? (
<button
type="button"
className="hover:text-primary text-muted-foreground ml-2 truncate text-sm font-medium underline"
onClick={() => onClickFeature(feature.name)}
>
<FeatureTitle feature={feature} />
</button>
) : (
<span className="text-muted-foreground ml-2 truncate text-sm font-medium">
<FeatureTitle feature={feature} />
</span>
)}
</Fragment>
)}
</div>
);
}
function FeatureTitle({ feature }: { feature: { title: string; value: number; href?: string | null; badge?: string | null } }) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const value = typeof feature.value === "number" ? formatters.number(feature.value, { maximumFractionDigits: 0 }) : feature.value;
return (
<span>
{t(feature.title, { 0: value })} {feature.badge && <span className="bg-primary text-theme-50 ml-1 rounded-md px-1 py-0.5 text-xs">{feature.badge}</span>}
</span>
);
}
@@ -0,0 +1,33 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { SubscriptionFeatureDto } from "~/application/dtos/subscriptions/SubscriptionFeatureDto";
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
import CheckIcon from "~/components/ui/icons/CheckIcon";
import XIcon from "~/components/ui/icons/XIcon";
interface Props {
item: SubscriptionFeatureDto | undefined;
}
export default function PlanFeatureValue({ item }: Props) {
const { t } = useTranslation();
return (
<div>
{item ? (
<div className="flex items-baseline space-x-1">
{item.type === SubscriptionFeatureLimitType.INCLUDED && <CheckIcon className={clsx("text-primary h-4 w-4 text-center")} />}
{item.type === SubscriptionFeatureLimitType.NOT_INCLUDED && <XIcon className={clsx("h-4 w-4 text-center text-gray-300")} />}
{item.type === SubscriptionFeatureLimitType.MONTHLY && (
<span className="text-muted-foreground lowercase">
{item.value}
{t("pricing.periods.monthlySuffixLong")}
</span>
)}
{item.type === SubscriptionFeatureLimitType.MAX && <span className="text-muted-foreground lowercase">{item.value}</span>}
{item.type === SubscriptionFeatureLimitType.UNLIMITED && <span className="text-muted-foreground lowercase">{t("shared.unlimited")}</span>}
</div>
) : (
<span className=" text-muted-foreground">-</span>
)}
</div>
);
}
@@ -0,0 +1,169 @@
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import clsx from "~/utils/shared/ClassesUtils";
import { useEffect, useState } from "react";
import type { SubscriptionProductDto } from "~/application/dtos/subscriptions/SubscriptionProductDto";
import Plan from "./Plan";
import ToggleBillingPeriod from "./ToggleBillingPeriod";
import CurrencyToggle from "~/components/ui/toggles/CurrencyToggle";
import { PricingModel } from "~/application/enums/subscriptions/PricingModel";
import { getBillingPeriodParams, getYearlyDiscount } from "~/utils/helpers/PricingHelper";
import type { TenantSubscriptionWithDetails } from "~/utils/db/tenantSubscriptions.db.server";
import type { SubscriptionPriceDto } from "~/application/dtos/subscriptions/SubscriptionPriceDto";
import type Stripe from "stripe";
import { useSearchParams } from "react-router";
import { useLocaleFormatters } from "~/locale/format";
interface Props {
items: SubscriptionProductDto[];
tenantSubscription?: TenantSubscriptionWithDetails | null;
canSubmit?: boolean;
className?: string;
stripeCoupon: Stripe.Coupon | null;
currenciesAndPeriod: {
currencies: { value: string; options: string[] };
billingPeriods: { value: SubscriptionBillingPeriod; options: SubscriptionBillingPeriod[] };
};
onClickFeature?: (name: string) => void;
}
export default function Plans({ items, tenantSubscription, canSubmit, className, stripeCoupon, currenciesAndPeriod, onClickFeature }: Props) {
const [searchParams, setSearchParams] = useSearchParams();
const formatters = useLocaleFormatters();
const [currency, setCurrency] = useState(currenciesAndPeriod.currencies.value);
const [billingPeriod, setBillingPeriod] = useState<SubscriptionBillingPeriod>(currenciesAndPeriod.billingPeriods.value);
useEffect(() => {
if (currency !== currenciesAndPeriod.currencies.value) {
setCurrency(currenciesAndPeriod.currencies.value);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currenciesAndPeriod.currencies.value]);
function getRecurringPrices() {
let prices: SubscriptionPriceDto[] = [];
items
.filter((f) => f.model !== PricingModel.ONCE)
.forEach((product) => {
const recurringPrices = product.prices;
prices = prices.concat(recurringPrices);
});
return prices;
}
function alreadyOwned(plan: SubscriptionProductDto) {
const found = tenantSubscription?.products.find((f) => f.subscriptionProductId === plan.id);
if (found) {
return true;
}
return false;
}
function checkUpgradeDowngrade(plan: SubscriptionProductDto) {
const existing = tenantSubscription?.products.find((f) => f)?.subscriptionProduct;
if (existing) {
if (plan.order > existing.order) {
return { upgrade: true };
} else {
return { downgrade: true };
}
}
}
return (
<div className={clsx(className)}>
<div className="flex items-center justify-between">
<div>
<CurrencyToggle
value={currency}
setValue={(e) => {
searchParams.set("c", e.toString());
setSearchParams(searchParams, {
preventScrollReset: true,
});
setCurrency(e);
}}
possibleCurrencies={currenciesAndPeriod.currencies.options}
darkMode
/>
</div>
<div>
<ToggleBillingPeriod
currency={currency}
products={items}
size="sm"
billingPeriod={billingPeriod}
onChange={(e) => {
// console.log("Set billing period: ", SubscriptionBillingPeriod[e]);
searchParams.set("b", getBillingPeriodParams(e));
setSearchParams(searchParams, {
preventScrollReset: true,
});
setBillingPeriod(e);
}}
yearlyDiscount={getYearlyDiscount(getRecurringPrices(), currency, formatters.percent)}
possibleBillingPeriods={currenciesAndPeriod.billingPeriods.options}
darkMode
/>
</div>
</div>
<div
className={clsx(
"grid gap-6 lg:gap-3",
items.length === 2 && "lg:grid-cols-2 xl:grid-cols-2",
items.length === 3 && "lg:grid-cols-3 xl:grid-cols-3",
items.length === 4 && "lg:grid-cols-4 xl:grid-cols-4",
items.length === 5 && "lg:grid-cols-4 xl:grid-cols-4",
items.length === 6 && "lg:grid-cols-3 xl:grid-cols-3",
items.length === 7 && "lg:grid-cols-3 xl:grid-cols-3",
items.length >= 8 && "lg:grid-cols-3 xl:grid-cols-3"
)}
>
{(() => {
// Build a "current plan info" snapshot once for the proration-preview modal.
// Picks the first active TenantSubscriptionProduct that has a flat-price
// matching the user's selected currency.
const activeTenantProduct = tenantSubscription?.products?.find(
(f) => !f.cancelledAt || (f.endsAt && new Date(f.endsAt).getTime() > Date.now())
);
const currentFlatPrice = activeTenantProduct?.prices?.find((p) => p.subscriptionPrice?.currency === currency)?.subscriptionPrice
?? activeTenantProduct?.prices?.[0]?.subscriptionPrice
?? null;
const currentPlanInfo = activeTenantProduct
? {
title: activeTenantProduct.subscriptionProduct.title,
priceAmount: currentFlatPrice ? Number(currentFlatPrice.price) : null,
currency: currentFlatPrice?.currency ?? currency,
currentPeriodStart: activeTenantProduct.currentPeriodStart ? new Date(activeTenantProduct.currentPeriodStart) : null,
currentPeriodEnd: activeTenantProduct.currentPeriodEnd ? new Date(activeTenantProduct.currentPeriodEnd) : null,
}
: null;
return items.map((plan, index) => {
return (
<Plan
key={index}
className={clsx((items.length === 1 || (items.length === 4 && index === 3)) && "lg:col-span-1")}
product={plan}
title={plan.title}
description={plan.description ?? undefined}
badge={plan.badge ?? undefined}
features={plan.features}
billingPeriod={billingPeriod}
currency={currency}
prices={plan.prices}
model={plan.model}
usageBasedPrices={plan.usageBasedPrices}
alreadyOwned={alreadyOwned(plan)}
canSubmit={canSubmit}
isUpgrade={checkUpgradeDowngrade(plan)?.upgrade}
isDowngrade={checkUpgradeDowngrade(plan)?.downgrade}
stripeCoupon={stripeCoupon}
onClickFeature={onClickFeature}
currentPlanInfo={currentPlanInfo}
/>
);
});
})()}
</div>
</div>
);
}
@@ -0,0 +1,38 @@
// @vitest-environment node
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const componentPaths = [
"apps/web/app/components/core/pricing/UsageBasedPricesUnit.tsx",
"apps/web/app/components/core/settings/subscription/ChangeSubscription.tsx",
"apps/web/app/components/core/settings/subscription/Plan.tsx",
"apps/web/app/components/core/settings/subscription/PlanFeatureValue.tsx",
];
function readComponent(relativePath: string) {
return fs.readFileSync(path.join(root, relativePath), "utf8");
}
describe("pricing card locale formatting", () => {
it("keeps visible pricing numbers and currency on locale formatters", () => {
const source = componentPaths.map(readComponent).join("\n");
expect(source).toContain("useLocaleFormatters");
expect(source).toContain("formatters.currency");
expect(source).toContain("formatters.number");
expect(source).toContain("pricing.subscribePlanAmountPeriod");
expect(source).toContain("pricing.periods.monthlySuffixLong");
expect(source).toContain("SubscriptionUtils.getBillingPeriodShortDescription");
expect(source).not.toContain('from "~/utils/shared/NumberUtils"');
expect(source).not.toContain('from "~/utils/helpers/PricingHelper"');
expect(source).not.toContain("NumberUtils.");
expect(source).not.toContain("getFormattedPriceInCurrency");
expect(source).not.toContain('"$"');
expect(source).not.toContain('"License"');
expect(source).not.toContain('"Metered"');
expect(source).not.toContain('"i.e. API Call"');
expect(source).not.toContain("/month</span>");
});
});
@@ -0,0 +1,38 @@
import { useTranslation } from "react-i18next";
import { TenantSubscriptionWithDetails } from "~/utils/db/tenantSubscriptions.db.server";
import WarningBanner from "~/components/ui/banners/WarningBanner";
import { SubscriptionProductDto } from "~/application/dtos/subscriptions/SubscriptionProductDto";
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
interface Props {
products: SubscriptionProductDto[];
subscription: TenantSubscriptionWithDetails | null;
canCancel: boolean;
onCancel: () => void;
}
export default function SubscriptionProducts({ products, subscription, canCancel, onCancel }: Props) {
const { t } = useTranslation();
const appOrAdminData = useAppOrAdminData();
return (
<div>
{products.length === 0 ? (
<>
{appOrAdminData.user.admin ? (
<WarningBanner redirect={adminPath("settings/pricing", { mode: "host-aware" })} title={t("shared.warning")} text={t("admin.pricing.noPricesInDatabase")} />
) : (
<WarningBanner title={t("shared.warning")} text={t("admin.pricing.noPricesConfigured")} />
)}
</>
) : (
<>
{products.map((product, idx) => {
return <div key={idx}>{t(product.title)}</div>;
})}
</>
)}
</div>
);
}
@@ -0,0 +1,209 @@
import { TenantSubscriptionProductPrice } from "@toolctrl/db/prisma-browser";
import { Fragment, useRef } from "react";
import { useTranslation } from "react-i18next";
import { SubscriptionPriceDto } from "~/application/dtos/subscriptions/SubscriptionPriceDto";
import { Colors } from "~/application/enums/shared/Colors";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import { useLocaleFormatters } from "~/locale/format";
import { useRootData } from "~/utils/data/useRootData";
import { TenantSubscriptionProductWithDetails } from "~/utils/db/subscriptions/tenantSubscriptionProducts.db.server";
interface Props {
item: TenantSubscriptionProductWithDetails;
onCancel?: (item: TenantSubscriptionProductWithDetails) => void;
}
export default function TenantProduct({ item, onCancel }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const rootData = useRootData();
const confirmModal = useRef<RefConfirmModal>(null);
function getFormattedPrice(price: TenantSubscriptionProductPrice & { subscriptionPrice: SubscriptionPriceDto | null }) {
const currency = price.subscriptionPrice?.currency ?? "USD";
const formattedPrice = formatters.currency(Number(price.subscriptionPrice?.price), currency);
let priceString = ``;
if (price.subscriptionPrice?.billingPeriod === SubscriptionBillingPeriod.ONCE) {
priceString = `${formattedPrice} x ${item.quantity ?? 1} ${t("pricing.periods.ONCE")}`;
} else if (price.subscriptionPrice?.billingPeriod === SubscriptionBillingPeriod.MONTHLY) {
priceString = `${formattedPrice}/${t("pricing.periods.MONTHLY")}`;
} else if (price.subscriptionPrice?.billingPeriod === SubscriptionBillingPeriod.YEARLY) {
priceString = `${formattedPrice}/${t("pricing.periods.YEARLY")}`;
}
return priceString;
}
function getPlanTitle() {
if (item.quantity && item.quantity > 1) {
return `${item.quantity} x ${t(item.subscriptionProduct.title)}`;
}
return `${t(item.subscriptionProduct.title)}`;
}
function cancelSubscription() {
// Include period-end so the user knows what they lose immediately vs at billing-cycle end.
const periodEnd = item.currentPeriodEnd ? formatters.date(item.currentPeriodEnd, { dateStyle: "long" }) : null;
const message = periodEnd
? t("settings.subscription.confirmCancelWithDate", {
date: periodEnd,
})
: t("settings.subscription.confirmCancel");
confirmModal.current?.show(message);
}
function confirmedCancel() {
if (onCancel) {
onCancel(item);
}
}
return (
<div className="border-border bg-background group space-y-3 rounded-lg border p-4 shadow-2xs" data-ui-id="ui.subscription.product.card">
<div className="flex w-full justify-between">
<div className="text-foreground text-base font-medium">{getPlanTitle()}</div>
<div className="shrink-0">
{item.cancelledAt ? (
<SimpleBadge title={t("settings.subscription.cancelled")} color={Colors.RED} />
) : (
<SimpleBadge title={t("settings.subscription.active")} color={Colors.GREEN} />
)}
</div>
</div>
<div className="border-border w-full border"></div>
{item.currentPeriodStart && item.currentPeriodEnd && (
<div className="text-foreground/80 flex flex-col text-sm sm:flex-row sm:justify-between">
<div className="flex items-center space-x-2">
{!item.endsAt ? (
<div
title={t("settings.subscription.period.rangeTitle", {
from: formatters.dateTime(item.currentPeriodStart, { timeStyle: "medium" }),
to: formatters.dateTime(item.currentPeriodEnd, { timeStyle: "medium" }),
})}
>
{t("settings.subscription.period.current")} ({formatters.date(item.currentPeriodStart, { dateStyle: "long" })} –{" "}
{formatters.date(item.currentPeriodEnd, { dateStyle: "long" })})
</div>
) : (
<div className="flex space-x-2">
<div>
{new Date() < new Date(item.endsAt) ? (
<span>
{t("settings.subscription.ends")} {formatters.date(item.endsAt, { dateStyle: "long" })}
</span>
) : (
<span>
{t("settings.subscription.endedAt")} {formatters.date(item.endsAt, { dateStyle: "long" })}
</span>
)}
</div>
</div>
)}
</div>
<div>
{!item.cancelledAt && (
<button
type="button"
onClick={cancelSubscription}
// Reveal on hover/focus via opacity, not visibility: a
// visibility:hidden button is removed from the tab order and cannot
// receive focus, leaving this destructive control unreachable by
// keyboard/touch users. opacity-0 keeps it focusable so it shows on focus.
className="hover:bg-secondary hover:border-border text-foreground/80 focus:ring-ring border-transparent opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100 border px-2 py-1 text-sm group-hover:underline hover:rounded-md focus:rounded-md focus:underline focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
data-ui-id="ui.subscription.product.cancel"
>
{t("settings.subscription.cancel")}
</button>
)}
</div>
</div>
)}
<div className="flex flex-col space-y-1">
{item.prices
.sort((a) => {
if (a.subscriptionPrice) {
return -1;
}
return 1;
})
.map((price) => {
return (
<Fragment key={price.id}>
{price.subscriptionPrice && (
<div className="text-muted-foreground flex items-baseline space-x-1 text-sm">
<span>{getFormattedPrice(price)}</span>
{/* <span className="text-xs uppercase">{price.subscriptionPrice.currency}</span> */}
</div>
)}
{price.subscriptionUsageBasedPrice && (
<div className="text-muted-foreground text-sm">
<div className="font-bold">{t(price.subscriptionUsageBasedPrice.unitTitlePlural)}</div>
{price.subscriptionUsageBasedPrice.tiers
.sort((a, b) => a.from - b.from)
.map((tier, idx) => {
return (
<div key={idx} className="flex items-center space-x-1">
{price.subscriptionUsageBasedPrice?.tiersMode === "graduated" ? (
<div>{idx === 0 ? t("pricing.usageBased.first") : t("pricing.usageBased.next")}</div>
) : (
<div>{t("pricing.usageBased.total")}</div>
)}
{tier.to ? (
<>
<div>{tier.from}</div>
<div>-</div>
<div>{tier.to ? tier.to : "-"}</div>
</>
) : (
<>
<div>{">="}</div>
<div>{tier.from}</div>
</>
)}
{tier.perUnitPrice && (
<div>
<>
+{formatters.currency(Number(tier.perUnitPrice), price.subscriptionUsageBasedPrice?.currency ?? "USD")} x{" "}
{t(price.subscriptionUsageBasedPrice?.unitTitle ?? "")}
</>
</div>
)}
{tier.flatFeePrice && (
<div>
<>+{formatters.currency(Number(tier.flatFeePrice), price.subscriptionUsageBasedPrice?.currency ?? "USD")}</>
</div>
)}
</div>
);
})}
</div>
)}
</Fragment>
);
})}
</div>
{rootData.debug && (
<div className="border-border bg-secondary block space-y-2 rounded-md border border-dashed p-3 text-xs">
<div className="font-bold">{t("settings.subscription.debug.title")}</div>
<div className="flex items-baseline space-x-2">
<div className="font-medium">{t("settings.subscription.debug.id")}</div>
<div>{item.id}</div>
</div>
{item.stripeSubscriptionId && (
<div className="flex items-baseline space-x-2">
<div className="font-medium">{t("settings.subscription.debug.stripeId")}</div>
<a
target="_blank"
className="underline"
rel="noreferrer"
href={`https://dashboard.stripe.com${rootData.isStripeTest ? "/test" : ""}/subscriptions/${item.stripeSubscriptionId}`}
>
{item.stripeSubscriptionId}
</a>
</div>
)}
</div>
)}
<ConfirmModal ref={confirmModal} onYes={confirmedCancel} destructive contentUiId="ui.subscription.product.cancel.dialog" />
</div>
);
}
@@ -0,0 +1,118 @@
import clsx from "clsx";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { SubscriptionProductDto } from "~/application/dtos/subscriptions/SubscriptionProductDto";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
interface Props {
className?: string;
billingPeriod: SubscriptionBillingPeriod;
onChange: (billingPeriod: SubscriptionBillingPeriod) => void;
yearlyDiscount: string | undefined;
size?: "sm" | "md";
disabled?: boolean;
possibleBillingPeriods: SubscriptionBillingPeriod[];
darkMode?: boolean;
currency: string;
products: SubscriptionProductDto[];
}
export default function ToggleBillingPeriod({
className,
billingPeriod,
onChange,
yearlyDiscount,
size = "md",
disabled,
possibleBillingPeriods,
darkMode,
currency,
products,
}: Props) {
const { t } = useTranslation();
// const [discounts, setDiscounts] = useState<{ billingPeriod: SubscriptionBillingPeriod; discount?: number }[]>([]);
// useEffect(() => {
// setDiscounts(findDiscounts({ currency, products, billingPeriods: possibleBillingPeriods }));
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [currency]);
function setBillingPeriod(item: SubscriptionBillingPeriod) {
if (billingPeriod !== item) {
onChange(item);
}
}
const isMonthlyOrYearly =
possibleBillingPeriods.length === 2 &&
possibleBillingPeriods.includes(SubscriptionBillingPeriod.MONTHLY) &&
possibleBillingPeriods.includes(SubscriptionBillingPeriod.YEARLY);
// function getBillingPeriodTitle(billingPeriod: SubscriptionBillingPeriod) {
// const discount = discounts.find((d) => d.billingPeriod === billingPeriod)?.discount;
// const discountText = discount ? ` (${discount}%)` : "";
// return `${t("pricing." + SubscriptionBillingPeriod[billingPeriod])}${discountText}`;
// }
return (
<Fragment>
{isMonthlyOrYearly ? (
<div className={clsx("flex items-center justify-center space-x-4", className)}>
<button type="button" className="text-sm font-medium" onClick={() => setBillingPeriod(SubscriptionBillingPeriod.MONTHLY)}>
{t("pricing.MONTHLY")}
</button>
<button
disabled={disabled}
type="button"
aria-label={t("settings.subscription.toggleBillingPeriod", { defaultValue: "Toggle billing period" })}
aria-pressed={billingPeriod === SubscriptionBillingPeriod.YEARLY}
className={clsx(
"focus:ring-primary relative rounded-full focus:outline-hidden focus:ring-2 focus:ring-offset-2",
disabled && "cursor-not-allowed opacity-80"
)}
onClick={() =>
setBillingPeriod(billingPeriod === SubscriptionBillingPeriod.MONTHLY ? SubscriptionBillingPeriod.YEARLY : SubscriptionBillingPeriod.MONTHLY)
}
>
<div className={clsx("bg-primary rounded-full shadow-md outline-hidden transition", size === "sm" && "h-4 w-8", size === "md" && "h-8 w-16")}></div>
<div
className={clsx(
"bg-primary-foreground absolute left-1 top-1 inline-flex transform items-center justify-center rounded-full shadow-2xs transition-all duration-200 ease-in-out",
size === "sm" && "h-2 w-2",
size === "md" && "h-6 w-6",
billingPeriod === 3 && "translate-x-0",
billingPeriod === 4 && size === "sm" && "translate-x-4",
billingPeriod === 4 && size === "md" && "translate-x-8"
)}
></div>
</button>
<button type="button" className="text-sm font-medium" onClick={() => setBillingPeriod(SubscriptionBillingPeriod.YEARLY)}>
{t("pricing.YEARLY")}{" "}
{yearlyDiscount && (
<span className="ml-1 inline-flex items-center rounded-md bg-teal-100 px-2.5 py-0.5 text-sm font-medium text-teal-800">{yearlyDiscount}</span>
)}
</button>
</div>
) : possibleBillingPeriods.length > 1 ? (
<Fragment>
<div className={className}>
<select
value={billingPeriod}
onChange={(e) => setBillingPeriod(Number(e.target.value) as SubscriptionBillingPeriod)}
className={clsx(
"focus:ring-primary focus:border-primary border-border text-foreground block w-full rounded-md py-2 pl-3 pr-10 text-base focus:outline-hidden sm:text-sm",
darkMode && "dark:border-gray-700 dark:bg-gray-800 dark:text-gray-50"
)}
>
{possibleBillingPeriods.map((item) => {
return (
<option key={item} value={item}>
{t("pricing." + SubscriptionBillingPeriod[item])}
</option>
);
})}
</select>
</div>
</Fragment>
) : null}
</Fragment>
);
}
@@ -0,0 +1,89 @@
import { Fragment, useMemo, useState } from "react";
import type { SubscriptionProductDto } from "~/application/dtos/subscriptions/SubscriptionProductDto";
import type { TenantSubscriptionWithDetails } from "~/utils/db/tenantSubscriptions.db.server";
import Plans from "../Plans";
import type Stripe from "stripe";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import Modal from "~/components/ui/modals/Modal";
import { DefaultFeatures } from "~/application/dtos/shared/DefaultFeatures";
import CreditsTableInfo from "~/modules/usage/components/CreditsTableInfo";
interface Props {
items: SubscriptionProductDto[];
tenantSubscription?: TenantSubscriptionWithDetails | null;
canSubmit?: boolean;
stripeCoupon: Stripe.Coupon | null;
currenciesAndPeriod: {
currencies: { value: string; options: string[] };
billingPeriods: { value: SubscriptionBillingPeriod; options: SubscriptionBillingPeriod[] };
};
}
export default function PlansGrouped({ items, tenantSubscription, canSubmit, stripeCoupon, currenciesAndPeriod }: Props) {
const [showFeatureInfoModal, setShowFeatureInfoModal] = useState<boolean>(false);
const [showFeatureInfo, setShowFeatureInfo] = useState<string | null>(null);
const groups = useMemo(() => {
const groups: { group: { title: string; description: string }; items: SubscriptionProductDto[] }[] = [];
items.forEach((product) => {
let found = groups.find((f) => f.group.title === product.groupTitle && f.group.description === product.groupDescription);
if (!found) {
found = groups.find((f) => !f.group.title && !f.group.description && !product.groupTitle && !product.groupDescription);
}
if (found) {
found.items.push(product);
} else {
groups.push({
group: {
title: product.groupTitle ?? "",
description: product.groupDescription ?? "",
},
items: [product],
});
}
});
return groups;
}, [items]);
return (
<Fragment>
<div className="space-y-10">
{groups.map((group, idx) => {
return (
<Fragment key={idx}>
<div>
{group.group.title && (
<div className="py-4">
<div className="py-2">
<h2 className="text-2xl font-bold md:text-3xl">{group.group.title}</h2>
<p className="text-muted-foreground text-lg">{group.group.description}</p>
</div>
</div>
)}
<Plans
key={idx}
items={group.items}
tenantSubscription={tenantSubscription}
canSubmit={canSubmit}
className="space-y-4"
stripeCoupon={stripeCoupon}
currenciesAndPeriod={currenciesAndPeriod}
onClickFeature={(name) => {
setShowFeatureInfo(name);
setShowFeatureInfoModal(true);
}}
/>
</div>
</Fragment>
);
})}
</div>
<Modal size="lg" open={showFeatureInfoModal} setOpen={() => setShowFeatureInfoModal(false)}>
{showFeatureInfo === DefaultFeatures.Credits && (
<div>
<CreditsTableInfo />
</div>
)}
</Modal>
</Fragment>
);
}
@@ -0,0 +1,105 @@
import { useTranslation } from "react-i18next";
import ErrorModal, { RefErrorModal } from "~/components/ui/modals/ErrorModal";
import { useEffect, useRef, useState } from "react";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import { Form, useActionData, useSubmit } from "react-router";
import SuccessModal, { RefSuccessModal } from "~/components/ui/modals/SuccessModal";
import RowProperties from "~/components/entities/rows/RowProperties";
import type { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import { useRootData } from "~/utils/data/useRootData";
export default function TenantNew({ tenantSettingsEntity }: { tenantSettingsEntity: EntityWithDetails | null }) {
const actionData = useActionData<{ tenantId?: string; error?: string; success?: string }>();
const { t } = useTranslation();
const submit = useSubmit();
const rootData = useRootData();
const inputName = useRef<RefInputText>(null);
const errorModal = useRef<RefErrorModal>(null);
const successModal = useRef<RefSuccessModal>(null);
const [name, setName] = useState("");
useEffect(() => {
inputName.current?.input.current?.focus();
inputName.current?.input.current?.select();
}, []);
useEffect(() => {
if (actionData?.error) {
errorModal.current?.show(actionData.error);
} else if (actionData?.success) {
successModal.current?.show(actionData.success);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionData]);
function createdTenant() {
if (actionData?.tenantId) {
const form = new FormData();
form.set("action", "set-tenant");
form.set("tenantId", actionData.tenantId);
form.set("redirectTo", location.pathname + location.search);
submit(form, {
method: "post",
action: "/",
});
}
}
return (
<div>
<div className="flex flex-1 flex-col justify-between">
<Form method="post" className="" data-ui-id="ui.account.create.form">
<input type="hidden" name="action" value="create" hidden readOnly />
<input type="hidden" name="csrf" value={rootData.csrf ?? ""} hidden readOnly />
<div className="space-y-3 pb-5 pt-2">
<div>
{/* <label className="block text-xs font-medium text-muted-foreground">
{t("account.register.organization")} <span className="ml-1 text-red-500">*</span>
</label> */}
<div className="mt-1 -space-y-px rounded-md shadow-2xs">
<div>
{/* <label htmlFor="tax-id" className="sr-only">
{t("shared.name")}
</label> */}
<InputText
title={t("shared.name")}
ref={inputName}
type="text"
name="name"
id="name"
dataUiId="ui.account.create.name"
placeholder={t("shared.name")}
required
value={name}
setValue={setName}
// className="focus:border-theme-500 focus:ring-ring relative block w-full appearance-none rounded-md border border-border px-3 py-2 text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden sm:text-sm"
/>
</div>
</div>
</div>
{tenantSettingsEntity && (
<div className="col-span-6 sm:col-span-6">
<RowProperties entity={tenantSettingsEntity} item={null} />
</div>
)}
</div>
<div className="pb-6 text-right">
<div className="right-0 text-sm leading-5">
<span className="ml-2 inline-flex rounded-sm shadow-2xs">
<LoadingButton type="submit" dataUiId="ui.account.create.submit">{t("shared.create")}</LoadingButton>
</span>
</div>
</div>
</Form>
</div>
<SuccessModal ref={successModal} onClosed={createdTenant} />
<ErrorModal ref={errorModal} />
</div>
);
}
@@ -0,0 +1,83 @@
import { useNavigation, Form } from "react-router";
import clsx from "clsx";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Modal from "~/components/ui/modals/Modal";
import { TenantSimple } from "~/utils/db/tenants.db.server";
export default function DeactivateTenantModal({
item,
open,
onClose,
onConfirm,
}: {
item?: TenantSimple;
open: boolean;
onClose: () => void;
onConfirm: (item: TenantSimple, reason: string) => void;
}) {
const { t } = useTranslation();
const navigation = useNavigation();
const [reason, setReason] = useState("");
function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!item) {
return;
}
onConfirm(item, reason);
}
return (
<Modal open={open} setOpen={onClose} size="md">
{!item ? (
<div></div>
) : (
<Form onSubmit={onSubmit} className="bg-background inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
<div className="mt-3 text-center sm:mt-5">
<div className="flex items-baseline space-x-1">
<h3 className="text-foreground text-lg font-medium leading-6">{t("settings.tenant.deactivation.title")}</h3>
<div className="font-medium">{item.name}</div>
</div>
</div>
<div className="mt-4 space-y-2">
<div className="relative mt-1 rounded-md shadow-2xs">
<input
value={reason}
onChange={(e) => setReason(e.target.value)}
type="text"
name="reason"
id="reason"
required
className="border-border focus:border-border block w-full rounded-md focus:ring-gray-500 sm:text-sm"
placeholder={t("settings.tenant.deactivation.reasonPlaceholder")}
/>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<button
type="button"
className={clsx(
"inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-2xs focus:outline-hidden focus:ring-2 focus:ring-offset-2 sm:col-start-2 sm:text-sm",
"bg-red-600 hover:bg-red-700 focus:ring-red-500"
)}
onClick={() => onConfirm(item, reason)}
>
{t("shared.deactivate")}
</button>
<button
type="submit"
disabled={navigation.state === "submitting"}
className={clsx(
"hover:bg-secondary border-border bg-background text-foreground/80 mt-3 inline-flex w-full justify-center rounded-md border px-4 py-2 text-base font-medium shadow-2xs focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 sm:col-start-1 sm:mt-0 sm:text-sm",
navigation.state === "submitting" ? "base-spinner cursor-not-allowed" : ""
)}
>
{t("shared.back")}
</button>
</div>
</Form>
)}
</Modal>
);
}
@@ -0,0 +1,19 @@
import { useTranslation } from "react-i18next";
import { useAppData } from "~/utils/data/useAppData";
interface Props {
item: { id: string; name: string; slug: string };
showCurrent?: boolean;
}
export default function TenantBadge({ item, showCurrent }: Props) {
const { t } = useTranslation();
const appData = useAppData();
function isCurrent() {
return appData.currentTenant?.id === item.id;
}
return (
<div>
{item.name} {showCurrent && isCurrent() && <span className="text-xs font-normal italic opacity-80">({t("shared.current")})</span>}
</div>
);
}
@@ -0,0 +1,33 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import TenantCell from "./TenantCell";
vi.mock("~/utils/data/useRootData", () => ({
useRootData: () => ({
routing: {
protocol: "https",
webAppHost: "app.example.test",
adminAppHost: "admin.example.test",
apiHost: "api.example.test",
appBaseDomain: "example.test",
},
}),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => ({ "shared.admin": "admin" })[key] ?? key,
}),
}));
describe("TenantCell", () => {
it("links tenant rows to the app host and admin rows to the admin dashboard", () => {
const { rerender } = render(<TenantCell item={{ id: "tenant_1", name: "Acme", slug: "acme" }} />);
expect(screen.getByRole("link", { name: "acme" })).toHaveAttribute("href", "https://app.example.test/acme");
rerender(<TenantCell item={null} />);
expect(screen.getByRole("link", { name: "admin" })).toHaveAttribute("href", "https://admin.example.test/dashboard");
});
});
@@ -0,0 +1,27 @@
import { absoluteAdminPath, absoluteAppPath } from "~/utils/app/CrossHostUrls";
import { useRootData } from "~/utils/data/useRootData";
import { useTranslation } from "react-i18next";
interface Props {
item: {
id: string;
name: string;
slug: string;
} | null;
}
export default function TenantCell({ item }: Props) {
const rootData = useRootData();
const { t } = useTranslation();
return (
<div>
{item ? (
<a href={absoluteAppPath(rootData.routing, item.slug)} className="hover:underline" target="_blank" rel="noreferrer">
<span>{item.slug}</span>
</a>
) : (
<a href={absoluteAdminPath(rootData.routing, "dashboard")} className="font-medium text-theme-500 hover:underline" target="_blank" rel="noreferrer">
<span>{t("shared.admin")}</span>
</a>
)}
</div>
);
}
@@ -0,0 +1,271 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import TableSimple from "~/components/ui/tables/TableSimple";
import { TenantWithUsage } from "~/utils/db/tenants.db.server";
import { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
import SubscriptionUtils from "~/utils/app/SubscriptionUtils";
import { RowHeaderActionDto } from "~/application/dtos/data/RowHeaderActionDto";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import { Colors } from "~/application/enums/shared/Colors";
import type Stripe from "stripe";
import { Link } from "react-router";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
import { absoluteAppPath } from "~/utils/app/CrossHostUrls";
import { useRootData } from "~/utils/data/useRootData";
import { createLocaleFormatters, type LocaleFormatters } from "~/locale/format";
import { translateLabel } from "~/utils/i18n/translateLabel";
interface Props {
items: TenantWithUsage[];
pagination: PaginationDto;
actions?: RowHeaderActionDto<TenantWithUsage>[];
tenantInvoices?: Stripe.Invoice[];
isStripeTest?: boolean;
}
type TenantInvoiceSummary = {
lastInvoice?: Stripe.Invoice;
paidCount: number;
totalsPaidByCurrency: Map<string, number>;
};
export default function TenantsTable({ items, pagination, actions = [], tenantInvoices, isStripeTest }: Props) {
const { i18n, t } = useTranslation();
const formatters = useMemo(() => createLocaleFormatters(i18n.language), [i18n.language]);
const rootData = useRootData();
const [headers, setHeaders] = useState<RowHeaderDisplayDto<TenantWithUsage>[]>([]);
const invoiceSummaryByCustomer = useMemo(() => {
if (!tenantInvoices) {
return undefined;
}
const summaries = new Map<string, TenantInvoiceSummary>();
for (const invoice of tenantInvoices) {
const customerId = invoice.customer?.toString();
if (!customerId) {
continue;
}
const summary = summaries.get(customerId) ?? { paidCount: 0, totalsPaidByCurrency: new Map<string, number>() };
const currency = invoice.currency.toUpperCase();
const amountPaid = Number(invoice.amount_paid / 100);
if (amountPaid > 0) {
summary.totalsPaidByCurrency.set(currency, (summary.totalsPaidByCurrency.get(currency) ?? 0) + amountPaid);
}
if (invoice.status === "paid") {
summary.paidCount += 1;
}
if (!summary.lastInvoice || invoice.created > summary.lastInvoice.created) {
summary.lastInvoice = invoice;
}
summaries.set(customerId, summary);
}
return summaries;
}, [tenantInvoices]);
useEffect(() => {
const headers: RowHeaderDisplayDto<TenantWithUsage>[] = [];
headers.push({
name: "tenant",
title: t("models.tenant.object"),
value: (i) => (
<div className="max-w-sm truncate">
<div className="text-foreground flex items-center space-x-1 truncate font-medium">
<Link to={adminPath(`accounts/${i.id}`, { mode: "host-aware" })} className="hover:underline">
{i.name}
</Link>
{i.deactivatedReason && <SimpleBadge title={t("shared.deactivated") + ": " + i.deactivatedReason} color={Colors.RED} />}
</div>
<Link
to={absoluteAppPath(rootData.routing, i.slug)}
className="text-muted-foreground focus:bg-secondary/90 hover:border-border rounded-md border-b border-dashed text-xs hover:border-dashed"
>
<span>/{i.slug}</span>
</Link>
</div>
),
});
headers.push({
name: "subscription",
title: t("admin.tenants.subscription.title"),
value: () => "",
formattedValue: (item) => (
<span>
{item.subscription?.products ? (
<>
<div>
{item.subscription.products.map((product) => {
return (
<div key={product.id}>
<div>
{t(product.subscriptionProduct.title)}{" "}
{product.prices
.map((f) => {
const price = f.subscriptionPrice;
return `${formatters.currency(Number(price?.price ?? 0), (price?.currency ?? "USD").toUpperCase())} - ${SubscriptionUtils.getBillingPeriodDescription(
t,
price?.billingPeriod ?? 0
)}`;
})
.join(", ")}
</div>
{product.endsAt && (
<>
{new Date() < new Date(product.endsAt) ? (
<div className="text-orange-500">
{t("settings.subscription.ends")} {formatters.date(product.endsAt, { dateStyle: "long" })}
</div>
) : (
<div className="text-red-500">
{t("settings.subscription.endedAt")} {formatters.date(product.endsAt, { dateStyle: "long" })}
</div>
)}
</>
)}
</div>
);
})}
</div>
</>
) : (
<span className="text-muted-foreground text-sm italic">{t("settings.subscription.noSubscription")}</span>
)}
</span>
),
});
if (tenantInvoices !== undefined) {
function getInvoiceSummary(tenant: TenantWithUsage): TenantInvoiceSummary | undefined {
const customerId = tenant.subscription?.stripeCustomerId;
return customerId ? invoiceSummaryByCustomer?.get(customerId) : undefined;
}
headers.push({
name: "lastInvoice",
title: t("settings.tenant.billing.lastInvoice"),
value: (i) =>
(() => {
const summary = getInvoiceSummary(i);
return (
<a
className="flex flex-col space-y-1 hover:underline"
target="_blank"
rel="noreferrer"
href={`https://dashboard.stripe.com${isStripeTest ? "/test" : ""}/customers/${i.subscription?.stripeCustomerId ?? ""}`}
>
<div className="flex flex-col space-y-1">
{!summary?.lastInvoice ? (
<span className="text-muted-foreground italic">{t("shared.none")}</span>
) : (
<TenantInvoice item={summary.lastInvoice} formatters={formatters} />
)}
</div>
</a>
);
})(),
});
headers.push({
name: "totalInvoicesPaid",
title: t("settings.tenant.billing.totalPaid"),
value: (i) =>
(() => {
const summary = getInvoiceSummary(i);
return (
<a
className="flex flex-col space-y-1 hover:underline"
target="_blank"
rel="noreferrer"
href={`https://dashboard.stripe.com${isStripeTest ? "/test" : ""}/customers/${i.subscription?.stripeCustomerId ?? ""}`}
>
{!summary || summary.totalsPaidByCurrency.size === 0 ? (
<span className="text-muted-foreground italic">{t("shared.none")}</span>
) : (
<div className="flex flex-col">
{[...summary.totalsPaidByCurrency.entries()].map(([currency, totalPaid]) => (
<span key={currency}>{formatters.currency(totalPaid, currency)}</span>
))}
<span className="text-muted-foreground text-xs">({formatters.number(summary.paidCount)})</span>
</div>
)}
</a>
);
})(),
});
}
headers.push({
name: "types",
title: t("shared.types"),
value: (i) =>
i.types.length === 0 ? (
<span className="text-muted-foreground">{t("shared.default")}</span>
) : (
i.types.map((f) => translateLabel(t, i18n, f.title)).join(", ")
),
});
headers.push({
name: "users",
title: t("models.user.plural"),
className: "max-w-xs truncate",
value: (i) => i.users.map((f) => f.user.email).join(", "),
href: (i) => adminPath(`accounts/users?tenantId=${i.id}`, { mode: "host-aware" }),
});
headers.push({
name: "rows",
title: t("models.row.plural"),
value: (item) => <Link to={adminPath("entities/rows?tenantId=" + item.id, { mode: "host-aware" })}>{formatters.number(item._count.rows ?? 0)}</Link>,
});
headers.push({
name: "events",
title: t("models.event.plural"),
value: (i) => i._count.events ?? 0,
formattedValue: (i) => <Link to={adminPath(`events?tenantId=${i.id}`, { mode: "host-aware" })}>{formatters.number(i._count.events ?? 0)}</Link>,
});
headers.push({
name: "createdAt",
title: t("shared.createdAt"),
value: (i) => i.createdAt,
formattedValue: (item) => (
<div className="flex flex-col">
<div>{formatters.date(item.createdAt)}</div>
<div className="text-xs">{formatters.relativeDate(item.createdAt)}</div>
</div>
),
});
setHeaders(headers);
}, [formatters, invoiceSummaryByCustomer, isStripeTest, rootData.routing, t, tenantInvoices]);
return (
<div className="space-y-2">
<TableSimple
items={items}
headers={headers}
actions={[
{
title: t("admin.tenants.overview"),
onClickRoute: (_, item) => adminPath(`accounts/${item.id}`, { mode: "host-aware" }),
},
...actions,
]}
pagination={pagination}
/>
</div>
);
}
function TenantInvoice({ item, formatters }: { item: Stripe.Invoice; formatters: LocaleFormatters }) {
const { t } = useTranslation();
const createdAt = new Date(item.created * 1000);
return (
<div className="flex flex-col">
<div title={formatters.dateTime(createdAt, { timeStyle: "medium" })} className="flex items-center space-x-1">
<div className="flex items-baseline space-x-1">
<div>{formatters.currency(item.total / 100, item.currency.toUpperCase())}</div>
<div className="text-muted-foreground text-xs uppercase">{item.currency}</div>
</div>
<SimpleBadge title={t("app.subscription.invoices.status." + item.status)} color={item.status === "paid" ? Colors.GREEN : Colors.YELLOW} />
</div>
<div className="text-muted-foreground text-xs">{item.created ? formatters.relativeDate(createdAt) : ""}</div>
</div>
);
}
@@ -0,0 +1,144 @@
import { Form } from "react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import ButtonTertiary from "~/components/ui/buttons/ButtonTertiary";
import UploadDocuments from "~/components/ui/uploaders/UploadDocument";
import InputText from "~/components/ui/input/InputText";
import UrlUtils from "~/utils/app/UrlUtils";
import type { TenantWithDetails } from "~/utils/db/tenants.db.server";
import RowProperties from "~/components/entities/rows/RowProperties";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import type { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
import InputCombobox from "~/components/ui/input/InputCombobox";
import { useRootData } from "~/utils/data/useRootData";
import { translateLabel } from "~/utils/i18n/translateLabel";
import { UserIcon } from "~/components/ui/icons/vendor";
type TenantTypeOption = {
id: string;
title: string;
};
interface Props {
tenant: TenantWithDetails;
disabled: boolean;
tenantSettingsEntity: EntityWithDetails | null;
tenantTypes?: TenantTypeOption[];
options?: {
canChangeType: boolean;
};
}
export default function UpdateTenantDetailsForm({ tenant, disabled, tenantSettingsEntity, tenantTypes, options }: Props) {
const { i18n, t } = useTranslation();
const rootData = useRootData();
const [slug, setSlug] = useState<string | undefined>(tenant?.slug ?? "");
const [icon, setIcon] = useState<string | undefined>(tenant?.icon ?? "");
const [types, setTypes] = useState<string[]>(tenant.types.map((f) => f.id));
function loadedImage(image: string | undefined) {
setIcon(image);
}
return (
<Form method="post" data-ui-id="ui.settings.account.form">
<input type="hidden" name="action" value="edit" hidden readOnly />
{rootData.csrf && <input type="hidden" name="csrf" value={rootData.csrf} />}
<div className="p-1">
<div className="">
<div className="grid grid-cols-6 gap-2">
<div className="col-span-6 sm:col-span-6">
<InputText
autoFocus
disabled={disabled}
required
name="name"
title={t("shared.name")}
defaultValue={tenant?.name}
dataUiId="ui.settings.account.name"
/>
</div>
<div className="col-span-6 sm:col-span-6">
<InputText
disabled={disabled}
required
name="slug"
title={t("shared.slug")}
dataUiId="ui.settings.account.slug"
value={slug}
setValue={(e) => {
const slug = UrlUtils.slugify(e.toString());
if (slug) {
setSlug(slug);
}
}}
/>
</div>
{tenantTypes !== undefined && tenantTypes.length > 0 && (
<div className="col-span-6 sm:col-span-6">
{types?.map((item, idx) => {
return <input key={idx} type="hidden" name={`typeIds[]`} value={item} hidden readOnly />;
})}
<InputCombobox
withSearch={false}
disabled={!options?.canChangeType}
name="typeIds"
title={t("shared.type")}
value={types}
onChange={(e) => setTypes(e as string[])}
options={tenantTypes.map((f) => {
return {
value: f.id,
name: translateLabel(t, i18n, f.title),
};
})}
/>
</div>
)}
<div className="col-span-6 sm:col-span-6">
<label htmlFor="icon" className="text-foreground/80 block text-xs leading-5 font-medium">
{t("shared.icon")}
</label>
<div className="mt-2 flex items-center space-x-3">
<input id="icon" name="icon" value={icon} hidden readOnly />
<div className="bg-secondary/90 h-12 w-12 overflow-hidden rounded-md">
{(() => {
if (icon) {
return <img id="icon" alt={t("settings.tenant.iconAlt")} src={icon} />;
} else {
return (
<UserIcon id="icon" className="h-full w-full text-gray-300" />
);
}
})()}
</div>
{icon ? (
<ButtonTertiary disabled={disabled} destructive={true} onClick={() => loadedImage("")} type="button">
{t("shared.delete")}
</ButtonTertiary>
) : (
<UploadDocuments disabled={disabled} accept="image/png, image/jpg, image/jpeg" onDropped={loadedImage} />
)}
</div>
</div>
{tenantSettingsEntity && (
<div className="col-span-6 sm:col-span-6">
<RowProperties entity={tenantSettingsEntity} item={tenant.tenantSettingsRow?.row ?? null} />
</div>
)}
</div>
</div>
<div className="border-border mt-3 border-t pt-3">
<div className="flex justify-end">
<LoadingButton disabled={disabled} type="submit" dataUiId="ui.settings.account.submit">
{t("shared.save")}
</LoadingButton>
</div>
</div>
</div>
</Form>
);
}
@@ -0,0 +1,3 @@
export default function UpdateTenantSubscriptionForm() {
return null;
}
@@ -0,0 +1,809 @@
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { Link, useFetcher, useSearchParams } from "react-router";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import { Label } from "~/components/ui/label";
import { TenantWithProfile } from "~/utils/db/tenants.db.server";
import { ColorPicker } from "~/components/ui/colors/ColorPicker";
import InputSelect from "~/components/ui/input/InputSelect";
import SettingSection from "~/components/ui/sections/SettingSection";
import { defaultThemes, getThemeConfig } from "~/utils/theme/defaultThemes";
import { useThemePreviewStore } from "~/stores/themePreviewStore";
import { ThemeConfig, defaultThemeConfig, parseThemeConfig } from "~/utils/theme/ThemeConfig";
import { Input } from "~/components/ui/input";
import { useRootData } from "~/utils/data/useRootData";
import { parseCssConfig } from "~/utils/theme/cssConfigParser";
import { exportConfigToCss } from "~/utils/theme/cssConfigExporter";
import { ExternalLinkIcon, ArrowLeftIcon, ArrowRightIcon, ImportIcon, EraserIcon, SunIcon, MoonIcon } from "~/components/ui/icons/vendor";
import ModalOrSlide from "~/components/ui/modals/ModalOrSlide";
import MonacoEditor from "~/components/editors/MonacoEditor";
import InputChoicebox from "~/components/ui/input/InputChoicebox";
type Props = {
tenant: TenantWithProfile;
};
export default function UpdateTenantFormTheme({ tenant }: Props) {
const { t } = useTranslation();
const { csrf, userSession } = useRootData();
const [searchParams] = useSearchParams();
const fetcher = useFetcher<{ success?: string; error?: string }>();
const { setPreviewTheme, setPreviewConfig, enablePreview, disablePreview, resetPreview } = useThemePreviewStore();
const isDarkMode = userSession?.lightOrDarkMode === "dark";
const [theme, setTheme] = useState(tenant?.theme || "");
const [config, setConfig] = useState<ThemeConfig>(() => parseThemeConfig(tenant?.themeConfig as string | null));
const [cssDialogOpen, setCssDialogOpen] = useState(false);
const [cssEditorValue, setCssEditorValue] = useState("");
const isUserInDarkMode = userSession?.lightOrDarkMode === "dark";
// Enable preview mode when component mounts
useEffect(() => {
enablePreview();
return () => {
disablePreview();
};
}, [enablePreview, disablePreview]);
// Update preview whenever state changes
useEffect(() => {
setPreviewTheme(theme || null);
setPreviewConfig(config);
}, [theme, config, setPreviewTheme, setPreviewConfig]);
useEffect(() => {
if (fetcher.data?.success) {
toast.success(fetcher.data.success);
} else if (fetcher.data?.error) {
toast.error(fetcher.data.error);
}
}, [fetcher.data]);
const updateConfigColor = (key: keyof NonNullable<ThemeConfig["colors"]>, value: string) => {
if (isUserInDarkMode) {
setConfig((prev) => ({
...prev,
darkColors: {
...prev.darkColors,
[key]: value || undefined,
},
}));
} else {
setConfig((prev) => ({
...prev,
colors: {
...prev.colors,
[key]: value || undefined,
},
}));
}
};
const getConfigColor = (key: keyof NonNullable<ThemeConfig["colors"]>) => {
if (isUserInDarkMode) {
return config.darkColors?.[key] || "";
}
return config.colors?.[key] || "";
};
const resetToDefaults = () => {
setTheme("");
setConfig({});
resetPreview();
};
const handleApplyCss = () => {
try {
const parsedConfig = parseCssConfig(cssEditorValue);
// Merge the parsed config with existing config
setConfig((prev) => ({
...prev,
...parsedConfig,
colors: {
...prev.colors,
...parsedConfig.colors,
},
darkColors: {
...prev.darkColors,
...parsedConfig.darkColors,
},
}));
// Clear theme preset since we're using custom config
setTheme("");
toast.success(t("settings:tenant.theme.editor.cssApplied"));
setCssDialogOpen(false);
} catch {
toast.error(t("settings:tenant.theme.editor.cssParseFailed"));
}
};
const openCssDialog = () => {
// Pre-populate with current config when opening
setCssEditorValue(exportConfigToCss(config));
setCssDialogOpen(true);
};
function previousTheme() {
const currentIndex = defaultThemes.findIndex((t) => t.value === theme);
const previousIndex = currentIndex - 1;
setTheme(defaultThemes[previousIndex].value);
setConfig(getThemeConfig(defaultThemes[previousIndex].value) || defaultThemeConfig);
}
function nextTheme() {
const currentIndex = defaultThemes.findIndex((t) => t.value === theme);
const nextIndex = currentIndex + 1;
setTheme(defaultThemes[nextIndex].value);
setConfig(getThemeConfig(defaultThemes[nextIndex].value) || defaultThemeConfig);
}
const onThemeToggle = async () => {
const form = new FormData();
form.set("action", "toggleLightOrDarkMode");
form.set("redirect", location.pathname + "?" + searchParams.toString());
if (csrf) {
form.set("csrf", csrf);
}
fetcher.submit(form, { method: "post", action: "/preferences", preventScrollReset: true });
};
return (
<div className="space-y-6">
<fetcher.Form method="post">
<input type="hidden" name="action" value="update-theme" readOnly hidden />
<input type="hidden" name="theme" value={theme} readOnly hidden />
<input type="hidden" name="themeConfig" value={JSON.stringify(config)} readOnly hidden />
<div className="space-y-6">
{/* Appearance Section */}
<SettingSection
title={t("settings:tenant.theme.appearance.title")}
description={t("settings:tenant.theme.appearance.description")}
>
<div className="grid grid-cols-2 gap-4">
{/* Theme Selection */}
<div className="col-span-1">
<div className="mb-2 flex items-center justify-between">
<Label className="block text-sm font-medium">{t("settings:tenant.theme.appearance.themePreset")}</Label>
</div>
<div className="flex items-center gap-2">
<InputSelect
name="themeSelect"
value={theme}
selectClassName="bg-background"
setValue={(value) => {
const newTheme = value?.toString() || "";
setTheme(newTheme);
// Apply theme preset colors if available
if (newTheme) {
const themePreset = getThemeConfig(newTheme);
if (themePreset) {
setConfig((prev) => ({
...prev,
fontFamily: themePreset.fontFamily || prev.fontFamily,
radius: themePreset.radius || prev.radius,
sidebarVariant: themePreset.sidebarVariant || prev.sidebarVariant,
sidebarDarkMode: themePreset.sidebarDarkMode ?? prev.sidebarDarkMode,
colors: {
...prev.colors,
...themePreset.colors,
},
darkColors: {
...prev.darkColors,
...themePreset.darkColors,
},
}));
}
}
}}
options={[
{ value: "", name: t("settings:tenant.theme.appearance.useDefaultTheme") },
...defaultThemes.map((theme) => {
const label = t(theme.labelKey, { defaultValue: theme.name });
return {
value: theme.value,
name: (
<div className="flex items-center space-x-2">
<div
className={cn(
`theme-${theme.value}`,
"inline-flex shrink-0 items-center rounded-sm text-xs font-medium",
"size-2.5",
"bg-primary text-primary"
)}
/>
<span>{label}</span>
</div>
),
};
}),
]}
className="flex-1"
/>
</div>
</div>
<div>
{/* Import/Export Theme */}
<div className="mb-2 flex items-center justify-between">
<Label className="invisible block text-sm font-medium">{t("settings:tenant.theme.appearance.themeActions")}</Label>
</div>
<div className="flex items-center gap-2">
{/* Previous theme */}
<Button type="button" variant="outline" size="sm" onClick={previousTheme} aria-label={t("shared.previous")}>
<ArrowLeftIcon className="size-3.5" aria-hidden="true" />
</Button>
{/* Next theme */}
<Button type="button" variant="outline" size="sm" onClick={nextTheme} aria-label={t("shared.next")}>
<ArrowRightIcon className="size-3.5" aria-hidden="true" />
</Button>
<Button type="button" variant="outline" size="sm" onClick={openCssDialog} aria-label={t("shared.import")}>
<ImportIcon className="size-3.5" aria-hidden="true" />
</Button>
{/* Reset theme */}
<Button type="button" variant="outline" size="sm" onClick={resetToDefaults} aria-label={t("shared.reset")}>
<EraserIcon className="size-3.5" aria-hidden="true" />
</Button>
<Button type="button" variant="outline" size="sm" onClick={onThemeToggle} aria-label={t(isDarkMode ? "shared.light" : "shared.dark")}>
{/* <DarkModeSvgs className="size-3.5" withLabel={false} /> */}
{isDarkMode ? <SunIcon className="size-3.5" aria-hidden="true" /> : <MoonIcon className="size-3.5" aria-hidden="true" />}
</Button>
</div>
</div>
{/* Font Family */}
<div>
<Label className="mb-2 block text-sm font-medium">{t("settings:tenant.theme.appearance.fontFamily")}</Label>
<InputSelect
name="fontFamily"
value={config.fontFamily || ""}
selectClassName="bg-background"
setValue={(value) => setConfig({ ...config, fontFamily: value?.toString() || undefined })}
options={[
{ value: "", name: t("settings:tenant.theme.appearance.defaultSystemUi") },
{
value: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
name: t("settings:tenant.theme.appearance.fontFamilies.sansSerif"),
},
{ value: "ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif", name: t("settings:tenant.theme.appearance.fontFamilies.serif") },
{
value: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
name: t("settings:tenant.theme.appearance.fontFamilies.monospace"),
},
{ value: "Inter, sans-serif", name: t("settings:tenant.theme.appearance.fontFamilies.inter") },
{ value: "Geist, sans-serif", name: t("settings:tenant.theme.appearance.fontFamilies.geist") },
{ value: "Outfit, sans-serif", name: t("settings:tenant.theme.appearance.fontFamilies.outfit") },
{ value: "'Open Sans', sans-serif", name: t("settings:tenant.theme.appearance.fontFamilies.openSans") },
{ value: "Roboto, sans-serif", name: t("settings:tenant.theme.appearance.fontFamilies.roboto") },
{ value: "Poppins, sans-serif", name: t("settings:tenant.theme.appearance.fontFamilies.poppins") },
{ value: "Montserrat, sans-serif", name: t("settings:tenant.theme.appearance.fontFamilies.montserrat") },
{ value: "Lato, sans-serif", name: t("settings:tenant.theme.appearance.fontFamilies.lato") },
{ value: "'Source Sans Pro', sans-serif", name: t("settings:tenant.theme.appearance.fontFamilies.sourceSansPro") },
{ value: "Raleway, sans-serif", name: t("settings:tenant.theme.appearance.fontFamilies.raleway") },
{ value: "'Playfair Display', serif", name: t("settings:tenant.theme.appearance.fontFamilies.playfairDisplay") },
{ value: "Merriweather, serif", name: t("settings:tenant.theme.appearance.fontFamilies.merriweather") },
{ value: "'JetBrains Mono', monospace", name: t("settings:tenant.theme.appearance.fontFamilies.jetbrainsMono") },
{ value: "'Fira Code', monospace", name: t("settings:tenant.theme.appearance.fontFamilies.firaCode") },
]}
/>
</div>
{/* Border Radius */}
<div>
<Label className="mb-2 block text-sm font-medium">{t("settings:tenant.theme.appearance.borderRadius")}</Label>
<div className="flex items-center gap-2">
<Input
type="number"
step="0.01"
min={0}
max={2}
value={parseFloat(config.radius?.replace("rem", "") || "0.5")}
onChange={(e) => setConfig({ ...config, radius: `${e.target.value}rem` })}
placeholder="0.5"
className="bg-background w-full"
/>
</div>
</div>
</div>
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-5">
<div className="border-border border-t"></div>
</div>
</div>
{/* Colors Section */}
<SettingSection title={t("settings:tenant.theme.colors.title")} description={t(isUserInDarkMode ? "settings.tenant.theme.colors.modes.dark" : "settings.tenant.theme.colors.modes.light")}>
<div className="space-y-4">
<div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.background")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.background")}
value={getConfigColor("background")}
onChange={(color) => updateConfigColor("background", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("background") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.foreground")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.foreground")}
value={getConfigColor("foreground")}
onChange={(color) => updateConfigColor("foreground", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("foreground") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.primary")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.primary")}
value={getConfigColor("primary")}
onChange={(color) => updateConfigColor("primary", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("primary") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.primaryForeground")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.primaryForeground")}
value={getConfigColor("primaryForeground")}
onChange={(color) => updateConfigColor("primaryForeground", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("primaryForeground") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.secondary")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.secondary")}
value={getConfigColor("secondary")}
onChange={(color) => updateConfigColor("secondary", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("secondary") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.secondaryForeground")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.secondaryForeground")}
value={getConfigColor("secondaryForeground")}
onChange={(color) => updateConfigColor("secondaryForeground", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("secondaryForeground") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.accent")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.accent")}
value={getConfigColor("accent")}
onChange={(color) => updateConfigColor("accent", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("accent") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.accentForeground")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.accentForeground")}
value={getConfigColor("accentForeground")}
onChange={(color) => updateConfigColor("accentForeground", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("accentForeground") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.destructive")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.destructive")}
value={getConfigColor("destructive")}
onChange={(color) => updateConfigColor("destructive", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("destructive") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.destructiveForeground")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.destructiveForeground")}
value={getConfigColor("destructiveForeground")}
onChange={(color) => updateConfigColor("destructiveForeground", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("destructiveForeground") || t("shared.default")}</span>
</div>
</div>
</div>
</div>
<div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.card")}</Label>
<div className="flex items-center gap-2">
<ColorPicker title={t("settings:tenant.theme.tokens.card")} value={getConfigColor("card")} onChange={(color) => updateConfigColor("card", color)} className="size-10" />
<span className="text-muted-foreground text-xs">{getConfigColor("card") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.cardForeground")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.cardForeground")}
value={getConfigColor("cardForeground")}
onChange={(color) => updateConfigColor("cardForeground", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("cardForeground") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.popover")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.popover")}
value={getConfigColor("popover")}
onChange={(color) => updateConfigColor("popover", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("popover") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.popoverForeground")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.popoverForeground")}
value={getConfigColor("popoverForeground")}
onChange={(color) => updateConfigColor("popoverForeground", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("popoverForeground") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.muted")}</Label>
<div className="flex items-center gap-2">
<ColorPicker title={t("settings:tenant.theme.tokens.muted")} value={getConfigColor("muted")} onChange={(color) => updateConfigColor("muted", color)} className="size-10" />
<span className="text-muted-foreground text-xs">{getConfigColor("muted") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.mutedForeground")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.mutedForeground")}
value={getConfigColor("mutedForeground")}
onChange={(color) => updateConfigColor("mutedForeground", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("mutedForeground") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.border")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.border")}
value={getConfigColor("border")}
onChange={(color) => updateConfigColor("border", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("border") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.input")}</Label>
<div className="flex items-center gap-2">
<ColorPicker title={t("settings:tenant.theme.tokens.input")} value={getConfigColor("input")} onChange={(color) => updateConfigColor("input", color)} className="size-10" />
<span className="text-muted-foreground text-xs">{getConfigColor("input") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.ring")}</Label>
<div className="flex items-center gap-2">
<ColorPicker title={t("settings:tenant.theme.tokens.ring")} value={getConfigColor("ring")} onChange={(color) => updateConfigColor("ring", color)} className="size-10" />
<span className="text-muted-foreground text-xs">{getConfigColor("ring") || t("shared.default")}</span>
</div>
</div>
</div>
</div>
</div>
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-5">
<div className="border-border border-t"></div>
</div>
</div>
{/* Sidebar Section */}
<SettingSection
title={t("settings:tenant.theme.sidebar.title")}
description={t("settings:tenant.theme.sidebar.description")}
>
<div className="space-y-6">
{/* Sidebar Settings */}
<div>
<div className="space-y-4">
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.sidebar.style")}</Label>
<InputChoicebox
name="sidebarVariant"
value={config.sidebarVariant || "inset"}
// selectClassName="bg-background"
onChange={(value) => setConfig({ ...config, sidebarVariant: value as "inset" | "sidebar" })}
options={[
{ value: "inset", name: t("settings:tenant.theme.sidebar.styleOptions.inset") },
{ value: "sidebar", name: t("settings:tenant.theme.sidebar.styleOptions.sidebar") },
]}
/>
</div>
{/*
<InputCheckboxInline
name="sidebarDarkMode"
title={t("settings:tenant.theme.sidebar.forceDark")}
value={config.sidebarDarkMode || false}
setValue={(value) => setConfig({ ...config, sidebarDarkMode: value as boolean })}
/> */}
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.sidebar.theme")}</Label>
<InputChoicebox
name="sidebarDarkMode"
value={config.sidebarDarkMode ? "true" : "false"}
onChange={(value) => setConfig({ ...config, sidebarDarkMode: value === "true" })}
options={[
{ value: "false", name: t("shared.light") },
{ value: "true", name: t("shared.dark") },
]}
/>
</div>
</div>
</div>
{/* Sidebar Colors */}
<div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.sidebar")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.sidebar")}
value={getConfigColor("sidebar")}
onChange={(color) => updateConfigColor("sidebar", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("sidebar") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.background")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.sidebarBackground")}
value={getConfigColor("sidebarBackground")}
onChange={(color) => updateConfigColor("sidebarBackground", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("sidebarBackground") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.foreground")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.sidebarForeground")}
value={getConfigColor("sidebarForeground")}
onChange={(color) => updateConfigColor("sidebarForeground", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("sidebarForeground") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.primary")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.sidebarPrimary")}
value={getConfigColor("sidebarPrimary")}
onChange={(color) => updateConfigColor("sidebarPrimary", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("sidebarPrimary") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.primaryForeground")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.sidebarPrimaryForeground")}
value={getConfigColor("sidebarPrimaryForeground")}
onChange={(color) => updateConfigColor("sidebarPrimaryForeground", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("sidebarPrimaryForeground") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.accent")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.sidebarAccent")}
value={getConfigColor("sidebarAccent")}
onChange={(color) => updateConfigColor("sidebarAccent", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("sidebarAccent") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.accentForeground")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.sidebarAccentForeground")}
value={getConfigColor("sidebarAccentForeground")}
onChange={(color) => updateConfigColor("sidebarAccentForeground", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("sidebarAccentForeground") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.border")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.sidebarBorder")}
value={getConfigColor("sidebarBorder")}
onChange={(color) => updateConfigColor("sidebarBorder", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("sidebarBorder") || t("shared.default")}</span>
</div>
</div>
<div>
<Label className="mb-1 block text-xs">{t("settings:tenant.theme.tokens.ring")}</Label>
<div className="flex items-center gap-2">
<ColorPicker
title={t("settings:tenant.theme.tokens.sidebarRing")}
value={getConfigColor("sidebarRing")}
onChange={(color) => updateConfigColor("sidebarRing", color)}
className="size-10"
/>
<span className="text-muted-foreground text-xs">{getConfigColor("sidebarRing") || t("shared.default")}</span>
</div>
</div>
</div>
</div>
</div>
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-1">
<div className=""></div>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="outline" onClick={resetToDefaults}>
{t("shared.reset")}
</Button>
<Button
type="submit"
variant={"default"}
disabled={fetcher.state === "submitting"}
className={cn(fetcher.state === "submitting" && "base-spinner")}
>
{t("shared.save")}
</Button>
</div>
</div>
</fetcher.Form>
{/* CSS Configuration Modal */}
<ModalOrSlide
type="slide"
open={cssDialogOpen}
setOpen={setCssDialogOpen}
title={t("settings:tenant.theme.editor.title")}
description={t("settings:tenant.theme.editor.description")}
size="3xl"
buttons={
<div className="flex justify-end gap-2">
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" asChild>
<Link to="https://tweakcn.com/editor/theme" target="_blank">
<ExternalLinkIcon className="mr-2 h-4 w-4" />
{t("settings:tenant.theme.editor.externalEditor")}
</Link>
</Button>
{/* <Button
type="button"
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(cssEditorValue);
toast.success(t("settings:tenant.theme.editor.cssCopied"));
}}
>
<CopyIcon className="mr-2 h-4 w-4" />
Copy
</Button> */}
<Button type="button" onClick={handleApplyCss} size="sm">
{t("settings:tenant.theme.editor.applyChanges")}
</Button>
</div>
</div>
}
>
<MonacoEditor
className="h-[calc(100vh-180px)] overflow-hidden"
value={cssEditorValue}
onChange={(value) => setCssEditorValue(value || "")}
language="css"
theme="vs-dark"
/>
</ModalOrSlide>
</div>
);
}
Loaded 100 of 5784 files, more files were not shown because too many files have changed in this diff. Show more