Track HiveGrid Cloud planning reference
This commit is contained in:
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()}`} →
|
||||
</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(", ");
|
||||
}
|
||||
+154
@@ -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>
|
||||
);
|
||||
}
|
||||
+28
@@ -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"');
|
||||
});
|
||||
});
|
||||
+37
@@ -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("$");
|
||||
});
|
||||
});
|
||||
+248
@@ -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>
|
||||
);
|
||||
}
|
||||
+40
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
@@ -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>
|
||||
);
|
||||
}
|
||||
+88
@@ -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>
|
||||
);
|
||||
}
|
||||
+63
@@ -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>
|
||||
);
|
||||
}
|
||||
+57
@@ -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>
|
||||
);
|
||||
}
|
||||
+43
@@ -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>
|
||||
);
|
||||
}
|
||||
+65
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+163
@@ -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>
|
||||
);
|
||||
}
|
||||
+82
@@ -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>
|
||||
);
|
||||
}
|
||||
+33
@@ -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>
|
||||
);
|
||||
}
|
||||
+38
@@ -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>");
|
||||
});
|
||||
});
|
||||
+38
@@ -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>
|
||||
);
|
||||
}
|
||||
+118
@@ -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>
|
||||
);
|
||||
}
|
||||
+89
@@ -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
Reference in new issue
Block a user