img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className,
+ )}
+ data-size={size}
+ data-slot="card"
+ {...props}
+ />
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/checkbox.tsx b/apps/react-router/saas-template/app/components/ui/checkbox.tsx
new file mode 100644
index 0000000..5642502
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/checkbox.tsx
@@ -0,0 +1,26 @@
+import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
+import { IconCheck } from "@tabler/icons-react";
+
+import { cn } from "~/lib/utils";
+
+function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
+ return (
+
+
+
+
+
+ );
+}
+
+export { Checkbox };
diff --git a/apps/react-router/saas-template/app/components/ui/collapsible.tsx b/apps/react-router/saas-template/app/components/ui/collapsible.tsx
new file mode 100644
index 0000000..9e9e522
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/collapsible.tsx
@@ -0,0 +1,19 @@
+import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
+
+function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
+ return
;
+}
+
+function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
+ return (
+
+ );
+}
+
+function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
+ return (
+
+ );
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/apps/react-router/saas-template/app/components/ui/command.tsx b/apps/react-router/saas-template/app/components/ui/command.tsx
new file mode 100644
index 0000000..8abacf1
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/command.tsx
@@ -0,0 +1,143 @@
+import { IconSearch } from "@tabler/icons-react";
+import { Command as CommandPrimitive } from "cmdk";
+import type * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+function Command({
+ className,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+ );
+}
+
+function CommandInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ );
+}
+
+function CommandList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandEmpty({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+export {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/dialog.tsx b/apps/react-router/saas-template/app/components/ui/dialog.tsx
new file mode 100644
index 0000000..673a26b
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/dialog.tsx
@@ -0,0 +1,154 @@
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
+import { IconX } from "@tabler/icons-react";
+import type * as React from "react";
+
+import { Button } from "~/components/ui/button";
+import { cn } from "~/lib/utils";
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return ;
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return ;
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return ;
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return ;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ );
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/dropdown-menu.tsx b/apps/react-router/saas-template/app/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..624ee46
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/dropdown-menu.tsx
@@ -0,0 +1,262 @@
+import { Menu as MenuPrimitive } from "@base-ui/react/menu";
+import { IconCheck, IconChevronRight } from "@tabler/icons-react";
+import type * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return ;
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return ;
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return ;
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ );
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return ;
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return ;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ );
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: MenuPrimitive.RadioItem.Props) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ );
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/field.tsx b/apps/react-router/saas-template/app/components/ui/field.tsx
new file mode 100644
index 0000000..468afb5
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/field.tsx
@@ -0,0 +1,245 @@
+/**
+ * biome-ignore-all lint/a11y/useSemanticElements: Shadcn uses `role="group"`
+ * deliberately to avoid confusing it for an actual ` `.
+ */
+import type { VariantProps } from "class-variance-authority";
+import { cva } from "class-variance-authority";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+
+import { Label } from "~/components/ui/label";
+import { Separator } from "~/components/ui/separator";
+import { cn } from "~/lib/utils";
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+ [data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
+ className,
+ )}
+ data-slot="field-set"
+ {...props}
+ />
+ );
+}
+
+function FieldLegend({
+ className,
+ variant = "legend",
+ ...props
+}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
+ return (
+
+ );
+}
+
+function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+const fieldVariants = cva(
+ "data-[invalid=true]:text-destructive gap-3 group/field flex w-full",
+ {
+ defaultVariants: {
+ orientation: "vertical",
+ },
+ variants: {
+ orientation: {
+ horizontal:
+ "flex-row items-center [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ responsive:
+ "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ vertical: "flex-col [&>*]:w-full [&>.sr-only]:w-auto",
+ },
+ },
+ },
+);
+
+function Field({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+ [data-slot=field]]:rounded-md has-[>[data-slot=field]]:border has-data-checked:border-primary has-data-checked:bg-primary/5 *:data-[slot=field]:p-3 group-data-[disabled=true]/field:opacity-50 dark:has-data-checked:bg-primary/10",
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
+ className,
+ )}
+ data-slot="field-label"
+ {...props}
+ />
+ );
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className,
+ )}
+ data-slot="field-description"
+ {...props}
+ />
+ );
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode;
+}) {
+ return (
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+ );
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined> | string[];
+}) {
+ const { t } = useTranslation() as { t: (key: string) => string };
+
+ const content = useMemo(() => {
+ if (children) {
+ return children;
+ }
+
+ if (!errors?.length) {
+ return null;
+ }
+
+ if (errors?.length === 1) {
+ return typeof errors[0] === "string"
+ ? t(errors[0])
+ : t(errors[0]?.message ?? "");
+ }
+
+ return (
+
+ {errors.map((error) =>
+ typeof error === "string" ? (
+ {t(error)}
+ ) : error?.message ? (
+ {t(error.message)}
+ ) : null,
+ )}
+
+ );
+ }, [children, errors, t]);
+
+ if (!content) {
+ return null;
+ }
+
+ return (
+
+ {content}
+
+ );
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/globe.tsx b/apps/react-router/saas-template/app/components/ui/globe.tsx
new file mode 100644
index 0000000..d574f4e
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/globe.tsx
@@ -0,0 +1,142 @@
+/** biome-ignore-all lint/style/noMagicNumbers: The numbers are random numbers,
+ * there is no good description for the numbers.
+ */
+import type { COBEOptions } from "cobe";
+import createGlobe from "cobe";
+import { useMotionValue, useSpring } from "motion/react";
+import { useEffect, useMemo, useRef } from "react";
+
+import { cn } from "~/lib/utils";
+
+const MOVEMENT_DAMPING = 1400;
+
+const GLOBE_CONFIG: COBEOptions = {
+ baseColor: [1, 1, 1],
+ dark: 0,
+ devicePixelRatio: 1,
+ diffuse: 0.4,
+ glowColor: [1, 1, 1],
+ height: 800,
+ mapBrightness: 1.2,
+ mapSamples: 8000,
+ markerColor: [10 / 255, 10 / 255, 10 / 255],
+ markers: [
+ { location: [14.5995, 120.9842], size: 0.03 },
+ { location: [19.076, 72.8777], size: 0.1 },
+ { location: [23.8103, 90.4125], size: 0.05 },
+ { location: [30.0444, 31.2357], size: 0.07 },
+ { location: [39.9042, 116.4074], size: 0.08 },
+ { location: [-23.5505, -46.6333], size: 0.1 },
+ { location: [19.4326, -99.1332], size: 0.1 },
+ { location: [40.7128, -74.006], size: 0.1 },
+ { location: [34.6937, 135.5022], size: 0.05 },
+ { location: [41.0082, 28.9784], size: 0.06 },
+ ],
+ onRender: () => {},
+ phi: 0,
+ theta: 0.3,
+ width: 800,
+};
+
+export function Globe({
+ className,
+ config,
+}: {
+ className?: string;
+ config?: Partial;
+}) {
+ const phi = useRef(0);
+ const width = useRef(0);
+ const canvasRef = useRef(null);
+ const pointerInteracting = useRef(null);
+ const pointerInteractionMovement = useRef(0);
+
+ const mergedConfig: COBEOptions = useMemo(
+ () => ({
+ ...GLOBE_CONFIG,
+ ...config,
+ }),
+ [config],
+ );
+
+ const r = useMotionValue(0);
+ const rs = useSpring(r, {
+ damping: 30,
+ mass: 1,
+ stiffness: 100,
+ });
+
+ const updatePointerInteraction = (value: number | null) => {
+ pointerInteracting.current = value;
+ if (canvasRef.current) {
+ canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab";
+ }
+ };
+
+ const updateMovement = (clientX: number) => {
+ if (pointerInteracting.current !== null) {
+ const delta = clientX - pointerInteracting.current;
+ pointerInteractionMovement.current = delta;
+ r.set(r.get() + delta / MOVEMENT_DAMPING);
+ }
+ };
+
+ useEffect(() => {
+ const onResize = () => {
+ if (canvasRef.current) {
+ width.current = canvasRef.current.offsetWidth;
+ }
+ };
+
+ window.addEventListener("resize", onResize);
+ onResize();
+
+ const devicePixelRatio = mergedConfig.devicePixelRatio ?? 2;
+ // biome-ignore lint/style/noNonNullAssertion: The canvas is guaranteed to be available.
+ const globe = createGlobe(canvasRef.current!, {
+ ...mergedConfig,
+ height: width.current * devicePixelRatio,
+ onRender: (state) => {
+ if (!pointerInteracting.current) phi.current += 0.005;
+ state.phi = phi.current + rs.get();
+ state.width = width.current * devicePixelRatio;
+ state.height = width.current * devicePixelRatio;
+ },
+ width: width.current * devicePixelRatio,
+ });
+
+ setTimeout(() => {
+ if (canvasRef.current) canvasRef.current.style.opacity = "1";
+ }, 0);
+ return () => {
+ globe.destroy();
+ window.removeEventListener("resize", onResize);
+ };
+ }, [rs, mergedConfig]);
+
+ return (
+
+ updateMovement(e.clientX)}
+ onPointerDown={(e) => {
+ pointerInteracting.current = e.clientX;
+ updatePointerInteraction(e.clientX);
+ }}
+ onPointerOut={() => updatePointerInteraction(null)}
+ onPointerUp={() => updatePointerInteraction(null)}
+ onTouchMove={(e) =>
+ e.touches[0] && updateMovement(e.touches[0].clientX)
+ }
+ ref={canvasRef}
+ />
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/components/ui/hover-card.tsx b/apps/react-router/saas-template/app/components/ui/hover-card.tsx
new file mode 100644
index 0000000..0fdfe1d
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/hover-card.tsx
@@ -0,0 +1,49 @@
+import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
+
+import { cn } from "~/lib/utils";
+
+function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
+ return ;
+}
+
+function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
+ return (
+
+ );
+}
+
+function HoverCardContent({
+ className,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 4,
+ ...props
+}: PreviewCardPrimitive.Popup.Props &
+ Pick<
+ PreviewCardPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ );
+}
+
+export { HoverCard, HoverCardTrigger, HoverCardContent };
diff --git a/apps/react-router/saas-template/app/components/ui/input-group.tsx b/apps/react-router/saas-template/app/components/ui/input-group.tsx
new file mode 100644
index 0000000..160b771
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/input-group.tsx
@@ -0,0 +1,170 @@
+/**
+ * biome-ignore-all lint/a11y/useSemanticElements: Shadcn uses `role="group"`
+ * deliberately to avoid confusing it for an actual ` `.
+ */
+import type { VariantProps } from "class-variance-authority";
+import { cva } from "class-variance-authority";
+import type * as React from "react";
+
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+import { Textarea } from "~/components/ui/textarea";
+import { cn } from "~/lib/utils";
+
+function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ [data-align=block-end]]:h-auto has-[>[data-align=block-start]]:h-auto has-[>textarea]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:flex-col has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
+ className,
+ )}
+ data-slot="input-group"
+ role="group"
+ {...props}
+ />
+ );
+}
+
+const inputGroupAddonVariants = cva(
+ "text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
+ {
+ defaultVariants: {
+ align: "inline-start",
+ },
+ variants: {
+ align: {
+ "block-end":
+ "px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
+ "block-start":
+ "px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
+ "inline-end":
+ "pr-2 has-[>button]:mr-[-0.25rem] has-[>kbd]:mr-[-0.15rem] order-last",
+ "inline-start":
+ "pl-2 has-[>button]:ml-[-0.25rem] has-[>kbd]:ml-[-0.15rem] order-first",
+ },
+ },
+ },
+);
+
+function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ return (
+ {
+ if ((event.target as HTMLElement).closest("button")) {
+ return;
+ }
+ event.currentTarget.parentElement?.querySelector("input")?.focus();
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ if ((event.target as HTMLElement).closest("button")) {
+ return;
+ }
+ event.preventDefault();
+ event.currentTarget.parentElement?.querySelector("input")?.focus();
+ }
+ }}
+ role="group"
+ {...props}
+ />
+ );
+}
+
+const inputGroupButtonVariants = cva(
+ "gap-2 text-sm shadow-none flex items-center",
+ {
+ defaultVariants: {
+ size: "xs",
+ },
+ variants: {
+ size: {
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+ "icon-xs":
+ "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
+ sm: "",
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
+ },
+ },
+ },
+);
+
+function InputGroupButton({
+ className,
+ type = "button",
+ variant = "ghost",
+ size = "xs",
+ ...props
+}: Omit
, "size" | "type"> &
+ VariantProps & {
+ type?: "button" | "submit" | "reset";
+ }) {
+ return (
+
+ );
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function InputGroupInput({
+ className,
+ ...props
+}: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+function InputGroupTextarea({
+ className,
+ ...props
+}: React.ComponentProps<"textarea">) {
+ return (
+
+ );
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/input-otp.tsx b/apps/react-router/saas-template/app/components/ui/input-otp.tsx
new file mode 100644
index 0000000..466b031
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/input-otp.tsx
@@ -0,0 +1,90 @@
+import { IconMinus } from "@tabler/icons-react";
+import { OTPInput, OTPInputContext } from "input-otp";
+import * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+function InputOTP({
+ className,
+ containerClassName,
+ ...props
+}: React.ComponentProps & {
+ containerClassName?: string;
+}) {
+ return (
+
+ );
+}
+
+function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function InputOTPSlot({
+ index,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ index: number;
+}) {
+ const inputOTPContext = React.useContext(OTPInputContext);
+ const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
+
+ return (
+
+ {char}
+ {hasFakeCaret && (
+
+ )}
+
+ );
+}
+
+function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
+ return (
+ // biome-ignore lint/a11y/useSemanticElements: can't have children
+ // biome-ignore lint/a11y/useFocusableInteractive: Should NOT be focusable
+
+
+
+ );
+}
+
+export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
diff --git a/apps/react-router/saas-template/app/components/ui/input.tsx b/apps/react-router/saas-template/app/components/ui/input.tsx
new file mode 100644
index 0000000..e4aa019
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import { Input as InputPrimitive } from "@base-ui/react/input";
+import type * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+const inputBaseClass =
+ "dark:bg-input/30 border-input h-9 rounded-md border bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] file:h-7 file:text-sm file:font-medium md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
+const inputFocusClass =
+ "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]";
+const inputErrorClass =
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 aria-invalid:ring-[3px]";
+const inputClassName = cn(inputBaseClass, inputFocusClass, inputErrorClass);
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+export { Input, inputClassName };
diff --git a/apps/react-router/saas-template/app/components/ui/item.tsx b/apps/react-router/saas-template/app/components/ui/item.tsx
new file mode 100644
index 0000000..eadc42c
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/item.tsx
@@ -0,0 +1,203 @@
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import type { VariantProps } from "class-variance-authority";
+import { cva } from "class-variance-authority";
+import type * as React from "react";
+
+import { Separator } from "~/components/ui/separator";
+import { cn } from "~/lib/utils";
+
+function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ // biome-ignore lint/a11y/useSemanticElements: Shadcn uses `role="list"`
+
+ );
+}
+
+function ItemSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+const itemVariants = cva(
+ "[a]:hover:bg-muted rounded-md border text-sm w-full group/item focus-visible:border-ring focus-visible:ring-ring/50 flex items-center flex-wrap outline-none transition-colors duration-100 focus-visible:ring-[3px] [a]:transition-colors",
+ {
+ defaultVariants: {
+ size: "default",
+ variant: "default",
+ },
+ variants: {
+ size: {
+ default: "gap-3.5 px-4 py-3.5",
+ sm: "gap-2.5 px-3 py-2.5",
+ xs: "gap-2 px-2.5 py-2 [[data-slot=dropdown-menu-content]_&]:p-0",
+ },
+ variant: {
+ default: "border-transparent",
+ muted: "bg-muted/50 border-transparent",
+ outline: "border-border",
+ },
+ },
+ },
+);
+
+function Item({
+ className,
+ variant = "default",
+ size = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"div"> & VariantProps) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(itemVariants({ className, size, variant })),
+ },
+ props,
+ ),
+ render,
+ state: {
+ size,
+ slot: "item",
+ variant,
+ },
+ });
+}
+
+const itemMediaVariants = cva(
+ "gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start flex shrink-0 items-center justify-center [&_svg]:pointer-events-none",
+ {
+ defaultVariants: {
+ variant: "default",
+ },
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ icon: "[&_svg:not([class*='size-'])]:size-4",
+ image:
+ "size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
+ },
+ },
+ },
+);
+
+function ItemMedia({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className,
+ )}
+ data-slot="item-description"
+ {...props}
+ />
+ );
+}
+
+function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Item,
+ ItemMedia,
+ ItemContent,
+ ItemActions,
+ ItemGroup,
+ ItemSeparator,
+ ItemTitle,
+ ItemDescription,
+ ItemHeader,
+ ItemFooter,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/label.tsx b/apps/react-router/saas-template/app/components/ui/label.tsx
new file mode 100644
index 0000000..1421402
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/label.tsx
@@ -0,0 +1,19 @@
+/** biome-ignore-all lint/a11y/noLabelWithoutControl: Will be used with input */
+import type * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+
+ );
+}
+
+export { Label };
diff --git a/apps/react-router/saas-template/app/components/ui/light-rays.tsx b/apps/react-router/saas-template/app/components/ui/light-rays.tsx
new file mode 100644
index 0000000..8d3df5b
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/light-rays.tsx
@@ -0,0 +1,171 @@
+/**
+ * biome-ignore-all lint/style/noMagicNumbers: The numbers are random numbers,
+ * there is no good description for the numbers.
+ */
+import { motion } from "motion/react";
+import type { CSSProperties } from "react";
+import { useEffect, useState } from "react";
+
+import { cn } from "~/lib/utils";
+
+interface LightRaysProps extends React.HTMLAttributes {
+ ref?: React.Ref;
+ count?: number;
+ color?: string;
+ blur?: number;
+ speed?: number;
+ length?: string;
+ prefersReducedMotion?: boolean;
+}
+
+type LightRay = {
+ id: string;
+ left: number;
+ rotate: number;
+ width: number;
+ swing: number;
+ delay: number;
+ duration: number;
+ intensity: number;
+};
+
+const createRays = (count: number, cycle: number): LightRay[] => {
+ if (count <= 0) return [];
+
+ return Array.from({ length: count }, (_, index) => {
+ const left = 8 + Math.random() * 84;
+ const rotate = -28 + Math.random() * 56;
+ const width = 160 + Math.random() * 160;
+ const swing = 0.8 + Math.random() * 1.8;
+ const delay = Math.random() * cycle;
+ const duration = cycle * (0.75 + Math.random() * 0.5);
+ const intensity = 0.6 + Math.random() * 0.5;
+
+ return {
+ delay,
+ duration,
+ id: `${index}-${Math.round(left * 10)}`,
+ intensity,
+ left,
+ rotate,
+ swing,
+ width,
+ };
+ });
+};
+
+const Ray = ({
+ left,
+ rotate,
+ width,
+ swing,
+ delay,
+ duration,
+ intensity,
+ prefersReducedMotion,
+}: LightRay & { prefersReducedMotion?: boolean }) => {
+ return (
+
+ );
+};
+
+export function LightRays({
+ className,
+ style,
+ count = 7,
+ color = "rgba(160, 210, 255, 0.2)",
+ blur = 36,
+ speed = 14,
+ length = "70vh",
+ prefersReducedMotion = false,
+ ref,
+ ...props
+}: LightRaysProps) {
+ const [rays, setRays] = useState([]);
+ const cycleDuration = Math.max(speed, 0.1);
+
+ useEffect(() => {
+ setRays(createRays(count, cycleDuration));
+ }, [count, cycleDuration]);
+
+ return (
+
+
+
+
+ {rays.map((ray) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/components/ui/navigation-menu.tsx b/apps/react-router/saas-template/app/components/ui/navigation-menu.tsx
new file mode 100644
index 0000000..9a5a7af
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/navigation-menu.tsx
@@ -0,0 +1,169 @@
+import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu";
+import { IconChevronDown } from "@tabler/icons-react";
+import { cva } from "class-variance-authority";
+
+import { cn } from "~/lib/utils";
+
+function NavigationMenu({
+ className,
+ children,
+ ...props
+}: NavigationMenuPrimitive.Root.Props) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function NavigationMenuList({
+ className,
+ ...props
+}: NavigationMenuPrimitive.List.Props) {
+ return (
+
+ );
+}
+
+function NavigationMenuItem({
+ className,
+ ...props
+}: NavigationMenuPrimitive.Item.Props) {
+ return (
+
+ );
+}
+
+const navigationMenuTriggerStyle = cva(
+ "bg-background hover:bg-muted focus:bg-muted data-open:hover:bg-muted data-open:focus:bg-muted data-open:bg-muted/50 focus-visible:ring-ring/50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted rounded-md px-4 py-2 text-sm font-medium transition-all focus-visible:ring-[3px] focus-visible:outline-1 disabled:opacity-50 group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center disabled:pointer-events-none outline-none",
+);
+
+function NavigationMenuTrigger({
+ className,
+ children,
+ ...props
+}: NavigationMenuPrimitive.Trigger.Props) {
+ return (
+
+ {children}{" "}
+
+
+ );
+}
+
+function NavigationMenuContent({
+ className,
+ ...props
+}: NavigationMenuPrimitive.Content.Props) {
+ return (
+
+ );
+}
+
+function NavigationMenuPositioner({
+ className,
+ side = "bottom",
+ sideOffset = 8,
+ align = "start",
+ alignOffset = 0,
+ ...props
+}: NavigationMenuPrimitive.Positioner.Props) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function NavigationMenuLink({
+ className,
+ ...props
+}: NavigationMenuPrimitive.Link.Props) {
+ return (
+
+ );
+}
+
+function NavigationMenuIndicator({
+ className,
+ ...props
+}: NavigationMenuPrimitive.Icon.Props) {
+ return (
+
+
+
+ );
+}
+
+export {
+ NavigationMenu,
+ NavigationMenuContent,
+ NavigationMenuIndicator,
+ NavigationMenuItem,
+ NavigationMenuLink,
+ NavigationMenuList,
+ NavigationMenuTrigger,
+ navigationMenuTriggerStyle,
+ NavigationMenuPositioner,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/popover.tsx b/apps/react-router/saas-template/app/components/ui/popover.tsx
new file mode 100644
index 0000000..1048325
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/popover.tsx
@@ -0,0 +1,88 @@
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
+import type * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+function Popover({ ...props }: PopoverPrimitive.Root.Props) {
+ return ;
+}
+
+function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
+ return ;
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ ...props
+}: PopoverPrimitive.Popup.Props &
+ Pick<
+ PopoverPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ );
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
+ return (
+
+ );
+}
+
+function PopoverDescription({
+ className,
+ ...props
+}: PopoverPrimitive.Description.Props) {
+ return (
+
+ );
+}
+
+export {
+ Popover,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/radio-group.tsx b/apps/react-router/saas-template/app/components/ui/radio-group.tsx
new file mode 100644
index 0000000..d7b0b30
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/radio-group.tsx
@@ -0,0 +1,37 @@
+import { Radio as RadioPrimitive } from "@base-ui/react/radio";
+import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
+import { IconCircle } from "@tabler/icons-react";
+
+import { cn } from "~/lib/utils";
+
+function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
+ return (
+
+ );
+}
+
+function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
+ return (
+
+
+
+
+
+ );
+}
+
+export { RadioGroup, RadioGroupItem };
diff --git a/apps/react-router/saas-template/app/components/ui/select.tsx b/apps/react-router/saas-template/app/components/ui/select.tsx
new file mode 100644
index 0000000..2eabb71
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/select.tsx
@@ -0,0 +1,216 @@
+import { Select as SelectPrimitive } from "@base-ui/react/select";
+import {
+ IconCheck,
+ IconChevronDown,
+ IconChevronUp,
+ IconSelector,
+} from "@tabler/icons-react";
+import type * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+const Select = SelectPrimitive.Root;
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+
+ );
+}
+
+type SelectValueProps = Omit & {
+ placeholder?: string;
+ children?: SelectPrimitive.Value.Props["children"];
+};
+
+function SelectValue({
+ className,
+ placeholder,
+ children,
+ ...props
+}: SelectValueProps) {
+ return (
+
+ {children ?? ((value) => (value == null ? placeholder : String(value)))}
+
+ );
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default";
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ );
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ );
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ );
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/separator.tsx b/apps/react-router/saas-template/app/components/ui/separator.tsx
new file mode 100644
index 0000000..95245f0
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/separator.tsx
@@ -0,0 +1,23 @@
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
+
+import { cn } from "~/lib/utils";
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/apps/react-router/saas-template/app/components/ui/sheet.tsx b/apps/react-router/saas-template/app/components/ui/sheet.tsx
new file mode 100644
index 0000000..767f96e
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/sheet.tsx
@@ -0,0 +1,132 @@
+import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
+import { IconX } from "@tabler/icons-react";
+import type * as React from "react";
+
+import { Button } from "~/components/ui/button";
+import { cn } from "~/lib/utils";
+
+function Sheet({ ...props }: SheetPrimitive.Root.Props) {
+ return ;
+}
+
+function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
+ return ;
+}
+
+function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
+ return ;
+}
+
+function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
+ return ;
+}
+
+function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
+ return (
+
+ );
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: SheetPrimitive.Popup.Props & {
+ side?: "top" | "right" | "bottom" | "left";
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ );
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
+ return (
+
+ );
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: SheetPrimitive.Description.Props) {
+ return (
+
+ );
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/sidebar.tsx b/apps/react-router/saas-template/app/components/ui/sidebar.tsx
new file mode 100644
index 0000000..7204cc9
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/sidebar.tsx
@@ -0,0 +1,745 @@
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { IconLayoutSidebar } from "@tabler/icons-react";
+import type { VariantProps } from "class-variance-authority";
+import { cva } from "class-variance-authority";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+import { Separator } from "~/components/ui/separator";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "~/components/ui/sheet";
+import { Skeleton } from "~/components/ui/skeleton";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "~/components/ui/tooltip";
+import { useIsMobile } from "~/hooks/use-mobile";
+import { cn } from "~/lib/utils";
+
+const SIDEBAR_COOKIE_NAME = "sidebar_state";
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+const SIDEBAR_WIDTH = "16rem";
+const SIDEBAR_WIDTH_MOBILE = "18rem";
+const SIDEBAR_WIDTH_ICON = "3rem";
+const SIDEBAR_KEYBOARD_SHORTCUT = "b";
+
+type SidebarContextProps = {
+ state: "expanded" | "collapsed";
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ openMobile: boolean;
+ setOpenMobile: (open: boolean) => void;
+ isMobile: boolean;
+ toggleSidebar: () => void;
+};
+
+const SidebarContext = React.createContext(null);
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext);
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.");
+ }
+
+ return context;
+}
+
+function SidebarProvider({
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ defaultOpen?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const isMobile = useIsMobile();
+ const [openMobile, setOpenMobile] = React.useState(false);
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen);
+ const open = openProp ?? _open;
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value;
+ if (setOpenProp) {
+ setOpenProp(openState);
+ } else {
+ _setOpen(openState);
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ /**
+ * biome-ignore lint/suspicious/noDocumentCookie: If Shadcn does it, it's fine.
+ */
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+ },
+ [setOpenProp, open],
+ );
+
+ // Helper to toggle the sidebar.
+ /**
+ * biome-ignore lint/correctness/useExhaustiveDependencies: We need to
+ * include setOpenMobile in the dependencies to ensure that the context value
+ * is updated when the mobile sidebar is opened or closed.
+ */
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
+ }, [isMobile, setOpen, setOpenMobile]);
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault();
+ toggleSidebar();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [toggleSidebar]);
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed";
+
+ /**
+ * biome-ignore lint/correctness/useExhaustiveDependencies: We need to
+ * include setOpenMobile in the dependenciesd to ensure that the context value
+ * is updated when the mobile sidebar is opened or closed.
+ */
+ const contextValue = React.useMemo(
+ () => ({
+ isMobile,
+ open,
+ openMobile,
+ setOpen,
+ setOpenMobile,
+ state,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function Sidebar({
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offExamples",
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ side?: "left" | "right";
+ variant?: "sidebar" | "floating" | "inset";
+ collapsible?: "offExamples" | "icon" | "none";
+}) {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "layout.appSidebar.nav",
+ });
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ );
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+
+ {children}
+
+
+
+ );
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ );
+}
+
+function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+ {
+ onClick?.(event);
+ toggleSidebar();
+ }}
+ size="icon-sm"
+ variant="ghost"
+ {...props}
+ >
+
+ Toggle Sidebar
+
+ );
+}
+
+function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ return (
+
+ );
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarGroupLabel({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(
+ "text-sidebar-foreground/70 ring-sidebar-ring h-8 rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
+ className,
+ ),
+ },
+ props,
+ ),
+ render,
+ state: {
+ sidebar: "group-label",
+ slot: "sidebar-group-label",
+ },
+ });
+}
+
+function SidebarGroupAction({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(
+ "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 w-5 rounded-md p-0 focus-visible:ring-2 [&>svg]:size-4 flex aspect-square items-center justify-center outline-hidden transition-transform [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden group-data-[collapsible=icon]:hidden",
+ className,
+ ),
+ },
+ props,
+ ),
+ render,
+ state: {
+ sidebar: "group-action",
+ slot: "sidebar-group-action",
+ },
+ });
+}
+
+function SidebarGroupContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ );
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+const sidebarMenuButtonVariants = cva(
+ "ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ defaultVariants: {
+ size: "default",
+ variant: "default",
+ },
+ variants: {
+ size: {
+ default: "h-8 text-sm",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
+ sm: "h-7 text-xs",
+ },
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
+ },
+ },
+ },
+);
+
+function SidebarMenuButton({
+ render,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+}: useRender.ComponentProps<"button"> &
+ React.ComponentProps<"button"> & {
+ isActive?: boolean;
+ tooltip?: string | React.ComponentProps;
+ } & VariantProps) {
+ const { isMobile, state } = useSidebar();
+ const comp = useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(sidebarMenuButtonVariants({ size, variant }), className),
+ },
+ props,
+ ),
+ render: !tooltip ? render : TooltipTrigger,
+ state: {
+ active: isActive,
+ sidebar: "menu-button",
+ size,
+ slot: "sidebar-menu-button",
+ },
+ });
+
+ if (!tooltip) {
+ return comp;
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ };
+ }
+
+ return (
+
+ {comp}
+
+
+ );
+}
+
+function SidebarMenuAction({
+ className,
+ render,
+ showOnHover = false,
+ ...props
+}: useRender.ComponentProps<"button"> &
+ React.ComponentProps<"button"> & {
+ showOnHover?: boolean;
+ }) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(
+ "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 aspect-square w-5 rounded-md p-0 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 focus-visible:ring-2 [&>svg]:size-4 flex items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
+ showOnHover &&
+ "peer-data-active/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-open:opacity-100 md:opacity-0",
+ className,
+ ),
+ },
+ props,
+ ),
+ render,
+ state: {
+ sidebar: "menu-action",
+ slot: "sidebar-menu-action",
+ },
+ });
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showIcon?: boolean;
+}) {
+ // Random width between 50 to 90%.
+ const [width] = React.useState(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`;
+ });
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ );
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ );
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+function SidebarMenuSubButton({
+ render,
+ size = "md",
+ isActive = false,
+ className,
+ ...props
+}: useRender.ComponentProps<"a"> &
+ React.ComponentProps<"a"> & {
+ size?: "sm" | "md";
+ isActive?: boolean;
+ }) {
+ return useRender({
+ defaultTagName: "a",
+ props: mergeProps<"a">(
+ {
+ className: cn(
+ "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
+ className,
+ ),
+ },
+ props,
+ ),
+ render,
+ state: {
+ active: isActive,
+ sidebar: "menu-sub-button",
+ size,
+ slot: "sidebar-menu-sub-button",
+ },
+ });
+}
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/skeleton.tsx b/apps/react-router/saas-template/app/components/ui/skeleton.tsx
new file mode 100644
index 0000000..bb6e8f2
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "~/lib/utils";
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/apps/react-router/saas-template/app/components/ui/sonner.tsx b/apps/react-router/saas-template/app/components/ui/sonner.tsx
new file mode 100644
index 0000000..f564956
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/sonner.tsx
@@ -0,0 +1,45 @@
+import {
+ IconAlertOctagon,
+ IconAlertTriangle,
+ IconCircleCheck,
+ IconInfoCircle,
+ IconLoader,
+} from "@tabler/icons-react";
+import type { ToasterProps } from "sonner";
+import { Toaster as Sonner } from "sonner";
+
+import { useColorScheme } from "~/features/color-scheme/use-color-scheme";
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const colorScheme = useColorScheme();
+
+ return (
+ ,
+ info: ,
+ loading: ,
+ success: ,
+ warning: ,
+ }}
+ style={
+ {
+ "--border-radius": "var(--radius)",
+ "--normal-bg": "var(--popover)",
+ "--normal-border": "var(--border)",
+ "--normal-text": "var(--popover-foreground)",
+ } as React.CSSProperties
+ }
+ theme={colorScheme as ToasterProps["theme"]}
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ );
+};
+
+export { Toaster };
diff --git a/apps/react-router/saas-template/app/components/ui/spinner.tsx b/apps/react-router/saas-template/app/components/ui/spinner.tsx
new file mode 100644
index 0000000..ee29dbb
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/spinner.tsx
@@ -0,0 +1,22 @@
+import { IconLoader } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+
+import { cn } from "~/lib/utils";
+
+function Spinner({
+ className,
+ ...props
+}: React.ComponentProps) {
+ const { t } = useTranslation("translation");
+
+ return (
+
+ );
+}
+
+export { Spinner };
diff --git a/apps/react-router/saas-template/app/components/ui/switch.tsx b/apps/react-router/saas-template/app/components/ui/switch.tsx
new file mode 100644
index 0000000..23fb7ce
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/switch.tsx
@@ -0,0 +1,30 @@
+import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
+
+import { cn } from "~/lib/utils";
+
+function Switch({
+ className,
+ size = "default",
+ ...props
+}: SwitchPrimitive.Root.Props & {
+ size?: "sm" | "default";
+}) {
+ return (
+
+
+
+ );
+}
+
+export { Switch };
diff --git a/apps/react-router/saas-template/app/components/ui/table.tsx b/apps/react-router/saas-template/app/components/ui/table.tsx
new file mode 100644
index 0000000..0c3de0d
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/table.tsx
@@ -0,0 +1,119 @@
+import type * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ );
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ );
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ );
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className,
+ )}
+ data-slot="table-footer"
+ {...props}
+ />
+ );
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+
+ );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+
+ );
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ );
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/apps/react-router/saas-template/app/components/ui/tabs.tsx b/apps/react-router/saas-template/app/components/ui/tabs.tsx
new file mode 100644
index 0000000..ae33931
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/tabs.tsx
@@ -0,0 +1,81 @@
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
+import type { VariantProps } from "class-variance-authority";
+import { cva } from "class-variance-authority";
+
+import { cn } from "~/lib/utils";
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+
+ );
+}
+
+const tabsListVariants = cva(
+ "rounded-lg p-[3px] group-data-horizontal/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
+ {
+ defaultVariants: {
+ variant: "default",
+ },
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ },
+);
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps) {
+ return (
+
+ );
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ );
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+
+ );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
diff --git a/apps/react-router/saas-template/app/components/ui/textarea.tsx b/apps/react-router/saas-template/app/components/ui/textarea.tsx
new file mode 100644
index 0000000..f17f77d
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import type * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ );
+}
+
+export { Textarea };
diff --git a/apps/react-router/saas-template/app/components/ui/tooltip.tsx b/apps/react-router/saas-template/app/components/ui/tooltip.tsx
new file mode 100644
index 0000000..18148e4
--- /dev/null
+++ b/apps/react-router/saas-template/app/components/ui/tooltip.tsx
@@ -0,0 +1,68 @@
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
+
+import { cn } from "~/lib/utils";
+
+function TooltipProvider({
+ delay = 0,
+ ...props
+}: TooltipPrimitive.Provider.Props) {
+ return (
+
+ );
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ return (
+
+
+
+ );
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ children,
+ ...props
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/apps/react-router/saas-template/app/entry.client.tsx b/apps/react-router/saas-template/app/entry.client.tsx
new file mode 100644
index 0000000..6508477
--- /dev/null
+++ b/apps/react-router/saas-template/app/entry.client.tsx
@@ -0,0 +1,33 @@
+import i18next from "i18next";
+import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector";
+import Fetch from "i18next-fetch-backend";
+import { StrictMode, startTransition } from "react";
+import { hydrateRoot } from "react-dom/client";
+import { I18nextProvider, initReactI18next } from "react-i18next";
+import { HydratedRouter } from "react-router/dom";
+
+async function hydrate() {
+ await i18next
+ .use(initReactI18next)
+ .use(Fetch)
+ .use(I18nextBrowserLanguageDetector)
+ .init({
+ backend: { loadPath: "/api/locales/{{lng}}/{{ns}}" },
+ detection: { caches: [], order: ["htmlTag"] },
+ fallbackLng: "en",
+ interpolation: { escapeValue: false },
+ });
+
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+
+ ,
+ );
+ });
+}
+
+hydrate();
diff --git a/apps/react-router/saas-template/app/entry.server.tsx b/apps/react-router/saas-template/app/entry.server.tsx
new file mode 100644
index 0000000..d80f411
--- /dev/null
+++ b/apps/react-router/saas-template/app/entry.server.tsx
@@ -0,0 +1,159 @@
+import crypto from "node:crypto";
+import { PassThrough } from "node:stream";
+import { contentSecurity } from "@nichtsam/helmet/content";
+import { createReadableStreamFromReadable } from "@react-router/node";
+import { isbot } from "isbot";
+import type { RenderToPipeableStreamOptions } from "react-dom/server";
+import { renderToPipeableStream } from "react-dom/server";
+import { I18nextProvider } from "react-i18next";
+import type { EntryContext, RouterContextProvider } from "react-router";
+import { ServerRouter } from "react-router";
+
+import { getInstance } from "./features/localization/i18next-middleware.server";
+import { getEnv, init } from "./utils/env.server";
+import { NonceProvider } from "./utils/nonce-provider";
+
+init();
+global.ENV = getEnv();
+
+export const streamTimeout = 5000;
+
+const oneSecond = 1000;
+const nonceLength = 16;
+const MODE = process.env.NODE_ENV ?? "development";
+
+let mockServerInitialized = false;
+
+async function initializeMockServer() {
+ if (mockServerInitialized) {
+ return;
+ }
+
+ if (process.env.MOCKS === "true") {
+ const { supabaseHandlers } = await import("~/test/mocks/handlers/supabase");
+ const { resendHandlers } = await import("~/test/mocks/handlers/resend");
+ const { stripeHandlers } = await import("~/test/mocks/handlers/stripe");
+ const { startMockServer } = await import("~/test/mocks/server");
+ startMockServer([
+ ...supabaseHandlers,
+ ...resendHandlers,
+ ...stripeHandlers,
+ ]);
+ }
+
+ mockServerInitialized = true;
+}
+
+export default async function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ entryContext: EntryContext,
+ routerContext: RouterContextProvider,
+) {
+ await initializeMockServer();
+
+ // Generate a cryptographically random nonce for this request
+ const nonce = crypto.randomBytes(nonceLength).toString("hex");
+
+ return new Promise((resolve, reject) => {
+ let shellRendered = false;
+
+ const userAgent = request.headers.get("user-agent");
+
+ // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
+ // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
+ const readyOption: keyof RenderToPipeableStreamOptions =
+ (userAgent && isbot(userAgent)) || entryContext.isSpaMode
+ ? "onAllReady"
+ : "onShellReady";
+
+ let timeoutId: ReturnType | undefined = setTimeout(
+ () => abort(),
+ streamTimeout + oneSecond,
+ );
+
+ const { pipe, abort } = renderToPipeableStream(
+
+
+
+
+ ,
+ {
+ nonce,
+ [readyOption]() {
+ shellRendered = true;
+ const body = new PassThrough({
+ final(callback) {
+ clearTimeout(timeoutId);
+ timeoutId = undefined;
+ callback();
+ },
+ });
+ const stream = createReadableStreamFromReadable(body);
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ // Configure Content Security Policy with the nonce
+ contentSecurity(responseHeaders, {
+ contentSecurityPolicy: {
+ directives: {
+ fetch: {
+ // Allow WebSocket connections in development for HMR
+ "connect-src": [
+ MODE === "development" ? "ws:" : undefined,
+ "'self'",
+ ],
+ "font-src": ["'self'"],
+ "frame-src": ["'self'"],
+ "img-src": [
+ "'self'",
+ "data:",
+ MODE === "test" ? "blob:" : undefined,
+ ],
+ // Script sources with nonce and strict-dynamic
+ "script-src": [
+ "'strict-dynamic'",
+ "'self'",
+ `'nonce-${nonce}'`,
+ ],
+ // Inline event handlers with nonce
+ "script-src-attr": [`'nonce-${nonce}'`],
+ },
+ },
+ // Report-only in dev/test, enforce in production
+ reportOnly: MODE !== "production",
+ },
+ crossOriginEmbedderPolicy: false,
+ });
+
+ resolve(
+ new Response(stream, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ }),
+ );
+
+ pipe(body);
+ },
+ onError(error: unknown) {
+ const internalServerErrorStatusCode = 500;
+ responseStatusCode = internalServerErrorStatusCode;
+ // Log streaming rendering errors from inside the shell. Don't log
+ // errors encountered during initial shell rendering since they'll
+ // reject and get logged in handleDocumentRequest.
+ if (shellRendered) {
+ console.error(error);
+ }
+ },
+ onShellError(error: unknown) {
+ reject(error as Error);
+ },
+ },
+ );
+ });
+}
diff --git a/apps/react-router/saas-template/app/features/billing/billing-action.server.ts b/apps/react-router/saas-template/app/features/billing/billing-action.server.ts
new file mode 100644
index 0000000..bbf4aa9
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/billing-action.server.ts
@@ -0,0 +1,291 @@
+import { data, redirect } from "react-router";
+import { z } from "zod";
+
+import { organizationMembershipContext } from "../organizations/organizations-middleware.server";
+import { updateOrganizationInDatabaseById } from "../organizations/organizations-model.server";
+import {
+ CANCEL_SUBSCRIPTION_INTENT,
+ KEEP_CURRENT_SUBSCRIPTION_INTENT,
+ OPEN_CHECKOUT_SESSION_INTENT,
+ RESUME_SUBSCRIPTION_INTENT,
+ SWITCH_SUBSCRIPTION_INTENT,
+ UPDATE_BILLING_EMAIL_INTENT,
+ VIEW_INVOICES_INTENT,
+} from "./billing-constants";
+import { extractBaseUrl } from "./billing-helpers.server";
+import {
+ cancelSubscriptionSchema,
+ keepCurrentSubscriptionSchema,
+ openCustomerCheckoutSessionSchema,
+ resumeSubscriptionSchema,
+ switchSubscriptionSchema,
+ updateBillingEmailSchema,
+ viewInvoicesSchema,
+} from "./billing-schemas";
+import {
+ createStripeCancelSubscriptionSession,
+ createStripeCheckoutSession,
+ createStripeCustomerPortalSession,
+ createStripeSwitchPlanSession,
+ keepCurrentSubscription,
+ resumeStripeSubscription,
+ updateStripeCustomer,
+} from "./stripe-helpers.server";
+import {
+ retrieveStripePriceFromDatabaseByLookupKey,
+ retrieveStripePriceWithProductFromDatabaseByLookupKey,
+} from "./stripe-prices-model.server";
+import { updateStripeSubscriptionInDatabaseById } from "./stripe-subscription-model.server";
+import { deleteStripeSubscriptionScheduleFromDatabaseById } from "./stripe-subscription-schedule-model.server";
+import type { Route } from ".react-router/types/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/settings+/+types/billing";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { combineHeaders } from "~/utils/combine-headers.server";
+import { getIsDataWithResponseInit } from "~/utils/get-is-data-with-response-init.server";
+import { requestToUrl } from "~/utils/get-search-parameter-from-request.server";
+import { badRequest, conflict, forbidden } from "~/utils/http-responses.server";
+import { createToastHeaders } from "~/utils/toast.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+const schema = z.discriminatedUnion("intent", [
+ cancelSubscriptionSchema,
+ keepCurrentSubscriptionSchema,
+ openCustomerCheckoutSessionSchema,
+ resumeSubscriptionSchema,
+ switchSubscriptionSchema,
+ updateBillingEmailSchema,
+ viewInvoicesSchema,
+]);
+
+export async function billingAction({
+ context,
+ params,
+ request,
+}: Route.ActionArgs) {
+ try {
+ const { organization, headers, role, user } = context.get(
+ organizationMembershipContext,
+ );
+ const i18n = getInstance(context);
+
+ const result = await validateFormData(request, schema);
+
+ if (!result.success) {
+ return result.response;
+ }
+
+ const body = result.data;
+
+ if (role === OrganizationMembershipRole.member) {
+ return forbidden();
+ }
+
+ const baseUrl = extractBaseUrl(requestToUrl(request));
+
+ switch (body.intent) {
+ case CANCEL_SUBSCRIPTION_INTENT: {
+ if (!organization.stripeCustomerId) {
+ throw new Error("Organization has no Stripe customer ID");
+ }
+
+ if (!organization.stripeSubscriptions[0]) {
+ throw new Error("Organization has no Stripe subscriptions");
+ }
+
+ const cancelSession = await createStripeCancelSubscriptionSession({
+ baseUrl,
+ customerId: organization.stripeCustomerId,
+ organizationSlug: params.organizationSlug,
+ subscriptionId: organization.stripeSubscriptions[0].stripeId,
+ });
+
+ return redirect(cancelSession.url);
+ }
+
+ case KEEP_CURRENT_SUBSCRIPTION_INTENT: {
+ const currentSubscription = organization.stripeSubscriptions[0];
+
+ if (!currentSubscription) {
+ throw new Error("Organization has no Stripe subscriptions");
+ }
+
+ if (currentSubscription.schedule) {
+ const schedule = await keepCurrentSubscription(
+ currentSubscription.schedule.stripeId,
+ );
+
+ await deleteStripeSubscriptionScheduleFromDatabaseById(schedule.id);
+ }
+
+ const toast = await createToastHeaders({
+ title: i18n.t(
+ "billing:billingPage.pendingDowngradeBanner.successTitle",
+ ),
+ type: "success",
+ });
+
+ return data(
+ { result: undefined },
+ { headers: combineHeaders(toast, headers) },
+ );
+ }
+
+ case OPEN_CHECKOUT_SESSION_INTENT: {
+ if (organization.stripeSubscriptions[0]) {
+ return conflict();
+ }
+
+ const price =
+ await retrieveStripePriceWithProductFromDatabaseByLookupKey(
+ body.lookupKey,
+ );
+
+ if (!price) {
+ throw new Error("Price not found");
+ }
+
+ if (organization._count.memberships > price.product.maxSeats) {
+ return conflict();
+ }
+
+ const checkoutSession = await createStripeCheckoutSession({
+ baseUrl,
+ customerEmail: organization.billingEmail,
+ customerId: organization.stripeCustomerId,
+ organizationId: organization.id,
+ organizationSlug: organization.slug,
+ priceId: price.stripeId,
+ purchasedById: user.id,
+ seatsUsed: organization._count.memberships,
+ });
+
+ // biome-ignore lint/style/noNonNullAssertion: Checkout sessions always have a URL
+ return redirect(checkoutSession.url!);
+ }
+
+ case RESUME_SUBSCRIPTION_INTENT: {
+ const currentSubscription = organization.stripeSubscriptions[0];
+
+ if (!currentSubscription) {
+ throw new Error("Organization has no Stripe subscriptions");
+ }
+
+ if (!organization.stripeSubscriptions[0]) {
+ throw new Error("Organization has no Stripe subscriptions");
+ }
+
+ const subscription = await resumeStripeSubscription(
+ organization.stripeSubscriptions[0].stripeId,
+ );
+
+ if (
+ subscription.cancel_at_period_end !==
+ currentSubscription.cancelAtPeriodEnd
+ ) {
+ await updateStripeSubscriptionInDatabaseById({
+ id: subscription.id,
+ subscription: { cancelAtPeriodEnd: false },
+ });
+ }
+
+ const toast = await createToastHeaders({
+ title: i18n.t(
+ "billing:billingPage.cancelAtPeriodEndBanner.resumeSuccessTitle",
+ ),
+ type: "success",
+ });
+
+ return data(
+ { result: undefined },
+ { headers: combineHeaders(toast, headers) },
+ );
+ }
+
+ case SWITCH_SUBSCRIPTION_INTENT: {
+ if (!organization.stripeCustomerId) {
+ throw new Error("Organization has no Stripe customer ID");
+ }
+
+ if (!organization.stripeSubscriptions[0]) {
+ throw new Error("Organization has no Stripe subscriptions");
+ }
+
+ const price = await retrieveStripePriceFromDatabaseByLookupKey(
+ body.lookupKey,
+ );
+
+ if (!price) {
+ return badRequest({ message: "Price not found" });
+ }
+
+ if (!organization.stripeSubscriptions[0].items[0]) {
+ throw new Error("Organization has no Stripe subscription items");
+ }
+
+ const portalSession = await createStripeSwitchPlanSession({
+ baseUrl,
+ customerId: organization.stripeCustomerId,
+ newPriceId: price.stripeId,
+ organizationSlug: params.organizationSlug,
+ quantity: organization._count.memberships,
+ subscriptionId: organization.stripeSubscriptions[0].stripeId,
+ subscriptionItemId:
+ organization.stripeSubscriptions[0].items[0].stripeId,
+ });
+
+ return redirect(portalSession.url);
+ }
+
+ case UPDATE_BILLING_EMAIL_INTENT: {
+ if (!organization.stripeCustomerId) {
+ throw new Error("Organization has no Stripe customer ID");
+ }
+
+ if (body.billingEmail !== organization.billingEmail) {
+ await updateStripeCustomer({
+ customerEmail: body.billingEmail,
+ customerId: organization.stripeCustomerId,
+ customerName: organization.name,
+ });
+
+ await updateOrganizationInDatabaseById({
+ id: organization.id,
+ organization: { billingEmail: body.billingEmail },
+ });
+ }
+
+ const toast = await createToastHeaders({
+ title: i18n.t(
+ "billing:billingPage.updateBillingEmailModal.successTitle",
+ ),
+ type: "success",
+ });
+
+ return data(
+ { result: undefined },
+ { headers: combineHeaders(toast, headers) },
+ );
+ }
+
+ case VIEW_INVOICES_INTENT: {
+ if (!organization.stripeCustomerId) {
+ throw new Error("Organization has no Stripe customer ID");
+ }
+
+ const portalSession = await createStripeCustomerPortalSession({
+ baseUrl,
+ customerId: organization.stripeCustomerId,
+ organizationSlug: params.organizationSlug,
+ });
+
+ return redirect(portalSession.url);
+ }
+ }
+ } catch (error) {
+ if (getIsDataWithResponseInit(error)) {
+ return error;
+ }
+
+ throw error;
+ }
+}
diff --git a/apps/react-router/saas-template/app/features/billing/billing-constants.ts b/apps/react-router/saas-template/app/features/billing/billing-constants.ts
new file mode 100644
index 0000000..dab1a22
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/billing-constants.ts
@@ -0,0 +1,46 @@
+export const CANCEL_SUBSCRIPTION_INTENT = "cancelSubscription";
+export const KEEP_CURRENT_SUBSCRIPTION_INTENT = "keepCurrentSubscription";
+export const OPEN_CHECKOUT_SESSION_INTENT = "openCheckoutSession";
+export const RESUME_SUBSCRIPTION_INTENT = "resumeSubscription";
+export const SWITCH_SUBSCRIPTION_INTENT = "switchSubscription";
+export const UPDATE_BILLING_EMAIL_INTENT = "updateBillingEmail";
+export const VIEW_INVOICES_INTENT = "viewInvoices";
+
+export const priceLookupKeysByTierAndInterval = {
+ high: {
+ annual: "annual_business_planv2",
+ monthly: "monthly_business_planv2",
+ },
+ low: {
+ annual: "annual_hobby_planv2",
+ monthly: "monthly_hobby_planv2",
+ },
+ mid: {
+ annual: "annual_startup_planv2",
+ monthly: "monthly_startup_planv2",
+ },
+} as const;
+
+export type Tier = keyof typeof priceLookupKeysByTierAndInterval;
+export type Interval = keyof (typeof priceLookupKeysByTierAndInterval)[Tier];
+
+export const monthlyLookupKeys = [
+ priceLookupKeysByTierAndInterval.low.monthly,
+ priceLookupKeysByTierAndInterval.mid.monthly,
+ priceLookupKeysByTierAndInterval.high.monthly,
+] as const;
+
+export const annualLookupKeys = [
+ priceLookupKeysByTierAndInterval.low.annual,
+ priceLookupKeysByTierAndInterval.mid.annual,
+ priceLookupKeysByTierAndInterval.high.annual,
+] as const;
+
+export const allLookupKeys = [
+ ...monthlyLookupKeys,
+ ...annualLookupKeys,
+] as const;
+
+export type LookupKey = (typeof allLookupKeys)[number];
+
+export const allTiers = ["low", "mid", "high"] as const;
diff --git a/apps/react-router/saas-template/app/features/billing/billing-factories.server.ts b/apps/react-router/saas-template/app/features/billing/billing-factories.server.ts
new file mode 100644
index 0000000..ae34f05
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/billing-factories.server.ts
@@ -0,0 +1,426 @@
+import { faker } from "@faker-js/faker";
+import { createId } from "@paralleldrive/cuid2";
+
+import { allLookupKeys, allTiers } from "./billing-constants";
+import type {
+ StripePrice,
+ StripeProduct,
+ StripeSubscription,
+ StripeSubscriptionItem,
+ StripeSubscriptionSchedule,
+ StripeSubscriptionSchedulePhase,
+} from "~/generated/client";
+import { StripePriceInterval } from "~/generated/client";
+import type { DeepPartial, Factory } from "~/utils/types";
+
+export const getRandomTier = () => faker.helpers.arrayElement(allTiers);
+export const getRandomLookupKey = () =>
+ faker.helpers.arrayElement(allLookupKeys);
+
+/* Base factories */
+
+export const createPopulatedStripeProduct: Factory = ({
+ stripeId = `prod_${createId()}`,
+ active = true,
+ name = faker.commerce.productName(),
+ maxSeats = faker.number.int({ max: 100, min: 1 }),
+} = {}) => ({ active, maxSeats, name, stripeId });
+
+/**
+ * Creates a Stripe price with populated values.
+ *
+ * @param priceParams - StripePrice params to create price with.
+ * @returns A populated Stripe price with given params.
+ */
+export const createPopulatedStripePrice: Factory = ({
+ lookupKey = `${faker.word.noun()}_${faker.word.noun()}_${faker.word.noun()}`,
+ stripeId = `price_${createId()}`,
+ active = true,
+ currency = "usd",
+ productId = `prod_${createId()}`,
+ unitAmount = faker.number.int({ max: 50_000, min: 500 }),
+ interval = faker.helpers.arrayElement([
+ StripePriceInterval.month,
+ StripePriceInterval.year,
+ ]),
+} = {}) => ({
+ active,
+ currency,
+ interval,
+ lookupKey,
+ productId,
+ stripeId,
+ unitAmount,
+});
+
+/**
+ * Creates a Stripe subscription schedule with populated values.
+ *
+ * @param scheduleParams - StripeSubscriptionSchedule params to create the schedule with.
+ * @returns A populated Stripe subscription schedule with given params.
+ */
+export const createPopulatedStripeSubscriptionSchedule: Factory<
+ StripeSubscriptionSchedule
+> = ({
+ stripeId = `sub_sched_${createId()}`,
+ subscriptionId = createPopulatedStripeSubscription().stripeId,
+ created = faker.date.past({ years: 1 }),
+ currentPhaseStart = faker.date.past({ years: 1 }),
+ currentPhaseEnd = currentPhaseStart
+ ? faker.date.future({ refDate: currentPhaseStart, years: 1 })
+ : null,
+} = {}) => ({
+ created,
+ currentPhaseEnd,
+ currentPhaseStart,
+ stripeId,
+ subscriptionId,
+});
+
+/**
+ * Creates a Stripe subscription schedule phase with populated values.
+ *
+ * @param phaseParams - StripeSubscriptionSchedulePhase params to create the phase with.
+ * @returns A populated Stripe subscription schedule phase with given params.
+ */
+export const createPopulatedStripeSubscriptionSchedulePhase: Factory<
+ StripeSubscriptionSchedulePhase
+> = ({
+ id = createId(),
+ scheduleId = createPopulatedStripeSubscriptionSchedule().stripeId,
+ startDate = faker.date.past({ years: 1 }),
+ endDate = faker.date.future({ refDate: startDate, years: 1 }),
+ priceId = `price_${createId()}`,
+ quantity = faker.number.int({ max: 100, min: 1 }),
+} = {}) => ({
+ endDate,
+ id,
+ priceId,
+ quantity,
+ scheduleId,
+ startDate,
+});
+
+/**
+ * Creates a Stripe subscription item with populated values.
+ *
+ * @param subscriptionItemParams - StripeSubscriptionItem params to create subscription item with.
+ * @returns A populated Stripe subscription item with given params.
+ */
+export const createPopulatedStripeSubscriptionItem: Factory<
+ StripeSubscriptionItem
+> = ({
+ stripeId = `si_${createId()}`,
+ stripeSubscriptionId = `sub_${createId()}`,
+ currentPeriodEnd = faker.date.future({ years: 1 }),
+ currentPeriodStart = faker.date.past({ refDate: currentPeriodEnd, years: 1 }),
+ priceId = `price_${createId()}`,
+} = {}) => ({
+ currentPeriodEnd,
+ currentPeriodStart,
+ priceId,
+ stripeId,
+ stripeSubscriptionId,
+});
+
+/**
+ * Creates a Stripe subscription with populated values.
+ *
+ * @param subscriptionParams - StripeSubscription params to create subscription with.
+ * @returns A populated Stripe subscription with given params.
+ */
+export const createPopulatedStripeSubscription: Factory = ({
+ stripeId = `sub_${createId()}`,
+ organizationId = createId(),
+ purchasedById = createId(),
+ created = faker.date.past({ years: 1 }),
+ cancelAtPeriodEnd = false,
+ status = "active",
+} = {}) => ({
+ cancelAtPeriodEnd,
+ created,
+ organizationId,
+ purchasedById,
+ status,
+ stripeId,
+});
+
+/* Compound Factories */
+
+export type StripeProductWithPrices = StripeProduct & {
+ prices: StripePrice[];
+};
+
+/**
+ * Creates a Stripe product with its associated prices.
+ *
+ * @param overrides - Optional parameters to customize the product and prices.
+ * @param overrides.prices - Optional array of price override values.
+ * @param overrides.productOverrides - Optional product override values.
+ * @returns A populated Stripe product with its associated prices.
+ */
+export function createStripeProductWithPrices(
+ overrides: DeepPartial = {},
+): StripeProductWithPrices {
+ const { prices: pricesOverride, ...productOverrides } = overrides;
+
+ // Create the base product
+ const product = createPopulatedStripeProduct(productOverrides);
+
+ // Handle price overrides, defaulting to two prices if none given
+ const prices = Array.isArray(pricesOverride)
+ ? pricesOverride.map((priceOvr) =>
+ createPopulatedStripePrice({
+ ...priceOvr,
+ productId: product.stripeId,
+ }),
+ )
+ : [
+ createPopulatedStripePrice({ productId: product.stripeId }),
+ createPopulatedStripePrice({ productId: product.stripeId }),
+ ];
+
+ return { ...product, ...overrides, prices };
+}
+
+export type StripePriceWithProduct = StripePrice & {
+ product: StripeProduct;
+};
+
+/**
+ * Creates a Stripe price with its associated product.
+ *
+ * @param params - Optional parameters to customize the price and product.
+ * @param params.product - Optional product override values.
+ * @param params.priceOverrides - Optional price override values.
+ * @returns A populated Stripe price with its associated product.
+ */
+export function createPopulatedStripePriceWithProduct(
+ overrides: DeepPartial = {},
+): StripePriceWithProduct {
+ const { product: productOverrides, ...priceOverrides } = overrides;
+ const product = createPopulatedStripeProduct(productOverrides);
+ const price = createPopulatedStripePrice({
+ lookupKey: getRandomLookupKey(),
+ ...priceOverrides,
+ productId: product.stripeId,
+ });
+ return { ...price, product };
+}
+
+export type StripeSubscriptionItemWithPriceAndProduct =
+ StripeSubscriptionItem & {
+ price: StripePriceWithProduct;
+ };
+
+/**
+ * Creates a Stripe subscription item with its associated price and product.
+ *
+ * @param params - Optional parameters to customize the subscription item.
+ * @param params.price - Optional price (with product) override values.
+ * @param params.itemOverrides - Optional subscription item override values.
+ * @returns A populated Stripe subscription item with its associated price and product.
+ */
+export function createPopulatedStripeSubscriptionItemWithPriceAndProduct(
+ overrides: DeepPartial = {},
+): StripeSubscriptionItemWithPriceAndProduct {
+ const { price: priceOverrides, ...itemOverrides } = overrides;
+ const price = createPopulatedStripePriceWithProduct(priceOverrides);
+ const item = createPopulatedStripeSubscriptionItem({
+ ...itemOverrides,
+ priceId: price.stripeId,
+ });
+ return { ...item, price };
+}
+
+export type StripeSubscriptionWithItemsAndPriceAndProduct =
+ StripeSubscription & {
+ items: StripeSubscriptionItemWithPriceAndProduct[];
+ };
+
+/**
+ * Creates a Stripe subscription with its associated subscription items, prices and products.
+ *
+ * @param params - Optional parameters to customize the subscription.
+ * @param params.items - Optional array of subscription items with prices and products.
+ * @param params.subscriptionOverrides - Optional subscription override values.
+ * @returns A populated Stripe subscription with all associated data.
+ */
+export function createPopulatedStripeSubscriptionWithItemsAndPriceAndProduct(
+ overrides: DeepPartial = {},
+): StripeSubscriptionWithItemsAndPriceAndProduct {
+ const { items: itemsOverride, ...subscriptionBaseOverride } = overrides;
+
+ // Base subscription (just top-level fields)
+ const baseSubscription = createPopulatedStripeSubscription(
+ subscriptionBaseOverride,
+ );
+
+ // Items: if provided (even empty), map overrides; else default one
+ const items = Array.isArray(itemsOverride)
+ ? itemsOverride.map((itemOverride) =>
+ createPopulatedStripeSubscriptionItemWithPriceAndProduct(itemOverride),
+ )
+ : [createPopulatedStripeSubscriptionItemWithPriceAndProduct()];
+
+ return { ...baseSubscription, ...overrides, items };
+}
+
+export type StripeSubscriptionSchedulePhaseWithPrice =
+ StripeSubscriptionSchedulePhase & {
+ price: StripePrice;
+ };
+
+export function createPopulatedStripeSubscriptionSchedulePhaseWithPrice(
+ overrides: DeepPartial = {},
+): StripeSubscriptionSchedulePhaseWithPrice {
+ const { price: priceOverrides, ...phaseOverrides } = overrides;
+ // generate the full price (no product)
+ const price = createPopulatedStripePrice(priceOverrides);
+ // then build the "base" phase, syncing priceId
+ const phase = createPopulatedStripeSubscriptionSchedulePhase({
+ ...phaseOverrides,
+ priceId: price.stripeId,
+ });
+ return { ...phase, price };
+}
+
+export type StripeSubscriptionScheduleWithPhasesAndPrice =
+ StripeSubscriptionSchedule & {
+ phases: StripeSubscriptionSchedulePhaseWithPrice[];
+ };
+
+/**
+ * Creates a Stripe subscription schedule with its associated phases.
+ *
+ * @param params - Optional parameters to customize the subscription schedule.
+ * @param params.stripeId - Optional stripe ID for the schedule.
+ * @param params.phases - Optional array of phase override values.
+ * @param params.scheduleOverrides - Optional schedule override values.
+ * @returns A populated Stripe subscription schedule with its associated phases.
+ */
+export function createPopulatedStripeSubscriptionScheduleWithPhasesAndPrice(
+ overrides: DeepPartial = {},
+): StripeSubscriptionScheduleWithPhasesAndPrice {
+ const base = createPopulatedStripeSubscriptionSchedule({
+ ...overrides,
+ stripeId: overrides.stripeId ?? undefined,
+ });
+
+ // if caller passed phases (even empty), map overrides; otherwise default one
+ const phasesOverride = overrides.phases;
+ const phases = Array.isArray(phasesOverride)
+ ? phasesOverride.map((phOvr) =>
+ createPopulatedStripeSubscriptionSchedulePhaseWithPrice({
+ ...phOvr,
+ scheduleId: base.stripeId,
+ }),
+ )
+ : [
+ createPopulatedStripeSubscriptionSchedulePhaseWithPrice({
+ scheduleId: base.stripeId,
+ }),
+ ];
+
+ return { ...base, ...overrides, phases };
+}
+
+export type StripeSubscriptionItemWithPrice = StripeSubscriptionItem & {
+ price: StripePrice;
+};
+
+export type StripeSubscriptionWithItemsAndPrice = StripeSubscription & {
+ items: StripeSubscriptionItemWithPrice[];
+};
+
+/**
+ * Creates a Stripe subscription with its associated subscription items and their prices.
+ *
+ * @param params - Optional parameters to customize the subscription.
+ * @param params.items - Optional array of subscription items with prices.
+ * @param params.subscriptionOverrides - Optional subscription override values.
+ * @returns A populated Stripe subscription with its associated items and their prices.
+ */
+export function createPopulatedStripeSubscriptionWithItemsAndPrice(
+ overrides: DeepPartial = {},
+): StripeSubscriptionWithItemsAndPrice {
+ const { items: itemsOverride, ...subscriptionBaseOverride } = overrides;
+
+ const baseSubscription = createPopulatedStripeSubscription(
+ subscriptionBaseOverride,
+ );
+
+ const items = Array.isArray(itemsOverride)
+ ? itemsOverride.map((itemOverride) => {
+ const { price: priceOverrides, ...itemBaseOverrides } =
+ itemOverride || {};
+ const price = createPopulatedStripePrice({
+ lookupKey: getRandomLookupKey(),
+ ...priceOverrides,
+ });
+ const item = createPopulatedStripeSubscriptionItem({
+ ...itemBaseOverrides,
+ priceId: price.stripeId,
+ });
+ return { ...item, price };
+ })
+ : [
+ (() => {
+ const price = createPopulatedStripePrice({
+ lookupKey: getRandomLookupKey(),
+ });
+ const item = createPopulatedStripeSubscriptionItem({
+ priceId: price.stripeId,
+ });
+ return { ...item, price };
+ })(),
+ ];
+
+ return { ...baseSubscription, ...overrides, items };
+}
+
+export type StripeSubscriptionWithScheduleAndItemsWithPriceAndProduct =
+ StripeSubscription & {
+ schedule: StripeSubscriptionScheduleWithPhasesAndPrice;
+ items: StripeSubscriptionItemWithPriceAndProduct[];
+ };
+
+/**
+ * Creates a complete Stripe subscription with schedule, items, prices and products.
+ *
+ * @param params - Optional parameters to customize the subscription.
+ * @param params.items - Optional array of subscription items with prices and products.
+ * @param params.schedule - Optional subscription schedule with phases.
+ * @param params.subscriptionOverrides - Optional subscription override values.
+ * @returns A populated Stripe subscription with all associated data.
+ */
+export function createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct(
+ overrides: DeepPartial = {},
+): StripeSubscriptionWithScheduleAndItemsWithPriceAndProduct {
+ // Pull out array overrides so they don't go to the base subscription factory
+ const {
+ items: itemsOverride,
+ schedule: scheduleOverride,
+ ...subscriptionBaseOverride
+ } = overrides;
+
+ // Base subscription (just top-level fields)
+ const baseSubscription = createPopulatedStripeSubscription(
+ subscriptionBaseOverride,
+ );
+
+ // Items: if provided (even empty), map overrides; else default one
+ const items = Array.isArray(itemsOverride)
+ ? itemsOverride.map((itemOverride) =>
+ createPopulatedStripeSubscriptionItemWithPriceAndProduct(itemOverride),
+ )
+ : [createPopulatedStripeSubscriptionItemWithPriceAndProduct()];
+
+ // Schedule: same logic as items
+ const schedule =
+ createPopulatedStripeSubscriptionScheduleWithPhasesAndPrice(
+ scheduleOverride,
+ );
+
+ return { ...baseSubscription, ...overrides, items, schedule };
+}
diff --git a/apps/react-router/saas-template/app/features/billing/billing-helpers.server.test.ts b/apps/react-router/saas-template/app/features/billing/billing-helpers.server.test.ts
new file mode 100644
index 0000000..4b9ff11
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/billing-helpers.server.test.ts
@@ -0,0 +1,396 @@
+import { describe, expect, test } from "vitest";
+
+import {
+ createOrganizationWithMembershipsAndSubscriptions,
+ createPopulatedOrganization,
+} from "../organizations/organizations-factories.server";
+import { priceLookupKeysByTierAndInterval } from "./billing-constants";
+import {
+ createPopulatedStripePriceWithProduct,
+ createPopulatedStripeSubscriptionItem,
+ createPopulatedStripeSubscriptionSchedule,
+ createPopulatedStripeSubscriptionScheduleWithPhasesAndPrice,
+ createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct,
+ createStripeProductWithPrices,
+} from "./billing-factories.server";
+import type { ProductsForBillingPage } from "./billing-helpers.server";
+import {
+ extractBaseUrl,
+ getCreateSubscriptionModalProps,
+ mapStripeSubscriptionDataToBillingPageProps,
+} from "./billing-helpers.server";
+import type { BillingPageProps } from "./billing-page";
+import { StripePriceInterval } from "~/generated/client";
+
+describe.skipIf(!!process.env.CI)(
+ "mapStripeSubscriptionDataToBillingPageProps()",
+ () => {
+ test("given: an active paid monthly plan, should: return correct billing props", () => {
+ const now = new Date("2025-06-01T00:00:00.000Z");
+ const subscription =
+ createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct(
+ {
+ cancelAtPeriodEnd: false,
+ items: [
+ {
+ price: createPopulatedStripePriceWithProduct({
+ interval: StripePriceInterval.month,
+ lookupKey: priceLookupKeysByTierAndInterval.mid.monthly,
+ product: { maxSeats: 10 },
+ unitAmount: 2000,
+ }),
+ ...createPopulatedStripeSubscriptionItem({
+ currentPeriodEnd: new Date("2025-06-14T00:00:00.000Z"),
+ currentPeriodStart: new Date("2025-05-15T00:00:00.000Z"),
+ }),
+ },
+ ],
+ organizationId: "org-123",
+ schedule: {
+ phases: [], // No future phases - regular active subscription
+ },
+ status: "active",
+ },
+ );
+ const organization = createOrganizationWithMembershipsAndSubscriptions({
+ memberCount: 4,
+ stripeSubscriptions: [subscription],
+ });
+
+ const actual = mapStripeSubscriptionDataToBillingPageProps({
+ now,
+ organization,
+ });
+ const expected: Omit = {
+ billingEmail: organization.billingEmail,
+ cancelAtPeriodEnd: false,
+ cancelOrModifySubscriptionModalProps: {
+ canCancelSubscription: true,
+ currentTier: "mid",
+ currentTierInterval: "monthly",
+ },
+ currentInterval: "monthly",
+ currentMonthlyRatePerUser: 20,
+ currentPeriodEnd: new Date("2025-06-14T00:00:00.000Z"),
+ currentSeats: 4,
+ currentTier: "mid",
+ isEnterprisePlan: false,
+ isOnFreeTrial: false,
+ maxSeats: 10,
+ organizationSlug: organization.slug,
+ projectedTotal: 80,
+ subscriptionStatus: "active",
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test('given: a subscription cancelled at period end but still ongoing, should: mark status "active"', () => {
+ const now = new Date("2025-06-10T00:00:00.000Z");
+ const subscription =
+ createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct(
+ {
+ cancelAtPeriodEnd: true,
+ items: [
+ {
+ price: createPopulatedStripePriceWithProduct({
+ interval: StripePriceInterval.month,
+ lookupKey: priceLookupKeysByTierAndInterval.high.monthly,
+ product: { maxSeats: 25 },
+ unitAmount: 5000,
+ }),
+ ...createPopulatedStripeSubscriptionItem({
+ currentPeriodEnd: new Date("2025-06-30T00:00:00.000Z"),
+ currentPeriodStart: new Date("2025-06-01T00:00:00.000Z"),
+ }),
+ },
+ ],
+ organizationId: "org-456",
+ schedule: {
+ phases: [], // No future phases - subscription will end at period end
+ },
+ status: "active",
+ },
+ );
+ const organization = createOrganizationWithMembershipsAndSubscriptions({
+ memberCount: 8,
+ stripeSubscriptions: [subscription],
+ });
+
+ const actual = mapStripeSubscriptionDataToBillingPageProps({
+ now,
+ organization,
+ });
+ const expected: Omit = {
+ billingEmail: organization.billingEmail,
+ cancelAtPeriodEnd: true,
+ cancelOrModifySubscriptionModalProps: {
+ canCancelSubscription: false,
+ currentTier: "high",
+ currentTierInterval: "monthly",
+ },
+ currentInterval: "monthly",
+ currentMonthlyRatePerUser: 50,
+ currentPeriodEnd: new Date("2025-06-30T00:00:00.000Z"),
+ currentSeats: 8,
+ currentTier: "high",
+ isEnterprisePlan: false,
+ isOnFreeTrial: false,
+ maxSeats: 25,
+ organizationSlug: organization.slug,
+ projectedTotal: 400,
+ subscriptionStatus: "active",
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test('given: a subscription cancelled at period end and it ran out, should: mark status "paused"', () => {
+ const now = new Date("2025-06-10T00:00:00.000Z");
+ const subscription =
+ createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct(
+ {
+ cancelAtPeriodEnd: true,
+ items: [
+ {
+ price: createPopulatedStripePriceWithProduct({
+ interval: StripePriceInterval.month,
+ lookupKey: priceLookupKeysByTierAndInterval.high.monthly,
+ product: { maxSeats: 25 },
+ unitAmount: 5000,
+ }),
+ ...createPopulatedStripeSubscriptionItem({
+ currentPeriodEnd: new Date("2025-06-09T00:00:00.000Z"),
+ currentPeriodStart: new Date("2025-06-01T00:00:00.000Z"),
+ }),
+ },
+ ],
+ organizationId: "org-456",
+ schedule: {
+ phases: [], // No future phases - subscription has ended
+ },
+ status: "active",
+ },
+ );
+ const organization = createOrganizationWithMembershipsAndSubscriptions({
+ memberCount: 8,
+ stripeSubscriptions: [subscription],
+ });
+
+ const actual = mapStripeSubscriptionDataToBillingPageProps({
+ now,
+ organization,
+ });
+ const expected: Omit = {
+ billingEmail: organization.billingEmail,
+ cancelAtPeriodEnd: true,
+ cancelOrModifySubscriptionModalProps: {
+ canCancelSubscription: false,
+ currentTier: "high",
+ currentTierInterval: "monthly",
+ },
+ currentInterval: "monthly",
+ currentMonthlyRatePerUser: 50,
+ currentPeriodEnd: new Date("2025-06-09T00:00:00.000Z"),
+ currentSeats: 8,
+ currentTier: "high",
+ isEnterprisePlan: false,
+ isOnFreeTrial: false,
+ maxSeats: 25,
+ organizationSlug: organization.slug,
+ projectedTotal: 400,
+ subscriptionStatus: "paused",
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a subscription still in free trial, should: flag isOnFreeTrial true", () => {
+ const now = new Date("2025-01-10T00:00:00.000Z");
+ const organization = createOrganizationWithMembershipsAndSubscriptions({
+ memberCount: 2,
+ organization: createPopulatedOrganization({
+ createdAt: new Date("2024-12-29T00:00:00.000Z"),
+ }),
+ stripeSubscriptions: [],
+ });
+
+ const actual = mapStripeSubscriptionDataToBillingPageProps({
+ now,
+ organization,
+ });
+
+ const expected: Omit = {
+ billingEmail: organization.billingEmail,
+ cancelAtPeriodEnd: false,
+ cancelOrModifySubscriptionModalProps: {
+ canCancelSubscription: false,
+ currentTier: "high",
+ currentTierInterval: "monthly",
+ },
+ currentInterval: "monthly",
+ currentMonthlyRatePerUser: 85,
+ currentPeriodEnd: organization.trialEnd,
+ currentSeats: 2,
+ currentTier: "high",
+ isEnterprisePlan: false,
+ isOnFreeTrial: true,
+ maxSeats: 25,
+ organizationSlug: organization.slug,
+ projectedTotal: 170,
+ subscriptionStatus: "active",
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a subscription with a pending downgrade, should: return correct billing props", () => {
+ const now = new Date("2025-06-15T00:00:00.000Z");
+ const subscriptionId =
+ createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct()
+ .stripeId;
+ const subscriptionScheduleId =
+ createPopulatedStripeSubscriptionSchedule().stripeId;
+
+ // 1) Start with a live, high-tier subscription
+ const subscription = {
+ ...createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct(
+ {
+ cancelAtPeriodEnd: false,
+ items: [
+ {
+ price: createPopulatedStripePriceWithProduct({
+ interval: StripePriceInterval.month,
+ lookupKey: priceLookupKeysByTierAndInterval.high.monthly,
+ product: { maxSeats: 25 },
+ unitAmount: 6000,
+ }),
+ ...createPopulatedStripeSubscriptionItem({
+ currentPeriodEnd: new Date("2025-06-30T00:00:00.000Z"),
+ currentPeriodStart: new Date("2025-05-01T00:00:00.000Z"),
+ }),
+ },
+ ],
+ organizationId: "org-789",
+ status: "active",
+ stripeId: subscriptionId,
+ },
+ ),
+ schedule: createPopulatedStripeSubscriptionScheduleWithPhasesAndPrice({
+ // deep‐override exactly the two phases you care about
+ phases: [
+ {
+ endDate: new Date("2025-06-30T00:00:00.000Z"),
+ price: {
+ lookupKey: priceLookupKeysByTierAndInterval.high.monthly,
+ unitAmount: 6000,
+ },
+ quantity: 5,
+ scheduleId: subscriptionScheduleId,
+ startDate: new Date("2025-05-01T00:00:00.000Z"),
+ },
+ {
+ endDate: new Date("2025-07-30T00:00:00.000Z"),
+ price: {
+ lookupKey: priceLookupKeysByTierAndInterval.low.monthly,
+ unitAmount: 2000,
+ },
+ quantity: 2,
+ scheduleId: subscriptionScheduleId,
+ startDate: new Date("2025-06-30T00:00:00.000Z"),
+ },
+ ],
+ // force the same IDs you generated above
+ stripeId: subscriptionScheduleId,
+ subscriptionId,
+ }),
+ };
+
+ const organization = createOrganizationWithMembershipsAndSubscriptions({
+ memberCount: 5,
+ stripeSubscriptions: [subscription],
+ });
+
+ const actual = mapStripeSubscriptionDataToBillingPageProps({
+ now,
+ organization,
+ });
+ const expected: Omit = {
+ billingEmail: organization.billingEmail,
+ cancelAtPeriodEnd: false,
+ cancelOrModifySubscriptionModalProps: {
+ canCancelSubscription: true,
+ currentTier: "high",
+ currentTierInterval: "monthly",
+ },
+ currentInterval: "monthly",
+ currentMonthlyRatePerUser: 60,
+ currentPeriodEnd: new Date("2025-06-30T00:00:00.000Z"),
+ currentSeats: 5,
+ currentTier: "high",
+ isEnterprisePlan: false,
+ isOnFreeTrial: false,
+ maxSeats: 25,
+ organizationSlug: organization.slug,
+ pendingChange: {
+ pendingChangeDate: new Date("2025-06-30T00:00:00.000Z"),
+ pendingInterval: "monthly",
+ pendingTier: "low",
+ },
+ projectedTotal: 300,
+ subscriptionStatus: "active",
+ };
+
+ expect(actual).toEqual(expected);
+ });
+ },
+);
+
+describe("extractBaseUrl()", () => {
+ test("given: a request URL, should: return the base URL", () => {
+ const url = new URL("https://example.com/some/path?query=param");
+
+ const actual = extractBaseUrl(url);
+ const expected = "http://example.com";
+
+ expect(actual).toEqual(expected);
+ });
+});
+
+describe("getCreateSubscriptionModalProps()", () => {
+ test("should compute modal props from org and products", () => {
+ const organization = createOrganizationWithMembershipsAndSubscriptions({
+ memberCount: 3,
+ stripeSubscriptions: [],
+ });
+ const products = [
+ createStripeProductWithPrices({ maxSeats: 1 }),
+ createStripeProductWithPrices({ maxSeats: 10 }),
+ createStripeProductWithPrices({ maxSeats: 25 }),
+ ];
+
+ const actual = getCreateSubscriptionModalProps(organization, products);
+ expect(actual).toEqual({
+ createSubscriptionModalProps: {
+ currentSeats: 3,
+ planLimits: { high: 25, low: 1, mid: 10 },
+ },
+ });
+ });
+
+ test("should handle empty products", () => {
+ const organization = createOrganizationWithMembershipsAndSubscriptions({
+ memberCount: 2,
+ stripeSubscriptions: [],
+ });
+ const products: ProductsForBillingPage = [];
+
+ const actual = getCreateSubscriptionModalProps(organization, products);
+ expect(actual).toEqual({
+ createSubscriptionModalProps: {
+ currentSeats: 2,
+ planLimits: { high: 0, low: 0, mid: 0 },
+ },
+ });
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/billing/billing-helpers.server.ts b/apps/react-router/saas-template/app/features/billing/billing-helpers.server.ts
new file mode 100644
index 0000000..2ef617a
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/billing-helpers.server.ts
@@ -0,0 +1,191 @@
+import type { OrganizationWithMembershipsAndSubscriptions } from "../onboarding/onboarding-helpers.server";
+import type { Interval, Tier } from "./billing-constants";
+import type { StripeSubscriptionSchedulePhaseWithPrice } from "./billing-factories.server";
+import { getTierAndIntervalForLookupKey } from "./billing-helpers";
+import type { BillingPageProps } from "./billing-page";
+import type { CancelOrModifySubscriptionModalContentProps } from "./cancel-or-modify-subscription-modal-content";
+import type { CreateSubscriptionModalContentProps } from "./create-subscription-modal-content";
+import type { retrieveProductsFromDatabaseByPriceLookupKeys } from "./stripe-product-model.server";
+import type { retrieveLatestStripeSubscriptionWithActiveScheduleAndPhasesByOrganizationId } from "./stripe-subscription-model.server";
+import { StripeSubscriptionStatus } from "~/generated/client";
+
+const cancellableSubscriptionStatuses: StripeSubscriptionStatus[] = [
+ StripeSubscriptionStatus.active,
+ StripeSubscriptionStatus.trialing,
+ StripeSubscriptionStatus.past_due,
+ StripeSubscriptionStatus.paused,
+] as const;
+
+export type StripeSubscriptionData = NonNullable<
+ Awaited<
+ ReturnType<
+ typeof retrieveLatestStripeSubscriptionWithActiveScheduleAndPhasesByOrganizationId
+ >
+ >
+>;
+
+export function mapStripeSubscriptionDataToBillingPageProps({
+ organization,
+ now,
+}: {
+ organization: OrganizationWithMembershipsAndSubscriptions;
+ now: Date;
+}): Omit {
+ const subscription = organization.stripeSubscriptions[0];
+
+ if (!subscription) {
+ return {
+ billingEmail: organization.billingEmail,
+ cancelAtPeriodEnd: false,
+ cancelOrModifySubscriptionModalProps: {
+ canCancelSubscription: false,
+ currentTier: "high",
+ currentTierInterval: "monthly",
+ },
+ currentInterval: "monthly",
+ currentMonthlyRatePerUser: 85,
+ currentPeriodEnd: organization.trialEnd,
+ currentSeats: organization._count.memberships,
+ currentTier: "high",
+ isEnterprisePlan: false,
+ isOnFreeTrial: true,
+ maxSeats: 25,
+ organizationSlug: organization.slug,
+ projectedTotal: Number((85 * organization._count.memberships).toFixed(2)),
+ subscriptionStatus: "active",
+ };
+ }
+
+ const items = subscription.items;
+
+ // 1. Determine the end of the current billing period by taking the max timestamp
+ const currentPeriodEnd = new Date(
+ Math.max(...items.map((item) => item.currentPeriodEnd.getTime())),
+ );
+
+ // 2. Use the first item to derive price, tier, and seats
+ // biome-ignore lint/style/noNonNullAssertion: check above ensures for null values
+ const { price } = items[0]!;
+
+ // 3. Parse max seats from metadata.max_seats (string or number)
+ const rawMaxSeats = price.product.maxSeats;
+ const maxSeats =
+ typeof rawMaxSeats === "string"
+ ? Number.parseInt(rawMaxSeats, 10)
+ : typeof rawMaxSeats === "number"
+ ? rawMaxSeats
+ : 1;
+ const currentSeats = organization._count.memberships;
+
+ // 4. Compute the per-user rate in dollars
+ const cents = price.unitAmount;
+ const currentMonthlyRatePerUser = cents / 100;
+
+ // 5. Humanize the tier name (capitalize lookupKey prefix)
+ const currentTier = getTierAndIntervalForLookupKey(price.lookupKey).tier;
+
+ // 6. Determine subscriptionStatus
+ let subscriptionStatus: "active" | "inactive" | "paused";
+ if (subscription.cancelAtPeriodEnd && now > currentPeriodEnd) {
+ subscriptionStatus = "paused";
+ } else if (
+ subscription.status === "active" ||
+ subscription.status === "trialing"
+ ) {
+ subscriptionStatus = "active";
+ } else {
+ subscriptionStatus = "inactive";
+ }
+
+ // 7. Projected total = per-user rate × seats (rounded to 2 decimal places)
+ const projectedTotal = Number(
+ (currentMonthlyRatePerUser * organization._count.memberships).toFixed(2),
+ );
+
+ // 8. Cancel or modify subscription modal props
+ const { tier, interval } = getTierAndIntervalForLookupKey(price.lookupKey);
+ const cancelOrModifySubscriptionModalProps: CancelOrModifySubscriptionModalContentProps =
+ {
+ canCancelSubscription:
+ !subscription.cancelAtPeriodEnd &&
+ cancellableSubscriptionStatuses.includes(subscription.status),
+ currentTier: tier,
+ currentTierInterval: interval,
+ };
+
+ // 9. Pending change
+ // 9.1) Grab the upcoming schedule (if any)
+ const schedule = subscription.schedule;
+ let nextPhase: StripeSubscriptionSchedulePhaseWithPrice | undefined;
+ if (schedule) {
+ nextPhase = schedule.phases.find(
+ (p) => p.startDate.getTime() > now.getTime(),
+ );
+ }
+
+ // 9.2) If there’s a nextPhase, derive its price & quantity
+ let pendingTier: Tier | undefined;
+ let pendingInterval: Interval | undefined;
+ if (nextPhase) {
+ const { tier, interval } = getTierAndIntervalForLookupKey(
+ nextPhase.price.lookupKey,
+ );
+ pendingTier = tier;
+ pendingInterval = interval;
+ }
+
+ return {
+ billingEmail: organization.billingEmail,
+ cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
+ cancelOrModifySubscriptionModalProps,
+ currentInterval: interval,
+ currentMonthlyRatePerUser,
+ currentPeriodEnd,
+ currentSeats,
+ currentTier,
+ isEnterprisePlan: false,
+ isOnFreeTrial: false,
+ maxSeats,
+ organizationSlug: organization.slug,
+ pendingChange: nextPhase
+ ? {
+ pendingChangeDate: nextPhase.startDate,
+ // biome-ignore lint/style/noNonNullAssertion: check above ensures for null values
+ pendingInterval: pendingInterval!,
+ // biome-ignore lint/style/noNonNullAssertion: check above ensures for null values
+ pendingTier: pendingTier!,
+ }
+ : undefined,
+ projectedTotal,
+ subscriptionStatus,
+ };
+}
+
+/**
+ * Extracts the base URL from a request URL.
+ *
+ * @param requestUrl - The request URL.
+ * @returns The base URL.
+ */
+export const extractBaseUrl = (url: URL) =>
+ `${process.env.NODE_ENV === "production" ? "https:" : "http:"}//${url.host}`;
+
+export type ProductsForBillingPage = Awaited<
+ ReturnType
+>;
+
+export function getCreateSubscriptionModalProps(
+ organization: OrganizationWithMembershipsAndSubscriptions,
+ products: ProductsForBillingPage,
+): { createSubscriptionModalProps: CreateSubscriptionModalContentProps } {
+ const [low = 0, mid = 0, high = 0] = products
+ .map(({ maxSeats }) => maxSeats)
+ .toSorted((a, b) => a - b);
+
+ return {
+ createSubscriptionModalProps: {
+ currentSeats: organization._count.memberships,
+ planLimits: { high, low, mid },
+ },
+ };
+}
diff --git a/apps/react-router/saas-template/app/features/billing/billing-helpers.test.ts b/apps/react-router/saas-template/app/features/billing/billing-helpers.test.ts
new file mode 100644
index 0000000..e597e8d
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/billing-helpers.test.ts
@@ -0,0 +1,30 @@
+// tests/billing-helpers.test.ts
+import { describe, expect, test } from "vitest";
+
+import type { Interval, Tier } from "./billing-constants";
+import { priceLookupKeysByTierAndInterval } from "./billing-constants";
+import { getTierAndIntervalForLookupKey } from "./billing-helpers";
+
+describe("getTierAndIntervalForLookupKey()", () => {
+ const validCases: [string, Tier, Interval][] = [
+ [priceLookupKeysByTierAndInterval.low.monthly, "low", "monthly"],
+ [priceLookupKeysByTierAndInterval.low.annual, "low", "annual"],
+ [priceLookupKeysByTierAndInterval.mid.monthly, "mid", "monthly"],
+ [priceLookupKeysByTierAndInterval.mid.annual, "mid", "annual"],
+ [priceLookupKeysByTierAndInterval.high.monthly, "high", "monthly"],
+ [priceLookupKeysByTierAndInterval.high.annual, "high", "annual"],
+ ];
+
+ test.each(
+ validCases,
+ )('given lookupKey="%s", returns { tier: "%s", interval: "%s" }', (lookupKey, tier, interval) => {
+ const actual = getTierAndIntervalForLookupKey(lookupKey);
+ expect(actual).toEqual({ interval, tier });
+ });
+
+ test("unknown lookupKey throws an “Invalid lookup key” error", () => {
+ expect(() => getTierAndIntervalForLookupKey("not-a-real-key")).toThrow(
+ /Invalid lookup key/,
+ );
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/billing/billing-helpers.ts b/apps/react-router/saas-template/app/features/billing/billing-helpers.ts
new file mode 100644
index 0000000..06f7125
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/billing-helpers.ts
@@ -0,0 +1,31 @@
+// src/billing-helpers.ts
+import type { Interval, Tier } from "./billing-constants";
+import { priceLookupKeysByTierAndInterval } from "./billing-constants";
+
+/**
+ * Given one of your lookup‐keys (e.g. 'monthly_hobby_planv2'),
+ * returns its associated tier and interval, or throws if the key is not found.
+ *
+ * @param lookupKey - The lookup key to look up.
+ * @returns An object with `tier` and `interval`.
+ * @throws If no entry matches the given `lookupKey`.
+ */
+export function getTierAndIntervalForLookupKey(lookupKey: string): {
+ tier: Tier;
+ interval: Interval;
+} {
+ for (const [tier, intervals] of Object.entries(
+ priceLookupKeysByTierAndInterval,
+ ) as [Tier, (typeof priceLookupKeysByTierAndInterval)[Tier]][]) {
+ for (const [interval, key] of Object.entries(intervals) as [
+ Interval,
+ string,
+ ][]) {
+ if (key === lookupKey) {
+ return { interval, tier };
+ }
+ }
+ }
+
+ throw new Error(`Invalid lookup key: ${lookupKey}`);
+}
diff --git a/apps/react-router/saas-template/app/features/billing/billing-page.test.tsx b/apps/react-router/saas-template/app/features/billing/billing-page.test.tsx
new file mode 100644
index 0000000..7894560
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/billing-page.test.tsx
@@ -0,0 +1,481 @@
+import { faker } from "@faker-js/faker";
+import { href } from "react-router";
+import { describe, expect, test } from "vitest";
+
+import { getRandomTier } from "./billing-factories.server";
+import type { BillingPageProps } from "./billing-page";
+import { BillingPage } from "./billing-page";
+import {
+ createRoutesStub,
+ render,
+ screen,
+ userEvent,
+} from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const path = "/organizations/:organizationSlug/settings/billing";
+
+const createProps: Factory = ({
+ billingEmail = faker.internet.email(),
+ cancelAtPeriodEnd = false,
+ cancelOrModifySubscriptionModalProps = {
+ canCancelSubscription: true,
+ currentTier: "high" as const,
+ currentTierInterval: "annual" as const,
+ },
+ createSubscriptionModalProps = {
+ currentSeats: 1,
+ planLimits: {
+ high: 25,
+ low: 1,
+ mid: 10,
+ },
+ },
+ currentInterval = "monthly",
+ currentMonthlyRatePerUser = faker.number.int({ max: 50, min: 5 }),
+ currentPeriodEnd = faker.date.future(),
+ currentSeats = faker.number.int({ max: 50, min: 1 }),
+ currentTier = getRandomTier(),
+ isCancellingSubscription = false,
+ isEnterprisePlan = false,
+ isKeepingCurrentSubscription = false,
+ isOnFreeTrial = false,
+ isResumingSubscription = false,
+ isViewingInvoices = false,
+ maxSeats = faker.number.int({ max: 200, min: currentSeats }),
+ organizationSlug = faker.string.uuid(),
+ pendingChange,
+ projectedTotal = currentMonthlyRatePerUser * maxSeats,
+ subscriptionStatus = "active",
+} = {}) => ({
+ billingEmail,
+ cancelAtPeriodEnd,
+ cancelOrModifySubscriptionModalProps,
+ createSubscriptionModalProps,
+ currentInterval,
+ currentMonthlyRatePerUser,
+ currentPeriodEnd,
+ currentSeats,
+ currentTier,
+ isCancellingSubscription,
+ isEnterprisePlan,
+ isKeepingCurrentSubscription,
+ isOnFreeTrial,
+ isResumingSubscription,
+ isViewingInvoices,
+ maxSeats,
+ organizationSlug,
+ pendingChange,
+ projectedTotal,
+ subscriptionStatus,
+});
+
+describe("BillingPage component", () => {
+ test("given: any props, should: render a heading and a description", () => {
+ const props = createProps();
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ expect(
+ screen.getByRole("heading", { level: 2, name: /billing/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/manage your billing information/i),
+ ).toBeInTheDocument();
+ });
+
+ test("given: the user is on a free trial plan, should: show an alert banner with the end date of the free trial and a button to enter their payment information", () => {
+ const props = createProps({
+ currentPeriodEnd: new Date("2025-02-12T00:00:00.000Z"),
+ isOnFreeTrial: true,
+ });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ expect(
+ screen.getByText(/your organization is currently on a free trial./i),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/your free trial will end on february 12, 2025/i),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /add payment information/i }),
+ ).toBeInTheDocument();
+ });
+
+ test("given: a current monthly rate per user, seats, tier name and projected total, should: show plan details, seats info, projected total, and management buttons", () => {
+ const props = createProps({
+ currentMonthlyRatePerUser: 10,
+ currentPeriodEnd: new Date("2025-03-15T00:00:00.000Z"),
+ currentSeats: 3,
+ currentTier: "mid",
+ maxSeats: 5,
+ projectedTotal: 10 * 5,
+ });
+
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+ render( );
+
+ // "Plan" heading must be an
+ expect(
+ screen.getByRole("heading", { level: 3, name: /your plan/i }),
+ ).toBeInTheDocument();
+
+ // Tier name and rate
+ expect(screen.getByText(/current plan/i)).toBeInTheDocument();
+ expect(screen.getByText(/^startup$/i)).toBeInTheDocument();
+ expect(screen.getByText(/\$10/i)).toBeInTheDocument();
+ expect(screen.getByText(/per user billed monthly/i)).toBeInTheDocument();
+ // One button for mobile and one for desktop. In a real browser, one of
+ // the two button will be hidden using `display: none`.
+ expect(
+ screen.getAllByRole("button", { name: /manage plan/i }),
+ ).toHaveLength(2);
+
+ // Users row shows current/max
+ expect(screen.getByText(/^users$/i)).toBeInTheDocument();
+ expect(screen.getByText(/3 \/ 5/i)).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /manage users/i })).toHaveAttribute(
+ "href",
+ href("/organizations/:organizationSlug/settings/members", {
+ organizationSlug: props.organizationSlug,
+ }),
+ );
+
+ // Projected total
+ expect(screen.getByText(/projected total/i)).toBeInTheDocument();
+ expect(screen.getByText("$50")).toBeInTheDocument();
+
+ // Next billing date
+ expect(screen.getByText(/next billing date/i)).toBeInTheDocument();
+ expect(screen.getByText(/march 15, 2025/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /view invoices/i }),
+ ).toBeInTheDocument();
+ });
+
+ test("given: the user has their subscription cancelled at the period end, should: show an alert banner with a button to resume their subscription", () => {
+ const props = createProps({
+ cancelAtPeriodEnd: true,
+ currentPeriodEnd: new Date("2025-02-12T00:00:00.000Z"),
+ isOnFreeTrial: faker.datatype.boolean(),
+ });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // It shows the cancel at period end banner
+ expect(
+ screen.getByText(/your subscription is ending soon./i),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/your subscription runs out on february 12, 2025/i),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /resume subscription/i }),
+ ).toBeInTheDocument();
+
+ // It hides any free trial banner
+ expect(
+ screen.queryByText(/your organization is currently on a free trial./i),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/your free trial will end on february 12, 2025/i),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /add payment information/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ test("given: the user's subscription is inactive, should: show an alert banner with a button to reactivate their subscription", () => {
+ const props = createProps({
+ cancelAtPeriodEnd: faker.datatype.boolean(),
+ currentPeriodEnd: new Date("2025-02-12T00:00:00.000Z"),
+ isOnFreeTrial: faker.datatype.boolean(),
+ subscriptionStatus: "inactive",
+ });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // It shows the subscription inactive banner
+ expect(
+ screen.getByText(/your subscription is inactive./i),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/your subscription has been cancelled./i),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /reactivate subscription/i }),
+ ).toBeInTheDocument();
+
+ // It hides any cancel at period end banner
+ expect(
+ screen.queryByText(/your subscription is ending soon./i),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/your subscription runs out on february 12, 2025/i),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /resume subscription/i }),
+ ).not.toBeInTheDocument();
+
+ // It hides any free trial banner
+ expect(
+ screen.queryByText(/your organization is currently on a free trial./i),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/your free trial will end on february 12, 2025/i),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /add payment information/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ test.skip("given: the user is on trial & adding their payment information, should: disable all other buttons and render a loading state on the clicked button", () => {
+ const props = createProps({
+ // isAddingPaymentInformation: true,
+ isOnFreeTrial: true,
+ });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // It shows the loading state on the clicked button
+ expect(
+ screen.getByRole("button", { name: /opening customer portal/i }),
+ ).toBeDisabled();
+
+ // It disables all other buttons
+ const managePlanButtons = screen.getAllByRole("button", {
+ name: /manage plan/i,
+ });
+ expect(managePlanButtons).toHaveLength(2);
+ for (const button of managePlanButtons) {
+ expect(button).toBeDisabled();
+ }
+ expect(
+ screen.getByRole("button", { name: /view invoices/i }),
+ ).toBeDisabled();
+ });
+
+ test("given: the user's subscription is set to be cancelled at the period end and the user is resuming their subscription, should: disable all other buttons and render a loading state on the clicked button", () => {
+ const props = createProps({
+ cancelAtPeriodEnd: true,
+ isOnFreeTrial: faker.datatype.boolean(),
+ isResumingSubscription: true,
+ });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // It shows the loading state on the clicked button
+ expect(
+ screen.getByRole("button", { name: /resuming subscription/i }),
+ ).toBeDisabled();
+
+ // It disables all other buttons
+ const managePlanButtons = screen.getAllByRole("button", {
+ name: /manage plan/i,
+ });
+ expect(managePlanButtons).toHaveLength(2);
+ for (const button of managePlanButtons) {
+ expect(button).toBeDisabled();
+ }
+ expect(
+ screen.getByRole("button", { name: /view invoices/i }),
+ ).toBeDisabled();
+ });
+
+ test.skip("given: the user's subscription is inactive and the user is reactivating their subscription, should: disable all other buttons and render a loading state on the clicked button", () => {
+ const props = createProps({
+ // isReactivatingSubscription: true,
+ cancelAtPeriodEnd: faker.datatype.boolean(),
+ isOnFreeTrial: faker.datatype.boolean(),
+ subscriptionStatus: "inactive",
+ });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // It shows the loading state on the clicked button
+ expect(
+ screen.getByRole("button", { name: /opening customer portal/i }),
+ ).toBeDisabled();
+
+ // It disables all other buttons
+ const managePlanButtons = screen.getAllByRole("button", {
+ name: /manage plan/i,
+ });
+ expect(managePlanButtons).toHaveLength(2);
+ for (const button of managePlanButtons) {
+ expect(button).toBeDisabled();
+ }
+ expect(
+ screen.getByRole("button", { name: /view invoices/i }),
+ ).toBeDisabled();
+ });
+
+ test("given: the user opens the cancel subscription modal, should: show a dialog with a title, description, a list of features, a button to cancel their subscription and a button to change their plan instead", async () => {
+ const user = userEvent.setup();
+ const props = createProps();
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Open the change plan modal
+ // biome-ignore lint/style/noNonNullAssertion: test code
+ const managePlanButton = screen.getAllByRole("button", {
+ name: /manage plan/i,
+ })[1]!;
+ await user.click(managePlanButton);
+ expect(
+ screen.getByRole("heading", { level: 2, name: /manage plan/i }),
+ ).toBeInTheDocument();
+
+ // Click the "Cancel subscription" button
+ const cancelSubscriptionButton = screen.getByRole("button", {
+ name: /cancel subscription/i,
+ });
+ await user.click(cancelSubscriptionButton);
+
+ // It shows the dialog with the correct title, description, and features
+ expect(
+ screen.getByRole("heading", {
+ level: 2,
+ name: /are you sure you want to cancel your subscription?/i,
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /canceling your subscription means you will lose access to your benefits at the end of your billing cycle./i,
+ ),
+ ).toBeInTheDocument();
+
+ // It shows the list of features
+ expect(screen.getByText(/sso/i)).toBeInTheDocument();
+ expect(screen.getByText(/unlimited members/i)).toBeInTheDocument();
+ expect(screen.getByText(/unlimited private projects/i)).toBeInTheDocument();
+ expect(screen.getByText(/priority support/i)).toBeInTheDocument();
+
+ // It shows the "Cancel subscription" button
+ expect(
+ screen.getByRole("button", { name: /cancel subscription/i }),
+ ).toBeInTheDocument();
+
+ // Clicking the "Select a different plan" button opens the change plan modal
+ const changePlanButton = screen.getByRole("button", {
+ name: /select a different plan/i,
+ });
+ await user.click(changePlanButton);
+ expect(
+ screen.getByRole("heading", { level: 2, name: /manage plan/i }),
+ ).toBeInTheDocument();
+ });
+
+ test("given: a billing email, should: show it in the billing email field", () => {
+ const props = createProps();
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ expect(
+ screen.getByRole("heading", { name: /payment information/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/billing email/i)).toBeInTheDocument();
+ expect(screen.getByText(props.billingEmail)).toBeInTheDocument();
+ });
+
+ test("given: no billing email, should: not show the billing email field", () => {
+ const props = createProps({ billingEmail: "" });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ expect(
+ screen.queryByRole("heading", { name: /payment information/i }),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText(/billing email/i)).not.toBeInTheDocument();
+ });
+
+ test("given: a pending change because a subscription is scheduled to downgrade, should: show a banner with the details of the pending change", () => {
+ const props = createProps({
+ currentTier: "high",
+ pendingChange: {
+ pendingChangeDate: new Date("2025-02-12T00:00:00.000Z"),
+ pendingInterval: "monthly" as const,
+ pendingTier: "mid" as const,
+ },
+ });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ expect(screen.getByText(/downgrade scheduled/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /your subscription will downgrade to the startup \(monthly\) plan on february 12, 2025/i,
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /keep current subscription/i }),
+ ).toBeInTheDocument();
+ });
+
+ test("given: a pending change because a subscription is scheduled to downgrade and the user is keeping their current subscription, should: show a banner with the details of the pending change", () => {
+ const props = createProps({
+ currentTier: "high",
+ isKeepingCurrentSubscription: true,
+ pendingChange: {
+ pendingChangeDate: new Date("2025-02-12T00:00:00.000Z"),
+ pendingInterval: "monthly" as const,
+ pendingTier: "high" as const,
+ },
+ });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ expect(screen.getByText(/downgrade scheduled/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /your subscription will downgrade to the business \(monthly\) plan on february 12, 2025/i,
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /updating subscription/i }),
+ ).toBeDisabled();
+ });
+
+ test.todo(
+ "given: the user is on an enterprise plan, should: show the available data",
+ );
+});
diff --git a/apps/react-router/saas-template/app/features/billing/billing-page.tsx b/apps/react-router/saas-template/app/features/billing/billing-page.tsx
new file mode 100644
index 0000000..081a46b
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/billing-page.tsx
@@ -0,0 +1,654 @@
+import type { SubmissionResult } from "@conform-to/react/future";
+import * as VisuallyHiddenPrimitive from "@radix-ui/react-visually-hidden";
+import { IconCircleX } from "@tabler/icons-react";
+import { useMemo, useState } from "react";
+import { Trans, useTranslation } from "react-i18next";
+import { Form, href, Link, useNavigation } from "react-router";
+import { useHydrated } from "remix-utils/use-hydrated";
+
+import type { Interval, Tier } from "./billing-constants";
+import {
+ CANCEL_SUBSCRIPTION_INTENT,
+ KEEP_CURRENT_SUBSCRIPTION_INTENT,
+ priceLookupKeysByTierAndInterval,
+ RESUME_SUBSCRIPTION_INTENT,
+ SWITCH_SUBSCRIPTION_INTENT,
+ UPDATE_BILLING_EMAIL_INTENT,
+ VIEW_INVOICES_INTENT,
+} from "./billing-constants";
+import type { CancelOrModifySubscriptionModalContentProps } from "./cancel-or-modify-subscription-modal-content";
+import { CancelOrModifySubscriptionModalContent } from "./cancel-or-modify-subscription-modal-content";
+import type { CreateSubscriptionModalContentProps } from "./create-subscription-modal-content";
+import { CreateSubscriptionModalContent } from "./create-subscription-modal-content";
+import {
+ DescriptionDetail,
+ DescriptionList,
+ DescriptionListRow,
+ DescriptionTerm,
+} from "./description-list";
+import { EditBillingEmailModalContent } from "./edit-billing-email-modal-content";
+import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
+import { Button } from "~/components/ui/button";
+import { Card } from "~/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog";
+import { Separator } from "~/components/ui/separator";
+import { Spinner } from "~/components/ui/spinner";
+import type { Organization } from "~/generated/browser";
+import { cn } from "~/lib/utils";
+
+type PendingDowngradeBannerProps = {
+ pendingTier: Tier;
+ pendingInterval: Interval;
+ pendingChangeDate: Date;
+ isKeepingCurrentSubscription?: boolean;
+ isSubmitting?: boolean;
+};
+
+function PendingDowngradeBanner({
+ pendingChangeDate,
+ pendingInterval,
+ pendingTier,
+ isKeepingCurrentSubscription,
+ isSubmitting,
+}: PendingDowngradeBannerProps) {
+ const { t, i18n } = useTranslation("billing", {
+ keyPrefix: "billingPage.pendingDowngradeBanner",
+ });
+ const { t: tTier } = useTranslation("billing", {
+ keyPrefix: "pricing.plans",
+ });
+
+ const formattedDate = useMemo(() => {
+ return new Intl.DateTimeFormat(i18n.language || "en-GB", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ }).format(new Date(pendingChangeDate));
+ }, [pendingChangeDate, i18n.language]);
+
+ return (
+
+ );
+}
+
+export type BillingPageProps = {
+ billingEmail: Organization["billingEmail"];
+ cancelAtPeriodEnd: boolean;
+ cancelOrModifySubscriptionModalProps: CancelOrModifySubscriptionModalContentProps;
+ createSubscriptionModalProps: CreateSubscriptionModalContentProps;
+ currentMonthlyRatePerUser: number;
+ /**
+ * During trial, this is the trial end date.
+ * Otherwise, this is the end of the current period.
+ */
+ currentPeriodEnd: Date;
+ currentSeats: number;
+ currentTier: Tier;
+ currentInterval: Interval;
+ isCancellingSubscription?: boolean;
+ isEnterprisePlan: boolean;
+ isKeepingCurrentSubscription?: boolean;
+ isOnFreeTrial: boolean;
+ isResumingSubscription?: boolean;
+ isViewingInvoices?: boolean;
+ lastResult?: SubmissionResult;
+ maxSeats: number;
+ organizationSlug: string;
+ pendingChange?: PendingDowngradeBannerProps;
+ projectedTotal: number;
+ subscriptionStatus: "active" | "inactive" | "paused";
+};
+
+export function BillingPage({
+ billingEmail,
+ cancelAtPeriodEnd,
+ cancelOrModifySubscriptionModalProps,
+ createSubscriptionModalProps,
+ currentMonthlyRatePerUser,
+ currentPeriodEnd,
+ currentSeats,
+ currentTier,
+ currentInterval,
+ isCancellingSubscription = false,
+ isKeepingCurrentSubscription = false,
+ isOnFreeTrial,
+ isResumingSubscription = false,
+ isViewingInvoices = false,
+ lastResult,
+ maxSeats,
+ organizationSlug,
+ pendingChange,
+ projectedTotal,
+ subscriptionStatus,
+}: BillingPageProps) {
+ const { t, i18n } = useTranslation("billing", { keyPrefix: "billingPage" });
+ const { t: tTier } = useTranslation("billing", {
+ keyPrefix: "pricing.plans",
+ });
+ const [isPlanManagementModalOpen, setIsPlanManagementModalOpen] =
+ useState(false);
+ const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
+ const hydrated = useHydrated();
+
+ const formattedDate = useMemo(() => {
+ return new Intl.DateTimeFormat(i18n.language || "en-GB", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ }).format(new Date(currentPeriodEnd));
+ }, [currentPeriodEnd, i18n.language]);
+
+ const isSubmitting =
+ isCancellingSubscription ||
+ isKeepingCurrentSubscription ||
+ isResumingSubscription ||
+ isViewingInvoices;
+
+ /* Switch subscription */
+ const navigation = useNavigation();
+ const isSwitchingToHigh =
+ navigation.formData?.get("intent") === SWITCH_SUBSCRIPTION_INTENT &&
+ (
+ [
+ priceLookupKeysByTierAndInterval.high.annual,
+ priceLookupKeysByTierAndInterval.high.monthly,
+ ] as string[]
+ ).includes(navigation.formData?.get("lookupKey") as string);
+ const isSwitchingToLow =
+ navigation.formData?.get("intent") === SWITCH_SUBSCRIPTION_INTENT &&
+ (
+ [
+ priceLookupKeysByTierAndInterval.low.annual,
+ priceLookupKeysByTierAndInterval.low.monthly,
+ ] as string[]
+ ).includes(navigation.formData?.get("lookupKey") as string);
+ const isSwitchingToMid =
+ navigation.formData?.get("intent") === SWITCH_SUBSCRIPTION_INTENT &&
+ (
+ [
+ priceLookupKeysByTierAndInterval.mid.annual,
+ priceLookupKeysByTierAndInterval.mid.monthly,
+ ] as string[]
+ ).includes(navigation.formData?.get("lookupKey") as string);
+
+ /* Update billing email */
+ const isUpdatingBillingEmail =
+ navigation.formData?.get("intent") === UPDATE_BILLING_EMAIL_INTENT;
+
+ return (
+
+
+
+
{t("pageTitle")}
+
+
+ {t("pageDescription")}
+
+
+
+
+
+ {subscriptionStatus === "inactive" ? (
+
+
+
+
+ {t("subscriptionCancelledBanner.title")}
+
+
+
+ {t("subscriptionCancelledBanner.description")}
+
+
+
+ }
+ >
+ {t("subscriptionCancelledBanner.button")}
+
+
+
+
+
+
+
+ {t("subscriptionCancelledBanner.modal.title")}
+
+
+
+
+ {t("subscriptionCancelledBanner.modal.description")}
+
+
+
+
+
+
+
+ ) : cancelAtPeriodEnd ? (
+
+ ) : pendingChange ? (
+
+ ) : (
+ isOnFreeTrial && (
+
+
+
+ {t("freeTrialBanner.title")}
+
+
+ {t("freeTrialBanner.description", {
+ date: formattedDate,
+ })}
+
+
+
+ }
+ >
+ {t("freeTrialBanner.button")}
+
+
+
+
+
+
+ {t("freeTrialBanner.modal.title")}
+
+
+
+ {t("freeTrialBanner.modal.description")}
+
+
+
+
+
+
+
+ )
+ )}
+
+
+
+ {t("planInformation.heading")}
+
+
+
+
+
+ {billingEmail && (
+
+
+ {t("paymentInformation.heading")}
+
+
+
+
+
+ {/* Billing Email */}
+
+
+
+ {t("paymentInformation.billingEmail")}
+
+
+ {billingEmail}
+
+
+
+
+ }
+ >
+ {t("paymentInformation.editButton")}
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ {t("pricingModal.title")}
+
+
+
+ {t("pricingModal.description")}
+
+
+
+
+ {subscriptionStatus === "active" && !isOnFreeTrial ? (
+ {
+ setIsPlanManagementModalOpen(false);
+ setIsCancelModalOpen(true);
+ }}
+ />
+ ) : (
+
+ )}
+
+
+
+ {/* Cancel subscription */}
+
+
+
+ {t("cancelSubscriptionModal.title")}
+
+
+ {t("cancelSubscriptionModal.description")}
+
+
+
+
+
+ {(
+ t("cancelSubscriptionModal.features", {
+ returnObjects: true,
+ }) as string[]
+ ).map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+
+ {
+ setIsCancelModalOpen(false);
+ setIsPlanManagementModalOpen(true);
+ }}
+ variant="outline"
+ >
+ {t("cancelSubscriptionModal.changePlan")}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/billing/billing-schemas.ts b/apps/react-router/saas-template/app/features/billing/billing-schemas.ts
new file mode 100644
index 0000000..eb66e72
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/billing-schemas.ts
@@ -0,0 +1,53 @@
+import { z } from "zod";
+
+import {
+ CANCEL_SUBSCRIPTION_INTENT,
+ KEEP_CURRENT_SUBSCRIPTION_INTENT,
+ OPEN_CHECKOUT_SESSION_INTENT,
+ RESUME_SUBSCRIPTION_INTENT,
+ SWITCH_SUBSCRIPTION_INTENT,
+ UPDATE_BILLING_EMAIL_INTENT,
+ VIEW_INVOICES_INTENT,
+} from "./billing-constants";
+
+z.config({ jitless: true });
+
+export const cancelSubscriptionSchema = z.object({
+ intent: z.literal(CANCEL_SUBSCRIPTION_INTENT),
+});
+
+export const openCustomerCheckoutSessionSchema = z.object({
+ intent: z.literal(OPEN_CHECKOUT_SESSION_INTENT),
+ lookupKey: z.string(),
+});
+
+export const keepCurrentSubscriptionSchema = z.object({
+ intent: z.literal(KEEP_CURRENT_SUBSCRIPTION_INTENT),
+});
+
+export const resumeSubscriptionSchema = z.object({
+ intent: z.literal(RESUME_SUBSCRIPTION_INTENT),
+});
+
+export const switchSubscriptionSchema = z.object({
+ intent: z.literal(SWITCH_SUBSCRIPTION_INTENT),
+ lookupKey: z.string(),
+});
+
+export const updateBillingEmailSchema = z.object({
+ billingEmail: z
+ .email({
+ message: "billing:billingPage.updateBillingEmailModal.emailInvalid",
+ })
+ .trim()
+ .min(1, {
+ message: "billing:billingPage.updateBillingEmailModal.emailRequired",
+ }),
+ intent: z.literal(UPDATE_BILLING_EMAIL_INTENT),
+});
+
+export const viewInvoicesSchema = z.object({
+ intent: z.literal(VIEW_INVOICES_INTENT),
+});
+
+export type UpdateBillingEmailSchema = z.infer;
diff --git a/apps/react-router/saas-template/app/features/billing/billing-sidebar-card.test.tsx b/apps/react-router/saas-template/app/features/billing/billing-sidebar-card.test.tsx
new file mode 100644
index 0000000..05024d7
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/billing-sidebar-card.test.tsx
@@ -0,0 +1,107 @@
+import { formatDate } from "date-fns";
+import { describe, expect, test } from "vitest";
+
+import type { BillingSidebarCardProps } from "./billing-sidebar-card";
+import { BillingSidebarCard } from "./billing-sidebar-card";
+import { createRoutesStub, render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createProps: Factory = ({
+ createSubscriptionModalProps = {
+ currentSeats: 1,
+ planLimits: {
+ high: 25,
+ low: 1,
+ mid: 10,
+ },
+ },
+ state = "trialing",
+ showButton = true,
+ trialEndDate = new Date("2024-12-31"),
+} = {}) => ({ createSubscriptionModalProps, showButton, state, trialEndDate });
+
+describe("BillingSidebarCard component", () => {
+ test("given: free trial is active, should: show active trial message with end date and correct button text", () => {
+ const props = createProps({
+ state: "trialing",
+ trialEndDate: new Date("2024-12-31"),
+ });
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Title and date
+ expect(screen.getByText(/business plan \(trial\)/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ new RegExp(formatDate(props.trialEndDate, "MMMM dd, yyyy"), "i"),
+ ),
+ ).toBeInTheDocument();
+
+ // Button text for active trial
+ const button = screen.getByRole("button", {
+ name: /add payment information/i,
+ });
+ expect(button).toBeInTheDocument();
+ });
+
+ test("given: free trial has ended, should: show trial ended message with end date and correct button text", () => {
+ const props = createProps({
+ state: "trialEnded",
+ trialEndDate: new Date("2024-12-31"),
+ });
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Title and date
+ expect(screen.getByText(/business plan \(trial\)/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ new RegExp(formatDate(props.trialEndDate, "MMMM dd, yyyy"), "i"),
+ ),
+ ).toBeInTheDocument();
+
+ // Button text for ended trial
+ const button = screen.getByRole("button", { name: /resume subscription/i });
+ expect(button).toBeInTheDocument();
+ });
+
+ test("given: showButton is false, should: not show manage subscription button", () => {
+ const props = createProps({ showButton: false });
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ test("given: state is cancelled, should: show cancelled message", () => {
+ const props = createProps({ state: "cancelled" });
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Cancelled message
+ expect(screen.getByText(/subscription inactive/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(/renew to keep using the app/i),
+ ).toBeInTheDocument();
+
+ // Button text for ended trial
+ const button = screen.getByRole("button", { name: /choose plan/i });
+ expect(button).toBeInTheDocument();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/billing/billing-sidebar-card.tsx b/apps/react-router/saas-template/app/features/billing/billing-sidebar-card.tsx
new file mode 100644
index 0000000..e344565
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/billing-sidebar-card.tsx
@@ -0,0 +1,126 @@
+import * as VisuallyHiddenPrimitive from "@radix-ui/react-visually-hidden";
+import { formatDate } from "date-fns";
+import { useTranslation } from "react-i18next";
+import { useHydrated } from "remix-utils/use-hydrated";
+
+import type { CreateSubscriptionModalContentProps } from "./create-subscription-modal-content";
+import { CreateSubscriptionModalContent } from "./create-subscription-modal-content";
+import { Button } from "~/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog";
+import { cn } from "~/lib/utils";
+
+export type BillingSidebarCardProps = {
+ className?: string;
+ createSubscriptionModalProps: CreateSubscriptionModalContentProps;
+ state: "trialing" | "trialEnded" | "cancelled";
+ showButton: boolean;
+ trialEndDate: Date;
+};
+
+export function BillingSidebarCard({
+ className,
+ createSubscriptionModalProps,
+ state,
+ showButton,
+ trialEndDate,
+}: BillingSidebarCardProps) {
+ const { t } = useTranslation("billing", {
+ keyPrefix: "billingSidebarCard",
+ });
+ const hydrated = useHydrated();
+
+ return (
+
+
+
+
+ {state === "trialing"
+ ? t("activeTrial.title")
+ : state === "cancelled"
+ ? t("subscriptionInactive.title")
+ : t("trialEnded.title")}
+
+
+
+ {state === "trialing"
+ ? t("activeTrial.description", {
+ date: formatDate(trialEndDate, "MMMM dd, yyyy"),
+ })
+ : state === "cancelled"
+ ? t("subscriptionInactive.description")
+ : t("trialEnded.description", {
+ date: formatDate(trialEndDate, "MMMM dd, yyyy"),
+ })}
+
+
+
+ {showButton && (
+
+
+ }
+ >
+ {state === "trialing"
+ ? t("activeTrial.button")
+ : state === "cancelled"
+ ? t("subscriptionInactive.button")
+ : t("trialEnded.button")}
+
+
+ )}
+
+
+
+
+
+ {state === "cancelled"
+ ? t("subscriptionInactive.modal.title")
+ : t("billingModal.title")}
+
+
+
+
+ {t("billingModal.description")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/billing/cancel-or-modify-subscription-modal-content.test.tsx b/apps/react-router/saas-template/app/features/billing/cancel-or-modify-subscription-modal-content.test.tsx
new file mode 100644
index 0000000..be69af3
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/cancel-or-modify-subscription-modal-content.test.tsx
@@ -0,0 +1,298 @@
+import { href } from "react-router";
+import { describe, expect, test, vi } from "vitest";
+
+import type { CancelOrModifySubscriptionModalContentProps } from "./cancel-or-modify-subscription-modal-content";
+import { CancelOrModifySubscriptionModalContent } from "./cancel-or-modify-subscription-modal-content";
+import {
+ createRoutesStub,
+ render,
+ screen,
+ userEvent,
+} from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createProps: Factory = ({
+ canCancelSubscription = false,
+ currentTier = "low",
+ currentTierInterval = "annual" as const,
+ isSwitchingToHigh = false,
+ isSwitchingToLow = false,
+ isSwitchingToMid = false,
+ onCancelSubscriptionClick = vi.fn(),
+} = {}) => ({
+ canCancelSubscription,
+ currentTier,
+ currentTierInterval,
+ isSwitchingToHigh,
+ isSwitchingToLow,
+ isSwitchingToMid,
+ onCancelSubscriptionClick,
+});
+
+describe("CancelOrModifySubscriptionModalContent component", () => {
+ test("given: any props, should: render tab buttons to switch between monthly and annual", async () => {
+ const user = userEvent.setup();
+ const props = createProps();
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ // Check initial state (annual by default)
+ expect(screen.getByRole("tab", { name: /annual/i })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+
+ // Switch to monthly
+ await user.click(screen.getByRole("tab", { name: /monthly/i }));
+ expect(screen.getByRole("tab", { name: /monthly/i })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+
+ // Verify save monthly message appears only on annual tab
+ const saveMonthlyText = screen.getByText(
+ /save up to 20% on the annual plan/i,
+ );
+ expect(saveMonthlyText).toBeInTheDocument();
+
+ // Switch back to annual and verify message appears
+ await user.click(screen.getByRole("tab", { name: /annual/i }));
+ expect(
+ screen.queryByText(/save 20% on the annual plan/i),
+ ).not.toBeInTheDocument();
+ });
+
+ test("given: user is on low tier, should: show current plan and upgrade buttons", () => {
+ const props = createProps({ currentTier: "low" });
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ // Current plan should be marked
+ expect(
+ screen.getByRole("button", { name: /current plan/i }),
+ ).toBeDisabled();
+
+ // Should show upgrade buttons for mid and high tiers
+ expect(screen.getAllByRole("button", { name: /upgrade/i })).toHaveLength(2);
+
+ // Enterprise should be a link
+ expect(
+ screen.getByRole("link", { name: /contact sales/i }),
+ ).toHaveAttribute("href", href("/contact-sales"));
+ });
+
+ test("given: user is on mid tier, should: show current plan, upgrade and downgrade buttons", () => {
+ const props = createProps({ currentTier: "mid" });
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ // Current plan should be marked
+ expect(
+ screen.getByRole("button", { name: /current plan/i }),
+ ).toBeDisabled();
+
+ // Should show downgrade button for low tier
+ expect(
+ screen.getByRole("button", { name: /downgrade/i }),
+ ).toBeInTheDocument();
+
+ // Should show upgrade button for high tier
+ expect(
+ screen.getByRole("button", { name: /upgrade/i }),
+ ).toBeInTheDocument();
+
+ // Enterprise should be a link
+ expect(
+ screen.getByRole("link", { name: /contact sales/i }),
+ ).toHaveAttribute("href", href("/contact-sales"));
+ });
+
+ test("given: user is on high tier, should: show current plan and downgrade buttons", () => {
+ const props = createProps({ currentTier: "high" });
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ // Current plan should be marked
+ expect(
+ screen.getByRole("button", { name: /current plan/i }),
+ ).toBeDisabled();
+
+ // Should show downgrade buttons for low and mid tiers
+ expect(screen.getAllByRole("button", { name: /downgrade/i })).toHaveLength(
+ 2,
+ );
+
+ // Enterprise should be a link
+ expect(
+ screen.getByRole("link", { name: /contact sales/i }),
+ ).toHaveAttribute("href", href("/contact-sales"));
+ });
+
+ test.each([
+ "mid",
+ "high",
+ ] as const)("given: user is switching to low tier, should: show downgrading status", (currentTier) => {
+ const props = createProps({ currentTier, isSwitchingToLow: true });
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ expect(screen.getByRole("button", { name: /downgrading/i })).toBeDisabled();
+ });
+
+ test("given: user is switching to mid tier from low tier, should: show upgrading status", () => {
+ const props = createProps({ currentTier: "low", isSwitchingToMid: true });
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ expect(screen.getByRole("button", { name: /upgrading/i })).toBeDisabled();
+ });
+
+ test("given: user is switching to mid tier from high tier, should: show downgrading status", () => {
+ const props = createProps({ currentTier: "high", isSwitchingToMid: true });
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ const downgradingButton = screen.getByRole("button", {
+ name: /downgrading/i,
+ });
+ expect(downgradingButton).toBeInTheDocument();
+ expect(downgradingButton).toBeDisabled();
+ });
+
+ test.each([
+ "low",
+ "mid",
+ ] as const)("given: user is switching to high tier, should: show upgrading status", (currentTier) => {
+ const props = createProps({ currentTier, isSwitchingToHigh: true });
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ expect(screen.getByRole("button", { name: /upgrading/i })).toBeDisabled();
+ });
+
+ test("given: the user has a subscription they can cancel, should: show cancellation option", () => {
+ const props = createProps({ canCancelSubscription: true });
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ expect(
+ screen.getByRole("button", { name: /cancel subscription/i }),
+ ).toBeInTheDocument();
+ });
+
+ test.each([
+ "low",
+ "mid",
+ "high",
+ ] as const)("given: the user is on the monthly plan, should: show 'switch to annual' button for their current tier", (currentTier) => {
+ const props = createProps({
+ currentTier,
+ currentTierInterval: "monthly",
+ });
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ // Should show "Switch to annual and save 20%" button
+ expect(
+ screen.getByRole("button", { name: /switch to annual/i }),
+ ).toBeInTheDocument();
+
+ // Should NOT show the "current plan" button
+ expect(
+ screen.queryByRole("button", { name: /current plan/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ test.each([
+ "low",
+ "mid",
+ "high",
+ ] as const)("given: the user is on the annual plan, should: show 'switch to monthly' button for their current tier", async (currentTier) => {
+ const user = userEvent.setup();
+ const props = createProps({
+ currentTier,
+ currentTierInterval: "annual",
+ });
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ // Switch to the monthly plan
+ await user.click(screen.getByRole("tab", { name: /monthly/i }));
+
+ // Should show "Switch to monthly" button
+ expect(
+ screen.getByRole("button", { name: /switch to monthly/i }),
+ ).toBeInTheDocument();
+
+ // Should NOT show the "current plan" button
+ expect(
+ screen.queryByRole("button", { name: /current plan/i }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/billing/cancel-or-modify-subscription-modal-content.tsx b/apps/react-router/saas-template/app/features/billing/cancel-or-modify-subscription-modal-content.tsx
new file mode 100644
index 0000000..acc7d69
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/cancel-or-modify-subscription-modal-content.tsx
@@ -0,0 +1,530 @@
+import { IconCheck } from "@tabler/icons-react";
+import type { ComponentProps, MouseEventHandler } from "react";
+import { useState } from "react";
+import { Trans, useTranslation } from "react-i18next";
+import { Form, href, Link } from "react-router";
+
+import type { Interval, Tier } from "./billing-constants";
+import {
+ priceLookupKeysByTierAndInterval,
+ SWITCH_SUBSCRIPTION_INTENT,
+} from "./billing-constants";
+import {
+ FeatureListItem,
+ FeaturesList,
+ FeaturesListTitle,
+ OfferBadge,
+ TierCard,
+ TierCardContent,
+ TierCardDescription,
+ TierCardHeader,
+ TierCardPrice,
+ TierCardTitle,
+ TierContainer,
+ TierGrid,
+} from "./pricing";
+import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import { Separator } from "~/components/ui/separator";
+import { Spinner } from "~/components/ui/spinner";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
+
+export type CancelOrModifySubscriptionModalContentProps = {
+ canCancelSubscription: boolean;
+ currentTier: Tier | "enterprise";
+ currentTierInterval: Interval;
+ isSwitchingToHigh?: boolean;
+ isSwitchingToLow?: boolean;
+ isSwitchingToMid?: boolean;
+ onCancelSubscriptionClick?: MouseEventHandler;
+};
+
+export function CancelOrModifySubscriptionModalContent({
+ canCancelSubscription = false,
+ currentTier,
+ currentTierInterval,
+ isSwitchingToHigh = false,
+ isSwitchingToLow = false,
+ isSwitchingToMid = false,
+ onCancelSubscriptionClick,
+}: CancelOrModifySubscriptionModalContentProps) {
+ const { t } = useTranslation("billing", { keyPrefix: "pricing" });
+ const { t: tModal } = useTranslation("billing", {
+ keyPrefix: "billingPage.pricingModal",
+ });
+ const [billingPeriod, setBillingPeriod] = useState("annual");
+
+ const isSubmitting =
+ isSwitchingToLow || isSwitchingToMid || isSwitchingToHigh;
+
+ // TODO: change to "Tier" - high, low, mid, enterprise
+ const getFeatures = (key: string): string[] =>
+ t(`plans.${key}.features`, "", { returnObjects: true }) as string[];
+
+ const getButtonProps = (
+ interval: "monthly" | "annual",
+ tier: "low" | "mid" | "high",
+ ): Partial> => {
+ const isCurrentTier = tier === currentTier;
+ const isUpgrade =
+ (currentTier === "low" && (tier === "mid" || tier === "high")) ||
+ (currentTier === "mid" && tier === "high");
+
+ // flags for in-flight actions
+ const switchingToThisTier =
+ (tier === "low" && isSwitchingToLow) ||
+ (tier === "mid" && isSwitchingToMid) ||
+ (tier === "high" && isSwitchingToHigh);
+
+ // 1. If this is the current tier but only the billing interval is different
+ if (isCurrentTier) {
+ if (interval !== currentTierInterval) {
+ return interval === "annual"
+ ? { children: tModal("switchToAnnualButton") }
+ : {
+ children: tModal("switchToMonthlyButton"),
+ variant: "outline",
+ };
+ }
+ return {
+ children: tModal("currentPlan"),
+ disabled: true,
+ variant: "outline",
+ };
+ }
+
+ // 2. If we’re already submitting a switch for this tier, show spinner + appropriate label
+ if (switchingToThisTier) {
+ const label = isUpgrade ? (
+ <>
+
+ {tModal("upgrading")}
+ >
+ ) : (
+ <>
+
+ {tModal("downgrading")}
+ >
+ );
+
+ return { children: label, ...(isUpgrade ? {} : { variant: "outline" }) };
+ }
+
+ // 3. Default static buttons for upgrade vs downgrade
+ return isUpgrade
+ ? { children: tModal("upgradeButton"), disabled: isSubmitting }
+ : { children: tModal("downgradeButton"), variant: "outline" };
+ };
+
+ return (
+ <>
+
+
+ {canCancelSubscription && (
+ <>
+
+
+
+
+
+ {tModal("cancelSubscriptionBanner.title")}
+
+
+
+ {tModal("cancelSubscriptionBanner.description")}
+
+
+
+ {tModal("cancelSubscriptionBanner.button")}
+
+
+
+ >
+ )}
+ >
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-action.server.ts b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-action.server.ts
new file mode 100644
index 0000000..fedf421
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-action.server.ts
@@ -0,0 +1,39 @@
+import { parseSubmission, report } from "@conform-to/react/future";
+import { parseFormData } from "@remix-run/form-data-parser";
+import { data } from "react-router";
+
+import { CONTACT_SALES_INTENT } from "./contact-sales-constants";
+import { saveContactSalesFormSubmissionToDatabase } from "./contact-sales-form-submission-model.server";
+import { contactSalesFormSchema } from "./contact-sales-schemas";
+import type { Route } from ".react-router/types/app/routes/+types/contact-sales";
+import { checkHoneypot } from "~/utils/honeypot.server";
+import { badRequest } from "~/utils/http-responses.server";
+
+export async function contactSalesAction({ request }: Route.ActionArgs) {
+ const formData = await parseFormData(request);
+
+ // Check honeypot before validation (honeypot fields won't be in validated data)
+ await checkHoneypot(Object.fromEntries(formData));
+
+ // Validate using the same formData (following the validateFormData helper pattern)
+ const submission = parseSubmission(formData, { stripEmptyValues: false });
+ const result = await contactSalesFormSchema.safeParseAsync(
+ submission.payload,
+ );
+
+ if (!result.success) {
+ return badRequest({
+ result: report(submission, {
+ error: { issues: result.error.issues },
+ }),
+ });
+ }
+
+ switch (result.data.intent) {
+ case CONTACT_SALES_INTENT: {
+ const { intent: _, ...submissionData } = result.data;
+ await saveContactSalesFormSubmissionToDatabase(submissionData);
+ return data({ result: undefined, success: true });
+ }
+ }
+}
diff --git a/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-constants.ts b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-constants.ts
new file mode 100644
index 0000000..ded93ff
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-constants.ts
@@ -0,0 +1 @@
+export const CONTACT_SALES_INTENT = "contactSales";
diff --git a/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-factories.server.ts b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-factories.server.ts
new file mode 100644
index 0000000..bfacaf3
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-factories.server.ts
@@ -0,0 +1,23 @@
+import { faker } from "@faker-js/faker";
+
+import type { ContactSalesFormSchema } from "./contact-sales-schemas";
+import type { Factory } from "~/utils/types";
+
+/**
+ * Creates a valid contact sales form submission body using Faker.
+ *
+ * @param overrides - Optional overrides for the default generated values.
+ * @returns A populated object matching the ContactSalesFormSchema structure.
+ */
+export const createValidContactSalesFormData: Factory<
+ Omit & { intent?: "contactSales" } // Allow overriding intent but default it later
+> = (overrides = {}) => ({
+ companyName: faker.company.name(),
+ firstName: faker.person.firstName(),
+ lastName: faker.person.lastName(),
+ message: faker.lorem.paragraph(),
+ phoneNumber: faker.phone.number(),
+ workEmail: faker.internet.email(),
+ ...overrides, // Apply overrides
+ intent: overrides.intent ?? "contactSales", // Ensure intent is 'contactSales' unless specifically overridden
+});
diff --git a/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-form-submission-model.server.ts b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-form-submission-model.server.ts
new file mode 100644
index 0000000..d59f493
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-form-submission-model.server.ts
@@ -0,0 +1,56 @@
+import type { ContactSalesFormSubmission, Prisma } from "~/generated/client";
+import { prisma } from "~/utils/database.server";
+
+/* CREATE */
+
+/**
+ * Saves a new contact sales form submission to the database.
+ *
+ * @param submission - Parameters of the contact sales form submission that
+ * should be created.
+ * @returns The newly created contact sales form submission.
+ */
+export async function saveContactSalesFormSubmissionToDatabase(
+ submission: Prisma.ContactSalesFormSubmissionCreateInput,
+) {
+ return await prisma.contactSalesFormSubmission.create({ data: submission });
+}
+
+/* READ */
+
+/**
+ * Retrieves all contact sales form submissions from the database.
+ *
+ * @returns A list of all contact sales form submissions.
+ */
+export async function retrieveContactSalesFormSubmissionsFromDatabase() {
+ return await prisma.contactSalesFormSubmission.findMany({
+ orderBy: { createdAt: "desc" },
+ });
+}
+
+/**
+ * Retrieves a contact sales form submission by its id.
+ *
+ * @param id - The id of the contact sales form submission to retrieve.
+ * @returns The contact sales form submission or null if not found.
+ */
+export async function retrieveContactSalesFormSubmissionFromDatabaseById(
+ id: ContactSalesFormSubmission["id"],
+) {
+ return await prisma.contactSalesFormSubmission.findUnique({ where: { id } });
+}
+
+/* DELETE */
+
+/**
+ * Deletes a contact sales form submission by its id.
+ *
+ * @param id - The id of the contact sales form submission to delete.
+ * @returns The deleted contact sales form submission.
+ */
+export async function deleteContactSalesFormSubmissionFromDatabaseById(
+ id: ContactSalesFormSubmission["id"],
+) {
+ return await prisma.contactSalesFormSubmission.delete({ where: { id } });
+}
diff --git a/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-schemas.ts b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-schemas.ts
new file mode 100644
index 0000000..afc4d5d
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-schemas.ts
@@ -0,0 +1,67 @@
+import { z } from "zod";
+
+import { CONTACT_SALES_INTENT } from "./contact-sales-constants";
+
+z.config({ jitless: true });
+
+export const contactSalesFormSchema = z.object({
+ companyName: z
+ .string()
+ .trim()
+ .min(1, {
+ message: "billing:contactSales.companyNameRequired",
+ })
+ .max(255, {
+ message: "billing:contactSales.companyNameTooLong",
+ })
+ .default(""),
+ firstName: z
+ .string()
+ .trim()
+ .min(1, {
+ message: "billing:contactSales.firstNameRequired",
+ })
+ .max(255, {
+ message: "billing:contactSales.firstNameTooLong",
+ })
+ .default(""),
+ intent: z.literal(CONTACT_SALES_INTENT),
+ lastName: z
+ .string()
+ .trim()
+ .min(1, {
+ message: "billing:contactSales.lastNameRequired",
+ })
+ .max(255, {
+ message: "billing:contactSales.lastNameTooLong",
+ })
+ .default(""),
+ message: z
+ .string()
+ .trim()
+ .min(1, {
+ message: "billing:contactSales.messageRequired",
+ })
+ .max(5000, {
+ message: "billing:contactSales.messageTooLong",
+ })
+ .default(""),
+ phoneNumber: z
+ .string()
+ .trim()
+ .min(1, {
+ message: "billing:contactSales.phoneNumberRequired",
+ })
+ .default(""),
+ workEmail: z
+ .email({
+ message: "billing:contactSales.workEmailInvalid",
+ })
+ .trim()
+ .min(1, {
+ message: "billing:contactSales.workEmailRequired",
+ })
+ .default(""),
+});
+
+export type ContactSalesFormSchema = z.infer;
diff --git a/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-team.test.tsx b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-team.test.tsx
new file mode 100644
index 0000000..d0a0e35
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-team.test.tsx
@@ -0,0 +1,44 @@
+import { describe, expect, test } from "vitest";
+
+import type { ContactSalesTeamProps } from "./contact-sales-team";
+import { ContactSalesTeam } from "./contact-sales-team";
+import { createRoutesStub, render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createProps: Factory = ({
+ isContactingSales = false,
+ ...props
+} = {}) => ({ isContactingSales, ...props });
+
+describe("ContactSalesTeam component", () => {
+ test("given no props: renders inputs for the first and last name, the company name, the work email, the phone number, and a message, as well as a submit button", () => {
+ const path = "/contact-sales";
+ const props = createProps();
+ const RemixStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ expect(screen.getByLabelText(/first name/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/last name/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/company/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/work email/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/phone number/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/message/i)).toBeInTheDocument();
+ });
+
+ test("given no props: renders a honeypot input", () => {
+ const path = "/contact-sales";
+ const props = createProps();
+ const RemixStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ expect(
+ screen.getByLabelText(/please leave this field blank/i),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-team.tsx b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-team.tsx
new file mode 100644
index 0000000..984c47e
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/contact-sales/contact-sales-team.tsx
@@ -0,0 +1,168 @@
+import type { SubmissionResult } from "@conform-to/react/future";
+import { useForm } from "@conform-to/react/future";
+import { useTranslation } from "react-i18next";
+import { Form } from "react-router";
+import { HoneypotInputs } from "remix-utils/honeypot/react";
+
+import { CONTACT_SALES_INTENT } from "./contact-sales-constants";
+import { contactSalesFormSchema } from "./contact-sales-schemas";
+import { Button } from "~/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { Field, FieldError, FieldLabel, FieldSet } from "~/components/ui/field";
+import { Input } from "~/components/ui/input";
+import { Spinner } from "~/components/ui/spinner";
+import { Textarea } from "~/components/ui/textarea";
+
+export type ContactSalesTeamProps = {
+ isContactingSales?: boolean;
+ lastResult?: SubmissionResult;
+};
+
+export function ContactSalesTeam({
+ isContactingSales = false,
+ lastResult,
+}: ContactSalesTeamProps) {
+ const { t } = useTranslation("billing", { keyPrefix: "contactSales" });
+
+ const { form, fields } = useForm(contactSalesFormSchema, {
+ lastResult,
+ });
+
+ return (
+
+
+
+ {t("contactSalesTitle")}
+
+
+
+ {t("contactSalesDescription")}
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/billing/create-subscription-modal-content.test.tsx b/apps/react-router/saas-template/app/features/billing/create-subscription-modal-content.test.tsx
new file mode 100644
index 0000000..ff398d7
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/create-subscription-modal-content.test.tsx
@@ -0,0 +1,189 @@
+import { faker } from "@faker-js/faker";
+import { href } from "react-router";
+import { describe, expect, test } from "vitest";
+
+import { priceLookupKeysByTierAndInterval } from "./billing-constants";
+import type { CreateSubscriptionModalContentProps } from "./create-subscription-modal-content";
+import { CreateSubscriptionModalContent } from "./create-subscription-modal-content";
+import {
+ createRoutesStub,
+ render,
+ screen,
+ userEvent,
+ within,
+} from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createProps: Factory = ({
+ currentSeats = faker.number.int({ max: 5, min: 1 }),
+ planLimits = { high: 25, low: 1, mid: 10 },
+} = {}) => ({
+ currentSeats,
+ planLimits,
+});
+
+describe("CreateSubscriptionModalContent component", () => {
+ test("given: default state, should: render annual plans then switch to monthly correctly", async () => {
+ const user = userEvent.setup();
+ const props = createProps();
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+ render( );
+
+ // should render the two billing period tabs
+ const annualTab = screen.getByRole("tab", { name: "Annual" });
+ const monthlyTab = screen.getByRole("tab", { name: "Monthly" });
+ expect(annualTab).toHaveAttribute("aria-selected", "true");
+ expect(monthlyTab).toHaveAttribute("aria-selected", "false");
+
+ // should render three "Subscribe Now" buttons for annual plans
+ const annualButtons = screen.getAllByRole("button", {
+ name: "Subscribe Now",
+ });
+ expect(annualButtons).toHaveLength(3);
+ // and each should carry the correct priceId
+ expect(annualButtons[0]).toHaveAttribute(
+ "value",
+ priceLookupKeysByTierAndInterval.low.annual,
+ );
+ expect(annualButtons[1]).toHaveAttribute(
+ "value",
+ priceLookupKeysByTierAndInterval.mid.annual,
+ );
+ expect(annualButtons[2]).toHaveAttribute(
+ "value",
+ priceLookupKeysByTierAndInterval.high.annual,
+ );
+
+ // should render the enterprise "Contact Sales" link
+ expect(screen.getByRole("link", { name: "Contact Sales" })).toHaveAttribute(
+ "href",
+ href("/contact-sales"),
+ );
+
+ // when switching to monthly
+ await user.click(monthlyTab);
+
+ // should update tab selection
+ expect(monthlyTab).toHaveAttribute("aria-selected", "true");
+ expect(annualTab).toHaveAttribute("aria-selected", "false");
+
+ // should show the annual savings message
+ expect(
+ screen.getByText("Save up to 20% on the annual plan."),
+ ).toBeInTheDocument();
+
+ // should render three "Subscribe Now" buttons for monthly plans
+ const monthlyButtons = screen.getAllByRole("button", {
+ name: "Subscribe Now",
+ });
+ expect(monthlyButtons).toHaveLength(3);
+ expect(monthlyButtons[0]).toHaveAttribute(
+ "value",
+ priceLookupKeysByTierAndInterval.low.monthly,
+ );
+ expect(monthlyButtons[1]).toHaveAttribute(
+ "value",
+ priceLookupKeysByTierAndInterval.mid.monthly,
+ );
+ expect(monthlyButtons[2]).toHaveAttribute(
+ "value",
+ priceLookupKeysByTierAndInterval.high.monthly,
+ );
+ });
+
+ test("given: 2 seats in use (low limit = 1), should: disable Hobby and show warning", () => {
+ const props = createProps({
+ currentSeats: 2,
+ planLimits: { high: 25, low: 1, mid: 10 },
+ });
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+ render( );
+
+ // find the three "Subscribe Now" buttons in order: low, mid, high
+ const subscribeButtons = screen.getAllByRole("button", {
+ name: "Subscribe Now",
+ });
+ expect(subscribeButtons).toHaveLength(3);
+
+ // Hobby (low) button
+ expect(subscribeButtons[0]).toHaveAttribute(
+ "value",
+ priceLookupKeysByTierAndInterval.low.annual,
+ );
+ expect(subscribeButtons[0]).toBeDisabled();
+
+ // Startup (mid) button
+ expect(subscribeButtons[1]).toHaveAttribute(
+ "value",
+ priceLookupKeysByTierAndInterval.mid.annual,
+ );
+ expect(subscribeButtons[1]).toBeEnabled();
+
+ // Business (high) button
+ expect(subscribeButtons[2]).toHaveAttribute(
+ "value",
+ priceLookupKeysByTierAndInterval.high.annual,
+ );
+ expect(subscribeButtons[2]).toBeEnabled();
+
+ // warning alert appears
+ const alert = screen.getByRole("alert");
+ // title
+ expect(
+ within(alert).getByText(/why are some plans disabled\?/i),
+ ).toBeVisible();
+ // description
+ expect(
+ within(alert).getByText(
+ /you currently have 2 users, and the hobby plan only supports 1 user\. please choose a plan that supports at least 2 seats\./i,
+ ),
+ ).toBeVisible();
+ });
+
+ test("given: 12 seats in use (low=1, mid=10), should: disable Hobby & Startup and show combined warning", () => {
+ const props = createProps({
+ currentSeats: 12,
+ planLimits: { high: 25, low: 1, mid: 10 },
+ });
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+ render( );
+
+ // find the three "Subscribe Now" buttons
+ const subscribeButtons = screen.getAllByRole("button", {
+ name: "Subscribe Now",
+ });
+ expect(subscribeButtons).toHaveLength(3);
+
+ // Hobby & Startup should be disabled
+ expect(subscribeButtons[0]).toBeDisabled();
+ expect(subscribeButtons[1]).toBeDisabled();
+ // Business remains enabled
+ expect(subscribeButtons[2]).toBeEnabled();
+
+ // warning alert mentions both plans
+ const alert = screen.getByRole("alert");
+ expect(
+ within(alert).getByText(/why are some plans disabled\?/i),
+ ).toBeVisible();
+ expect(
+ within(alert).getByText(
+ /you currently have 12 users, and the hobby plan only supports 1 user while the startup plan only supports 10 users\. please choose a plan that supports at least 12 seats\./i,
+ ),
+ ).toBeVisible();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/billing/create-subscription-modal-content.tsx b/apps/react-router/saas-template/app/features/billing/create-subscription-modal-content.tsx
new file mode 100644
index 0000000..fda6287
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/create-subscription-modal-content.tsx
@@ -0,0 +1,499 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: Checks ensure for null values */
+import { IconCheck } from "@tabler/icons-react";
+import type { ComponentProps } from "react";
+import { useState } from "react";
+import { Trans, useTranslation } from "react-i18next";
+import { Form, href, Link, useNavigation } from "react-router";
+
+import type { Interval, Tier } from "./billing-constants";
+import {
+ OPEN_CHECKOUT_SESSION_INTENT,
+ priceLookupKeysByTierAndInterval,
+} from "./billing-constants";
+import {
+ FeatureListItem,
+ FeaturesList,
+ FeaturesListTitle,
+ OfferBadge,
+ TierCard,
+ TierCardContent,
+ TierCardDescription,
+ TierCardHeader,
+ TierCardPrice,
+ TierCardTitle,
+ TierContainer,
+ TierGrid,
+} from "./pricing";
+import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import { Separator } from "~/components/ui/separator";
+import { Spinner } from "~/components/ui/spinner";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
+
+export type CreateSubscriptionModalContentProps = {
+ /** how many seats your org is currently using */
+ currentSeats: number;
+ /** max seats per tier (e.g. { low: 1, mid: 10, high: 25 }) */
+ planLimits: Record;
+};
+
+export function CreateSubscriptionModalContent({
+ currentSeats,
+ planLimits,
+}: CreateSubscriptionModalContentProps) {
+ const { t } = useTranslation("billing", { keyPrefix: "pricing" });
+ const { t: tModal } = useTranslation("billing", {
+ keyPrefix: "noCurrentPlanModal",
+ });
+ const [billingPeriod, setBillingPeriod] = useState("annual");
+
+ const navigation = useNavigation();
+ const isSubmitting =
+ navigation.formData?.get("intent") === OPEN_CHECKOUT_SESSION_INTENT;
+ const isSubscribingToLowMonthlyPlan =
+ navigation.formData?.get("lookupKey") ===
+ priceLookupKeysByTierAndInterval.low.monthly;
+ const isSubscribingToMidMonthlyPlan =
+ navigation.formData?.get("lookupKey") ===
+ priceLookupKeysByTierAndInterval.mid.monthly;
+ const isSubscribingToHighMonthlyPlan =
+ navigation.formData?.get("lookupKey") ===
+ priceLookupKeysByTierAndInterval.high.monthly;
+ const isSubscribingToLowAnnualPlan =
+ navigation.formData?.get("lookupKey") ===
+ priceLookupKeysByTierAndInterval.low.annual;
+ const isSubscribingToMidAnnualPlan =
+ navigation.formData?.get("lookupKey") ===
+ priceLookupKeysByTierAndInterval.mid.annual;
+ const isSubscribingToHighAnnualPlan =
+ navigation.formData?.get("lookupKey") ===
+ priceLookupKeysByTierAndInterval.high.annual;
+
+ const getFeatures = (key: string): string[] =>
+ t(`plans.${key}.features`, "", { returnObjects: true }) as string[];
+
+ const getButtonProps = (
+ interval: Interval,
+ tier: Tier,
+ ): Partial> => {
+ const isSubscribing = {
+ "annual-high": isSubscribingToHighAnnualPlan,
+ "annual-low": isSubscribingToLowAnnualPlan,
+ "annual-mid": isSubscribingToMidAnnualPlan,
+ "monthly-high": isSubscribingToHighMonthlyPlan,
+ "monthly-low": isSubscribingToLowMonthlyPlan,
+ "monthly-mid": isSubscribingToMidMonthlyPlan,
+ }[`${interval}-${tier}`];
+
+ return {
+ children: isSubscribing ? (
+ <>
+
+ {tModal("tierCardBusy")}
+ >
+ ) : (
+ tModal("tierCardCta")
+ ),
+ disabled: isSubscribing || planLimits[tier] < currentSeats,
+ name: "lookupKey",
+ value: priceLookupKeysByTierAndInterval[tier][interval],
+ };
+ };
+
+ // figure out which tiers can’t cover your seats:
+ const unavailable = (["low", "mid", "high"] as Tier[]).filter(
+ (tier) => planLimits[tier] < currentSeats,
+ );
+
+ return (
+
+ {unavailable.length > 0 && (
+
+ {tModal("disabledPlansAlert.title")}
+
+
+ {unavailable.length === 1
+ ? tModal("disabledPlansAlert.descriptionSingular", {
+ currentSeats,
+ planCapacity: planLimits[unavailable[0]!],
+ planTitle: t(`plans.${unavailable[0]!}.title`),
+ })
+ : tModal("disabledPlansAlert.descriptionPlural", {
+ currentSeats,
+ plan1Capacity: planLimits[unavailable[0]!],
+ plan1Title: t(`plans.${unavailable[0]!}.title`),
+ plan2Capacity: planLimits[unavailable[1]!],
+ plan2Title: t(`plans.${unavailable[1]!}.title`),
+ })}
+
+
+ )}
+
+
+
+
+
+
+
+ {t("monthly")}
+ {t("annual")}
+
+
+ {billingPeriod === "monthly" && (
+
{t("saveAnnually")}
+ )}
+
+
+
+
+
+ {/* Low Tier */}
+
+
+ {t("plans.low.title")}
+
+
+
+ ),
+ }}
+ i18nKey="pricing.price"
+ ns="billing"
+ values={{ price: "$17" }}
+ />
+
+
+
+ {t("plans.low.description")}
+
+
+
+
+
+
+
+
+
+ {t("plans.low.featuresTitle")}
+
+
+ {getFeatures("low").map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+
+ {/* Mid Tier */}
+
+
+ {t("plans.mid.title")}
+
+
+
+ ),
+ }}
+ i18nKey="pricing.price"
+ ns="billing"
+ values={{ price: "$30" }}
+ />
+
+
+
+ {t("plans.mid.description")}
+
+
+
+
+
+
+
+
+
+ {t("plans.mid.featuresTitle")}
+
+
+
+ {getFeatures("mid").map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+
+ {/* High Tier */}
+
+
+
+ {t("plans.high.title")}
+ {t("mostPopular")}
+
+
+
+
+ ),
+ }}
+ i18nKey="pricing.price"
+ ns="billing"
+ values={{ price: "$55" }}
+ />
+
+
+
+ {t("plans.high.description")}
+
+
+
+
+
+
+
+
+
+ {t("plans.high.featuresTitle")}
+
+
+
+ {getFeatures("high").map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {/* Low Tier */}
+
+
+ {t("plans.low.title")}
+
+
+ {
+
+ ),
+ }}
+ i18nKey="pricing.price"
+ ns="billing"
+ values={{ price: "$15" }}
+ />
+ }
+
+ -10%
+
+
+
+ {t("plans.low.description")}
+
+
+
+
+
+
+
+
+
+ {t("plans.low.featuresTitle")}
+
+
+
+ {getFeatures("low").map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+
+ {/* Mid Tier */}
+
+
+ {t("plans.mid.title")}
+
+
+
+ ),
+ }}
+ i18nKey="pricing.price"
+ ns="billing"
+ values={{ price: "$25" }}
+ />
+
+ -15%
+
+
+
+ {t("plans.mid.description")}
+
+
+
+
+
+
+
+
+
+ {t("plans.mid.featuresTitle")}
+
+
+
+ {getFeatures("mid").map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+
+ {/* High Tier */}
+
+
+
+ {t("plans.high.title")}
+ {t("mostPopular")}
+
+
+
+
+ ),
+ }}
+ i18nKey="pricing.price"
+ ns="billing"
+ values={{ price: "$45" }}
+ />
+
+ -20%
+
+
+
+ {t("plans.high.description")}
+
+
+
+
+
+
+
+
+
+ {t("plans.high.featuresTitle")}
+
+
+
+ {getFeatures("high").map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+
+ {/* Enterprise Tier */}
+
+
+ {t("plans.enterprise.title")}
+
+ {t("custom")}
+
+
+ {t("plans.enterprise.description")}
+
+
+ }
+ >
+ {t("plans.enterprise.cta")}
+
+
+
+
+
+
+
+ {t("plans.enterprise.featuresTitle")}
+
+
+
+ {getFeatures("enterprise").map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/billing/description-list.tsx b/apps/react-router/saas-template/app/features/billing/description-list.tsx
new file mode 100644
index 0000000..e8c7f8d
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/description-list.tsx
@@ -0,0 +1,32 @@
+import type { ComponentProps } from "react";
+
+import { cn } from "~/lib/utils";
+
+export function DescriptionList({ className, ...props }: ComponentProps<"dl">) {
+ return (
+
+ );
+}
+
+export function DescriptionListRow({
+ className,
+ ...props
+}: ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export function DescriptionTerm({ className, ...props }: ComponentProps<"dt">) {
+ return ;
+}
+
+export function DescriptionDetail({
+ className,
+ ...props
+}: ComponentProps<"dd">) {
+ return ;
+}
diff --git a/apps/react-router/saas-template/app/features/billing/edit-billing-email-modal-content.tsx b/apps/react-router/saas-template/app/features/billing/edit-billing-email-modal-content.tsx
new file mode 100644
index 0000000..490337b
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/edit-billing-email-modal-content.tsx
@@ -0,0 +1,94 @@
+import type { SubmissionResult } from "@conform-to/react/future";
+import { useForm } from "@conform-to/react/future";
+import { useTranslation } from "react-i18next";
+import { Form } from "react-router";
+
+import { UPDATE_BILLING_EMAIL_INTENT } from "./billing-constants";
+import { updateBillingEmailSchema } from "./billing-schemas";
+import { Button } from "~/components/ui/button";
+import {
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "~/components/ui/dialog";
+import { Field, FieldError, FieldLabel, FieldSet } from "~/components/ui/field";
+import { Input } from "~/components/ui/input";
+import { Spinner } from "~/components/ui/spinner";
+
+type EditBillingEmailModalContentProps = {
+ billingEmail: string;
+ isUpdatingBillingEmail?: boolean;
+ lastResult?: SubmissionResult;
+};
+
+export function EditBillingEmailModalContent({
+ billingEmail,
+ isUpdatingBillingEmail = false,
+ lastResult,
+}: EditBillingEmailModalContentProps) {
+ const { t } = useTranslation("billing", {
+ keyPrefix: "billingPage.updateBillingEmailModal",
+ });
+
+ const { form, fields } = useForm(updateBillingEmailSchema, {
+ lastResult,
+ });
+
+ return (
+
+
+ {t("title")}
+
+ {t("description")}
+
+
+
+
+
+
+ {t("emailLabel")}
+
+
+
+
+
+
+
+
+
+
+
+ {isUpdatingBillingEmail ? (
+ <>
+
+ {t("savingChanges")}
+ >
+ ) : (
+ t("submitButton")
+ )}
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/billing/pricing.tsx b/apps/react-router/saas-template/app/features/billing/pricing.tsx
new file mode 100644
index 0000000..0a00014
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/pricing.tsx
@@ -0,0 +1,117 @@
+import type { ComponentProps } from "react";
+
+import { Badge } from "~/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { cn } from "~/lib/utils";
+
+export function TierContainer({ className, ...props }: ComponentProps<"div">) {
+ return
;
+}
+
+export function TierGrid({ className, ...props }: ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export function TierCard(props: ComponentProps) {
+ return ;
+}
+
+export function TierCardHeader({
+ className,
+ ...props
+}: ComponentProps) {
+ return ;
+}
+
+export function TierCardTitle({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+ );
+}
+
+export function TierCardPrice({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+ );
+}
+
+export function OfferBadge({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+ );
+}
+
+export function TierCardDescription(
+ props: ComponentProps,
+) {
+ return ;
+}
+
+export function TierCardContent({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+ );
+}
+
+export function FeaturesListTitle({
+ className,
+ ...props
+}: ComponentProps<"p">) {
+ return
;
+}
+
+export function FeaturesList({ className, ...props }: ComponentProps<"ul">) {
+ return ;
+}
+
+export function FeatureListItem({ className, ...props }: ComponentProps<"li">) {
+ return (
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/billing/stripe-admin.server.ts b/apps/react-router/saas-template/app/features/billing/stripe-admin.server.ts
new file mode 100644
index 0000000..8dd66ea
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/stripe-admin.server.ts
@@ -0,0 +1,36 @@
+import Stripe from "stripe";
+
+// Why is this needed?
+// See: https://github.com/nock/nock/issues/2785#issuecomment-2427076034
+const isTestEnvironment = Boolean(process.env.MOCKS ?? process.env.VITEST);
+
+/**
+ * A passthrough wrapper around the global `fetch` function.
+ *
+ * This is necessary because passing `fetch` directly to
+ * `Stripe.createFetchHttpClient` does not guarantee correct `this` binding
+ * in all environments (such as Node.js or test runners).
+ *
+ * In particular, in certain test environments (e.g., using MSW, Nock, or when
+ * mocks are applied), passing `fetch` point-free (i.e., just `fetch`) may
+ * result in `this` being undefined, leading to unexpected errors like
+ * `TypeError: Illegal invocation`.
+ *
+ * Wrapping `fetch` inside a new function (`passthroughFetch`) ensures:
+ * - Correct argument forwarding
+ * - Proper binding of `this` context (implicitly bound to `globalThis`)
+ * - More predictable async behavior across environments
+ *
+ * See also: https://github.com/nock/nock/issues/2785#issuecomment-2427076034
+ *
+ * @param args - The arguments to pass to `fetch`, matching
+ * `Parameters`.
+ * @returns A `Promise` from calling the global `fetch`.
+ */
+const passthroughFetch = (...args: Parameters) => fetch(...args);
+
+export const stripeAdmin = new Stripe(process.env.STRIPE_SECRET_KEY, {
+ httpClient: isTestEnvironment
+ ? Stripe.createFetchHttpClient(passthroughFetch)
+ : undefined,
+});
diff --git a/apps/react-router/saas-template/app/features/billing/stripe-event-factories.server.ts b/apps/react-router/saas-template/app/features/billing/stripe-event-factories.server.ts
new file mode 100644
index 0000000..e435c74
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/stripe-event-factories.server.ts
@@ -0,0 +1,201 @@
+import { faker } from "@faker-js/faker";
+import { createId } from "@paralleldrive/cuid2";
+import type { Stripe } from "stripe";
+
+import { createPopulatedOrganization } from "../organizations/organizations-factories.server";
+import { createPopulatedUserAccount } from "../user-accounts/user-accounts-factories.server";
+import {
+ createStripeCheckoutSessionFactory,
+ createStripeCustomerFactory,
+ createStripePriceFactory,
+ createStripeProductFactory,
+ createStripeSubscriptionFactory,
+ createStripeSubscriptionScheduleFactory,
+} from "./stripe-factories.server";
+import type { Factory } from "~/utils/types";
+
+/**
+ * Base factory for all Stripe.Event fields _except_ `data` & `type`.
+ */
+export const createStripeEventFactory: Factory<
+ Omit
+> = ({
+ id = `evt_${createId()}`,
+ object = "event",
+ api_version = "2025-04-30.basil",
+ created = Math.floor(faker.date.recent({ days: 10 }).getTime() / 1000),
+ livemode = false,
+ pending_webhooks = faker.number.int({ max: 5, min: 1 }),
+ request = {
+ id: null,
+ idempotency_key: faker.string.uuid(),
+ },
+} = {}) => ({
+ api_version,
+ created,
+ id,
+ livemode,
+ object,
+ pending_webhooks,
+ request,
+});
+
+export const createStripeCheckoutSessionCompletedEventFactory: Factory<
+ Stripe.CheckoutSessionCompletedEvent
+> = ({
+ data = { object: createStripeCheckoutSessionFactory() },
+ type = "checkout.session.completed",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripeCustomerDeletedEventFactory: Factory<
+ Stripe.CustomerDeletedEvent
+> = ({
+ data = { object: createStripeCustomerFactory() },
+ type = "customer.deleted",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripeCustomerSubscriptionCreatedEventFactory: Factory<
+ Stripe.CustomerSubscriptionCreatedEvent
+> = ({
+ data = {
+ object: createStripeSubscriptionFactory({
+ automatic_tax: {
+ disabled_reason: null,
+ enabled: true,
+ liability: { type: "self" as const },
+ },
+ default_payment_method: `pm_${createId()}`,
+ metadata: {
+ organizationId: createPopulatedOrganization().id,
+ organizationSlug: createPopulatedOrganization().slug,
+ purchasedById: createPopulatedUserAccount().id,
+ },
+ payment_settings: {
+ payment_method_options: {
+ acss_debit: null,
+ bancontact: null,
+ card: {
+ network: null,
+ request_three_d_secure: "automatic" as const,
+ },
+ customer_balance: null,
+ konbini: null,
+ sepa_debit: null,
+ us_bank_account: null,
+ },
+ payment_method_types: null,
+ save_default_payment_method:
+ "off" as Stripe.Subscription.PaymentSettings.SaveDefaultPaymentMethod,
+ },
+ }),
+ },
+ type = "customer.subscription.created",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripeCustomerSubscriptionDeletedEventFactory: Factory<
+ Stripe.CustomerSubscriptionDeletedEvent
+> = ({
+ data = {
+ object: createStripeSubscriptionFactory({
+ canceled_at: Math.floor(Date.now() / 1000),
+ ended_at: Math.floor(Date.now() / 1000),
+ metadata: {
+ organizationId: createPopulatedOrganization().id,
+ purchasedById: createPopulatedUserAccount().id,
+ },
+ status: "canceled",
+ }),
+ },
+ type = "customer.subscription.deleted",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripeCustomerSubscriptionUpdatedEventFactory: Factory<
+ Stripe.CustomerSubscriptionUpdatedEvent
+> = ({
+ data = {
+ object: createStripeSubscriptionFactory({
+ metadata: {
+ organizationId: createPopulatedOrganization().id,
+ purchasedById: createPopulatedUserAccount().id,
+ },
+ }),
+ },
+ type = "customer.subscription.updated",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripeSubscriptionScheduleCreatedEventFactory: Factory<
+ Stripe.SubscriptionScheduleCreatedEvent
+> = ({
+ data = { object: createStripeSubscriptionScheduleFactory() },
+ type = "subscription_schedule.created",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripePriceCreatedEventFactory: Factory<
+ Stripe.PriceCreatedEvent
+> = ({
+ data = { object: createStripePriceFactory() },
+ type = "price.created",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripePriceDeletedEventFactory: Factory<
+ Stripe.PriceDeletedEvent
+> = ({
+ data = { object: createStripePriceFactory() },
+ type = "price.deleted",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripePriceUpdatedEventFactory: Factory<
+ Stripe.PriceUpdatedEvent
+> = ({
+ data = { object: createStripePriceFactory() },
+ type = "price.updated",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripeProductCreatedEventFactory: Factory<
+ Stripe.ProductCreatedEvent
+> = ({
+ data = { object: createStripeProductFactory() },
+ type = "product.created",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripeProductDeletedEventFactory: Factory<
+ Stripe.ProductDeletedEvent
+> = ({
+ data = { object: createStripeProductFactory() },
+ type = "product.deleted",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripeProductUpdatedEventFactory: Factory<
+ Stripe.ProductUpdatedEvent
+> = ({
+ data = { object: createStripeProductFactory() },
+ type = "product.updated",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripeSubscriptionScheduleExpiringEventFactory: Factory<
+ Stripe.SubscriptionScheduleExpiringEvent
+> = ({
+ data = { object: createStripeSubscriptionScheduleFactory() },
+ type = "subscription_schedule.expiring",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
+
+export const createStripeSubscriptionScheduleUpdatedEventFactory: Factory<
+ Stripe.SubscriptionScheduleUpdatedEvent
+> = ({
+ data = { object: createStripeSubscriptionScheduleFactory() },
+ type = "subscription_schedule.updated",
+ ...rest
+} = {}) => ({ ...createStripeEventFactory(rest), data, type });
diff --git a/apps/react-router/saas-template/app/features/billing/stripe-event-handlers.server.ts b/apps/react-router/saas-template/app/features/billing/stripe-event-handlers.server.ts
new file mode 100644
index 0000000..90ee434
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/stripe-event-handlers.server.ts
@@ -0,0 +1,327 @@
+import type { Stripe } from "stripe";
+
+import { updateOrganizationInDatabaseById } from "../organizations/organizations-model.server";
+import { updateStripeCustomer } from "./stripe-helpers.server";
+import {
+ deleteStripePriceFromDatabaseById,
+ saveStripePriceFromAPIToDatabase,
+ updateStripePriceFromAPIInDatabase,
+} from "./stripe-prices-model.server";
+import {
+ deleteStripeProductFromDatabaseById,
+ saveStripeProductFromAPIToDatabase,
+ updateStripeProductFromAPIInDatabase,
+} from "./stripe-product-model.server";
+import {
+ createStripeSubscriptionInDatabase,
+ updateStripeSubscriptionFromAPIInDatabase,
+} from "./stripe-subscription-model.server";
+import {
+ saveStripeSubscriptionScheduleFromAPIToDatabase,
+ updateStripeSubscriptionScheduleFromAPIInDatabase,
+} from "./stripe-subscription-schedule-model.server";
+import { stripeAdmin } from "~/features/billing/stripe-admin.server";
+import { getErrorMessage } from "~/utils/get-error-message";
+
+const ok = () => Response.json({ message: "OK" });
+
+const prettyPrint = (event: Stripe.Event) => {
+ console.log(
+ `unhandled Stripe event: ${event.type}`,
+ process.env.NODE_ENV === "development"
+ ? JSON.stringify(event, null, 2)
+ : "event not logged in production mode - look it up in the Stripe Dashboard",
+ );
+};
+
+export const handleStripeChargeDisputeClosedEvent = async (
+ event: Stripe.ChargeDisputeClosedEvent,
+) => {
+ const dispute = event.data.object;
+
+ // only cancel if the dispute was lost (cardholder won)
+ if (dispute.status !== "lost") {
+ return ok();
+ }
+
+ try {
+ // normalize dispute.charge → string ID
+ const chargeId =
+ typeof dispute.charge === "string" ? dispute.charge : dispute.charge.id;
+
+ // fetch the Charge
+ const charge = await stripeAdmin.charges.retrieve(chargeId);
+
+ // extract customer ID
+ const customerId =
+ typeof charge.customer === "string"
+ ? charge.customer
+ : charge.customer?.id;
+ if (!customerId) {
+ console.log("No customer associated with charge", charge.id);
+ return ok();
+ }
+
+ // list active subscriptions for that customer
+ const subsList = await stripeAdmin.subscriptions.list({
+ customer: customerId,
+ limit: 1, // just need one
+ status: "active",
+ });
+
+ if (subsList.data.length === 0) {
+ console.log(`No active subscriptions for customer ${customerId}`);
+ return ok();
+ }
+
+ // cancel the first one (or adjust logic if you need something more nuanced)
+ const cancelled = await stripeAdmin.subscriptions.cancel(
+ // biome-ignore lint/style/noNonNullAssertion: The check above ensures that there is a subscription
+ subsList.data[0]!.id,
+ );
+
+ console.log(
+ "Automatically cancelled subscription due to lost dispute:",
+ cancelled.id,
+ );
+ } catch (error) {
+ prettyPrint(event);
+ console.error(
+ "Error cancelling subscription on dispute.closed",
+ getErrorMessage(error),
+ );
+ }
+
+ return ok();
+};
+
+export const handleStripeCheckoutSessionCompletedEvent = async (
+ event: Stripe.CheckoutSessionCompletedEvent,
+) => {
+ try {
+ if (event.data.object.metadata?.organizationId) {
+ const organization = await updateOrganizationInDatabaseById({
+ id: event.data.object.metadata.organizationId,
+ organization: {
+ ...(event.data.object.customer_details?.email && {
+ billingEmail: event.data.object.customer_details.email,
+ }),
+ ...(typeof event.data.object.customer === "string" && {
+ stripeCustomerId: event.data.object.customer,
+ }),
+ // End the trial now.
+ trialEnd: new Date(),
+ },
+ });
+
+ if (typeof event.data.object.customer === "string") {
+ await updateStripeCustomer({
+ customerId: event.data.object.customer,
+ customerName: organization.name,
+ organizationId: organization.id,
+ });
+ }
+ } else {
+ console.error("No organization ID found in checkout session metadata");
+ prettyPrint(event);
+ }
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error(
+ "Error handling Stripe checkout session completed event",
+ message,
+ );
+ }
+
+ return ok();
+};
+
+export const handleStripeCustomerDeletedEvent = async (
+ event: Stripe.CustomerDeletedEvent,
+) => {
+ try {
+ if (event.data.object.metadata?.organizationId) {
+ await updateOrganizationInDatabaseById({
+ id: event.data.object.metadata.organizationId,
+ organization: { stripeCustomerId: null },
+ });
+ } else {
+ prettyPrint(event);
+ }
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error handling Stripe customer deleted event", message);
+ }
+
+ return ok();
+};
+
+export const handleStripeCustomerSubscriptionCreatedEvent = async (
+ event: Stripe.CustomerSubscriptionCreatedEvent,
+) => {
+ try {
+ await createStripeSubscriptionInDatabase(event.data.object);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error creating Stripe subscription", message);
+ }
+
+ return ok();
+};
+
+export const handleStripeCustomerSubscriptionDeletedEvent = async (
+ event: Stripe.CustomerSubscriptionDeletedEvent,
+) => {
+ try {
+ await updateStripeSubscriptionFromAPIInDatabase(event.data.object);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error updating deleted Stripe subscription", message);
+ }
+
+ return ok();
+};
+
+export const handleStripeCustomerSubscriptionUpdatedEvent = async (
+ event: Stripe.CustomerSubscriptionUpdatedEvent,
+) => {
+ try {
+ await updateStripeSubscriptionFromAPIInDatabase(event.data.object);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error updating Stripe subscription", message);
+ }
+
+ return ok();
+};
+
+export const handleStripePriceCreatedEvent = async (
+ event: Stripe.PriceCreatedEvent,
+) => {
+ try {
+ await saveStripePriceFromAPIToDatabase(event.data.object);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error creating Stripe price", message);
+ }
+
+ return ok();
+};
+
+export const handleStripePriceDeletedEvent = async (
+ event: Stripe.PriceDeletedEvent,
+) => {
+ try {
+ await deleteStripePriceFromDatabaseById(event.data.object.id);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error deleting Stripe price", message);
+ }
+
+ return ok();
+};
+
+export const handleStripePriceUpdatedEvent = async (
+ event: Stripe.PriceUpdatedEvent,
+) => {
+ try {
+ await updateStripePriceFromAPIInDatabase(event.data.object);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error updating Stripe price", message);
+ }
+
+ return ok();
+};
+
+export const handleStripeProductCreatedEvent = async (
+ event: Stripe.ProductCreatedEvent,
+) => {
+ try {
+ await saveStripeProductFromAPIToDatabase(event.data.object);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error creating Stripe product", message);
+ }
+
+ return ok();
+};
+
+export const handleStripeProductDeletedEvent = async (
+ event: Stripe.ProductDeletedEvent,
+) => {
+ try {
+ await deleteStripeProductFromDatabaseById(event.data.object.id);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error deleting Stripe product", message);
+ }
+
+ return ok();
+};
+
+export const handleStripeProductUpdatedEvent = async (
+ event: Stripe.ProductUpdatedEvent,
+) => {
+ try {
+ await updateStripeProductFromAPIInDatabase(event.data.object);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error updating Stripe product", message);
+ }
+
+ return ok();
+};
+
+export const handleStripeSubscriptionScheduleCreatedEvent = async (
+ event: Stripe.SubscriptionScheduleCreatedEvent,
+) => {
+ try {
+ await saveStripeSubscriptionScheduleFromAPIToDatabase(event.data.object);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error creating Stripe subscription schedule", message);
+ }
+
+ return ok();
+};
+
+export const handleStripeSubscriptionScheduleExpiringEvent = async (
+ event: Stripe.SubscriptionScheduleExpiringEvent,
+) => {
+ try {
+ await updateStripeSubscriptionScheduleFromAPIInDatabase(event.data.object);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error updating Stripe subscription schedule", message);
+ }
+
+ return ok();
+};
+
+export const handleStripeSubscriptionScheduleUpdatedEvent = async (
+ event: Stripe.SubscriptionScheduleUpdatedEvent,
+) => {
+ try {
+ await updateStripeSubscriptionScheduleFromAPIInDatabase(event.data.object);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ prettyPrint(event);
+ console.error("Error updating Stripe subscription schedule", message);
+ }
+
+ return ok();
+};
diff --git a/apps/react-router/saas-template/app/features/billing/stripe-factories.server.ts b/apps/react-router/saas-template/app/features/billing/stripe-factories.server.ts
new file mode 100644
index 0000000..80aca4c
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/stripe-factories.server.ts
@@ -0,0 +1,683 @@
+import { faker } from "@faker-js/faker";
+import { createId } from "@paralleldrive/cuid2";
+import type { Stripe } from "stripe";
+
+import { createPopulatedOrganization } from "../organizations/organizations-factories.server";
+import { createPopulatedUserAccount } from "../user-accounts/user-accounts-factories.server";
+import type { Factory } from "~/utils/types";
+
+/**
+ * Creates a Stripe Product object with populated values.
+ */
+export const createStripeProductFactory: Factory = ({
+ id = `prod_${createId()}`,
+ object = "product",
+ active = true,
+ created = Math.floor(faker.date.recent({ days: 10 }).getTime() / 1000),
+ default_price = null,
+ description = null,
+ images = [],
+ livemode = false,
+ marketing_features = [],
+ metadata = {
+ max_seats: "1",
+ },
+ name = "Hobby Plan",
+ package_dimensions = null,
+ shippable = null,
+ statement_descriptor = null,
+ tax_code = "txcd_10103001",
+ type = "service",
+ unit_label = "seat",
+ updated = created,
+ url = null,
+} = {}) => ({
+ active,
+ created,
+ default_price,
+ description,
+ id,
+ images,
+ livemode,
+ marketing_features,
+ metadata,
+ name,
+ object,
+ package_dimensions,
+ shippable,
+ statement_descriptor,
+ tax_code,
+ type,
+ unit_label,
+ updated,
+ url,
+});
+
+/**
+ * Creates a Stripe Customer object with populated values.
+ */
+export const createStripeCustomerFactory: Factory = ({
+ id = `cus_${createId()}`,
+ object = "customer",
+ address = null,
+ balance = 0,
+ // realistic created timestamp within last 10 days
+ created = Math.floor(faker.date.recent({ days: 10 }).getTime() / 1000),
+ currency = null,
+ default_source = null,
+ delinquent = false,
+ description = null,
+ email = faker.internet.email(),
+ invoice_prefix = faker.string.alphanumeric(8).toUpperCase(),
+ invoice_settings = {
+ custom_fields: null,
+ default_payment_method: null,
+ footer: null,
+ rendering_options: null,
+ },
+ livemode = false,
+ metadata = {},
+ name = faker.person.fullName(),
+ next_invoice_sequence = faker.number.int({ max: 10, min: 1 }),
+ phone = null,
+ preferred_locales = [],
+ shipping = null,
+ tax_exempt = "none",
+ test_clock = null,
+} = {}) => ({
+ address,
+ balance,
+ created,
+ currency,
+ default_source,
+ delinquent,
+ description,
+ email,
+ id,
+ invoice_prefix,
+ invoice_settings,
+ livemode,
+ metadata,
+ name,
+ next_invoice_sequence,
+ object,
+ phone,
+ preferred_locales,
+ shipping,
+ tax_exempt,
+ test_clock,
+});
+
+/**
+ * Creates a Stripe Customer Portal Session object with populated values.
+ */
+export const createStripeCustomerPortalSessionFactory: Factory<
+ Stripe.BillingPortal.Session
+> = ({
+ id = `bps_${createId()}`,
+ object = "billing_portal.session",
+ configuration = `bpc_${createId()}`,
+ // realistic created timestamp within last 10 days
+ created = Math.floor(faker.date.recent({ days: 10 }).getTime() / 1000),
+ customer = createStripeCustomerFactory().id,
+ flow = null,
+ livemode = false,
+ locale = null,
+ on_behalf_of = null,
+ // default to a random URL, overrideable in tests
+ return_url = faker.internet.url(),
+ // Stripe-hosted portal URL
+ url = `https://billing.stripe.com/p/session/test_${createId()}`,
+} = {}) => ({
+ configuration,
+ created,
+ customer,
+ flow,
+ id,
+ livemode,
+ locale,
+ object,
+ on_behalf_of,
+ return_url,
+ url,
+});
+
+/**
+ * Creates a Stripe Price object with populated values.
+ */
+export const createStripePriceFactory: Factory = ({
+ lookup_key = `${faker.word.noun()}-${faker.word.noun()}-${faker.word.noun()}`,
+ id = `price_${createId()}`,
+ object = "price",
+ active = true,
+ billing_scheme = "per_unit",
+ // realistic creation within last month
+ created = Math.floor(faker.date.past().getTime() / 1000),
+ currency = "usd",
+ custom_unit_amount = null,
+ livemode = false,
+ metadata = {},
+ nickname = null,
+ product = `prod_${createId()}`,
+ recurring = {
+ interval: faker.helpers.arrayElement([
+ "month",
+ "year",
+ ]) as Stripe.Price.Recurring.Interval,
+ interval_count: 1,
+ meter: null,
+ trial_period_days: null,
+ usage_type: "licensed" as Stripe.Price.Recurring.UsageType,
+ },
+ tax_behavior = "unspecified",
+ tiers_mode = null,
+ transform_quantity = null,
+ type = "recurring",
+ unit_amount = faker.number.int({ max: 5000, min: 500, multipleOf: 100 }),
+ unit_amount_decimal = String(
+ faker.number.int({ max: 5000, min: 500, multipleOf: 100 }),
+ ),
+} = {}) => ({
+ active,
+ billing_scheme,
+ created,
+ currency,
+ custom_unit_amount,
+ id,
+ livemode,
+ lookup_key,
+ metadata,
+ nickname,
+ object,
+ product,
+ recurring,
+ tax_behavior,
+ tiers_mode,
+ transform_quantity,
+ type,
+ unit_amount,
+ unit_amount_decimal,
+});
+
+/**
+ * Creates a Stripe SubscriptionItem object with populated values.
+ */
+export const createStripeSubscriptionItemFactory: Factory<
+ Stripe.SubscriptionItem
+> = ({
+ id = `si_${createId()}`,
+ object = "subscription_item",
+ // realistic created within last 5 days
+ created = Math.floor(faker.date.recent({ days: 5 }).getTime() / 1000),
+ discounts = [],
+ metadata = {},
+ plan = {} as Stripe.Plan, // deprecated in favor of price
+ price = createStripePriceFactory(),
+ quantity = faker.number.int({ max: 5, min: 1 }),
+ subscription = `sub_${createId()}`,
+ current_period_start = created,
+ // realistic period end ~30 days after start
+ current_period_end = Math.floor(
+ faker.date.soon({ days: 30, refDate: new Date(created * 1000) }).getTime() /
+ 1000,
+ ),
+ tax_rates = [],
+ billing_thresholds = null,
+} = {}) => ({
+ billing_thresholds,
+ created,
+ current_period_end,
+ current_period_start,
+ discounts,
+ id,
+ metadata,
+ object,
+ plan,
+ price,
+ quantity,
+ subscription,
+ tax_rates,
+});
+
+/**
+ * Creates a Stripe Subscription object with populated values.
+ */
+export const createStripeSubscriptionFactory: Factory = ({
+ id = `sub_${createId()}`,
+ object = "subscription",
+ application = null,
+ application_fee_percent = null,
+ automatic_tax = { disabled_reason: null, enabled: false, liability: null },
+ // realistic dates: created and cycle anchor within last week
+ created = Math.floor(faker.date.recent({ days: 7 }).getTime() / 1000),
+ billing_mode = {
+ flexible: null,
+ type: "flexible" as Stripe.Subscription.BillingMode.Type,
+ },
+ billing_cycle_anchor = created,
+ billing_cycle_anchor_config = null,
+ cancel_at = null,
+ cancel_at_period_end = false,
+ canceled_at = null,
+ cancellation_details = { comment: null, feedback: null, reason: null },
+ collection_method = "charge_automatically",
+ currency = "usd",
+ customer = createStripeCustomerFactory().id,
+ days_until_due = null,
+ default_payment_method = null,
+ default_source = null,
+ default_tax_rates = [],
+ description = null,
+ discounts = [],
+ ended_at = null,
+ invoice_settings = {
+ account_tax_ids: null,
+ issuer: { type: "self" as Stripe.Invoice.Issuer.Type },
+ },
+ items: itemsParameter,
+ latest_invoice = `in_${createId()}`,
+ livemode = false,
+ metadata = {},
+ next_pending_invoice_item_invoice = null,
+ on_behalf_of = null,
+ pause_collection = null,
+ payment_settings = {
+ payment_method_options: null,
+ payment_method_types: null,
+ save_default_payment_method:
+ "off" as Stripe.Subscription.PaymentSettings.SaveDefaultPaymentMethod,
+ },
+ pending_invoice_item_interval = null,
+ pending_setup_intent = null,
+ pending_update = null,
+ schedule = null,
+ start_date = created,
+ status = "active",
+ test_clock = null,
+ transfer_data = null,
+ trial_end = null,
+ trial_settings = {
+ end_behavior: {
+ missing_payment_method:
+ "create_invoice" as Stripe.Subscription.TrialSettings.EndBehavior.MissingPaymentMethod,
+ },
+ },
+ trial_start = null,
+ billing_thresholds = null,
+} = {}) => {
+ const defaultItem = createStripeSubscriptionItemFactory({
+ // align periods with subscription dates
+ created,
+ current_period_start: created,
+ subscription: id,
+ });
+ const items = itemsParameter ?? {
+ data: [defaultItem],
+ has_more: false,
+ object: "list",
+ total_count: 1,
+ url: `/v1/subscription_items?subscription=${id}`,
+ };
+
+ return {
+ application,
+ application_fee_percent,
+ automatic_tax,
+ billing_cycle_anchor,
+ billing_cycle_anchor_config,
+ billing_mode,
+ billing_thresholds,
+ cancel_at,
+ cancel_at_period_end,
+ canceled_at,
+ cancellation_details,
+ collection_method,
+ created,
+ currency,
+ customer,
+ days_until_due,
+ default_payment_method,
+ default_source,
+ default_tax_rates,
+ description,
+ discounts,
+ ended_at,
+ id,
+ invoice_settings,
+ items,
+ latest_invoice,
+ livemode,
+ metadata,
+ next_pending_invoice_item_invoice,
+ object,
+ on_behalf_of,
+ pause_collection,
+ payment_settings,
+ pending_invoice_item_interval,
+ pending_setup_intent,
+ pending_update,
+ schedule,
+ start_date,
+ status,
+ test_clock,
+ transfer_data,
+ trial_end,
+ trial_settings,
+ trial_start,
+ };
+};
+
+/**
+ * Creates a Stripe Checkout Session object with populated values.
+ */
+export const createStripeCheckoutSessionFactory: Factory<
+ Stripe.Checkout.Session
+> = ({
+ id = `cs_${createId()}`,
+ object = "checkout.session",
+ adaptive_pricing = null,
+ after_expiration = null,
+ allow_promotion_codes = null,
+ amount_subtotal = faker.number.int({ max: 100_000, min: 1000 }),
+ amount_total = amount_subtotal,
+ automatic_tax = {
+ enabled: true,
+ liability: { type: "self" as const },
+ provider: "stripe" as const,
+ status: "complete" as const,
+ },
+ billing_address_collection = "auto",
+ cancel_url = faker.internet.url(),
+ client_reference_id = null,
+ client_secret = null,
+ collected_information = {
+ business_name: null,
+ individual_name: null,
+ shipping_details: null,
+ },
+ consent = null,
+ consent_collection = null,
+ created = Math.floor(faker.date.recent({ days: 10 }).getTime() / 1000),
+ currency = "usd",
+ currency_conversion = null,
+ custom_fields = [],
+ custom_text = {
+ after_submit: null,
+ shipping_address: null,
+ submit: null,
+ terms_of_service_acceptance: null,
+ },
+ customer = createStripeCustomerFactory().id,
+ customer_creation = "always",
+ customer_details = {
+ address: {
+ city: null,
+ country: "CH",
+ line1: null,
+ line2: null,
+ postal_code: null,
+ state: null,
+ },
+ business_name: null,
+ email: faker.internet.email(),
+ individual_name: null,
+ name: faker.person.fullName(),
+ phone: null,
+ tax_exempt: "none" as Stripe.Checkout.Session.CustomerDetails.TaxExempt,
+ tax_ids: [],
+ },
+ customer_email = null,
+ discounts = [],
+ expires_at = created + 86_400, // 24 hours from creation
+ invoice = `in_${createId()}`,
+ invoice_creation = null,
+ livemode = false,
+ locale = null,
+ metadata = {
+ customerEmail: createPopulatedOrganization().billingEmail,
+ organizationId: createPopulatedOrganization().id,
+ organizationSlug: createPopulatedOrganization().slug,
+ purchasedById: createPopulatedUserAccount().email,
+ },
+ mode = "subscription",
+ origin_context = "web",
+ payment_intent = null,
+ payment_link = null,
+ payment_method_collection = "always",
+ payment_method_configuration_details = {
+ id: `pmc_${createId()}`,
+ parent: null,
+ },
+ payment_method_options = {
+ card: {
+ request_three_d_secure: "automatic" as const,
+ },
+ },
+ payment_method_types = ["card", "link"],
+ payment_status = "paid",
+ permissions = null,
+ phone_number_collection = {
+ enabled: false,
+ },
+ recovered_from = null,
+ saved_payment_method_options = {
+ allow_redisplay_filters: [
+ "always",
+ ] as Stripe.Checkout.Session.SavedPaymentMethodOptions.AllowRedisplayFilter[],
+ payment_method_remove: null,
+ payment_method_save: "enabled" as const,
+ },
+ setup_intent = null,
+ shipping_address_collection = null,
+ shipping_cost = null,
+ shipping_options = [],
+ status = "complete",
+ submit_type = null,
+ subscription = createStripeSubscriptionFactory().id,
+ success_url = faker.internet.url(),
+ total_details = {
+ amount_discount: 0,
+ amount_shipping: 0,
+ amount_tax: 0,
+ },
+ ui_mode = "hosted",
+ url = `https://checkout.stripe.com/pay/${id}`,
+ wallet_options = null,
+} = {}) => ({
+ adaptive_pricing,
+ after_expiration,
+ allow_promotion_codes,
+ amount_subtotal,
+ amount_total,
+ automatic_tax,
+ billing_address_collection,
+ cancel_url,
+ client_reference_id,
+ client_secret,
+ collected_information,
+ consent,
+ consent_collection,
+ created,
+ currency,
+ currency_conversion,
+ custom_fields,
+ custom_text,
+ customer,
+ customer_creation,
+ customer_details,
+ customer_email,
+ discounts,
+ expires_at,
+ id,
+ invoice,
+ invoice_creation,
+ livemode,
+ locale,
+ metadata,
+ mode,
+ object,
+ origin_context,
+ payment_intent,
+ payment_link,
+ payment_method_collection,
+ payment_method_configuration_details,
+ payment_method_options,
+ payment_method_types,
+ payment_status,
+ permissions,
+ phone_number_collection,
+ recovered_from,
+ saved_payment_method_options,
+ setup_intent,
+ shipping_address_collection,
+ shipping_cost,
+ shipping_options,
+ status,
+ submit_type,
+ subscription,
+ success_url,
+ total_details,
+ ui_mode,
+ url,
+ wallet_options,
+});
+
+/**
+ * Creates a Stripe SubscriptionSchedulePhase object with populated values.
+ */
+export const createStripeSubscriptionSchedulePhaseFactory: Factory<
+ Stripe.SubscriptionSchedule.Phase
+> = ({
+ add_invoice_items = [],
+ application_fee_percent = null,
+ automatic_tax = {
+ disabled_reason: null,
+ enabled: true,
+ liability: { type: "self" as const },
+ },
+ billing_cycle_anchor = null,
+ collection_method = null,
+ currency = "usd",
+ default_payment_method = null,
+ default_tax_rates = [],
+ description = null,
+ discounts = [],
+ end_date = Math.floor(faker.date.future().getTime() / 1000),
+ invoice_settings = null,
+ items = [
+ {
+ billing_thresholds: null,
+ discounts: [],
+ metadata: {},
+ plan: `price_${createId()}`,
+ price: `price_${createId()}`,
+ quantity: 1,
+ tax_rates: [],
+ },
+ ],
+ metadata = {},
+ on_behalf_of = null,
+ proration_behavior = "create_prorations" as const,
+ start_date = Math.floor(faker.date.recent().getTime() / 1000),
+ transfer_data = null,
+ trial_end = null,
+ billing_thresholds = null,
+} = {}) => ({
+ add_invoice_items,
+ application_fee_percent,
+ automatic_tax,
+ billing_cycle_anchor,
+ billing_thresholds,
+ collection_method,
+ currency,
+ default_payment_method,
+ default_tax_rates,
+ description,
+ discounts,
+ end_date,
+ invoice_settings,
+ items,
+ metadata,
+ on_behalf_of,
+ proration_behavior,
+ start_date,
+ transfer_data,
+ trial_end,
+});
+
+/**
+ * Creates a Stripe SubscriptionSchedule object with populated values.
+ */
+export const createStripeSubscriptionScheduleFactory: Factory<
+ Stripe.SubscriptionSchedule
+> = ({
+ id = `sub_sched_${createId()}`,
+ object = "subscription_schedule" as const,
+ application = null,
+ billing_mode = {
+ flexible: null,
+ type: "flexible" as Stripe.Subscription.BillingMode.Type,
+ },
+ canceled_at = null,
+ completed_at = null,
+ created = Math.floor(faker.date.recent().getTime() / 1000),
+ current_phase = {
+ end_date: Math.floor(faker.date.future().getTime() / 1000),
+ start_date: Math.floor(faker.date.recent().getTime() / 1000),
+ },
+ customer = createStripeCustomerFactory().id,
+ default_settings = {
+ application_fee_percent: null,
+ automatic_tax: {
+ disabled_reason: null,
+ enabled: true,
+ liability: {
+ type: "self" as const,
+ },
+ },
+ billing_cycle_anchor: "automatic" as const,
+ billing_thresholds: null,
+ collection_method: "charge_automatically" as const,
+ default_payment_method: `pm_${createId()}`,
+ default_source: null,
+ description: null,
+ invoice_settings: {
+ account_tax_ids: null,
+ days_until_due: null,
+ issuer: {
+ type: "self" as const,
+ },
+ },
+ on_behalf_of: null,
+ transfer_data: null,
+ },
+ end_behavior = "release" as const,
+ livemode = false,
+ metadata = {},
+ phases = [createStripeSubscriptionSchedulePhaseFactory()],
+ released_at = null,
+ released_subscription = null,
+ status = "active" as const,
+ subscription = createStripeSubscriptionFactory().id,
+ test_clock = null,
+} = {}) => ({
+ application,
+ billing_mode,
+ canceled_at,
+ completed_at,
+ created,
+ current_phase,
+ customer,
+ default_settings,
+ end_behavior,
+ id,
+ livemode,
+ metadata,
+ object,
+ phases,
+ released_at,
+ released_subscription,
+ status,
+ subscription,
+ test_clock,
+});
diff --git a/apps/react-router/saas-template/app/features/billing/stripe-helpers.server.ts b/apps/react-router/saas-template/app/features/billing/stripe-helpers.server.ts
new file mode 100644
index 0000000..9ec7b88
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/stripe-helpers.server.ts
@@ -0,0 +1,347 @@
+import { href } from "react-router";
+import type Stripe from "stripe";
+
+import { stripeAdmin } from "~/features/billing/stripe-admin.server";
+import type { Organization, UserAccount } from "~/generated/client";
+
+/**
+ * Creates a Stripe Checkout Session for a subscription purchase or update.
+ *
+ * @param baseUrl - Your app's public URL (e.g., https://app.example.com).
+ * @param customerEmail - The billing email for the customer/organization.
+ * @param customerId - The Stripe customer ID, if already created; omit to
+ * create a new customer.
+ * @param organizationId - The Prisma ID of the organization.
+ * @param organizationSlug - The slug of the organization for constructing
+ * return URLs.
+ * @param priceId - The Stripe Price ID to subscribe or update to.
+ * @param purchasedById - The UserAccount ID of who initiated the purchase.
+ * @param seatsUsed - Number of seats (quantity) to include in the subscription.
+ * @returns A Promise that resolves to the Stripe Checkout Session.
+ */
+export async function createStripeCheckoutSession({
+ baseUrl,
+ customerEmail,
+ customerId,
+ organizationId,
+ organizationSlug,
+ priceId,
+ purchasedById,
+ seatsUsed,
+}: {
+ baseUrl: string;
+ customerEmail: Organization["billingEmail"];
+ customerId: Organization["stripeCustomerId"];
+ organizationId: Organization["id"];
+ organizationSlug: Organization["slug"];
+ priceId: string;
+ purchasedById: UserAccount["id"];
+ seatsUsed: number;
+}) {
+ const hasCustomerId = customerId && customerId !== "";
+
+ const session = await stripeAdmin.checkout.sessions.create({
+ automatic_tax: { enabled: true },
+ billing_address_collection: "auto",
+ cancel_url: `${baseUrl}${href(
+ "/organizations/:organizationSlug/settings/billing",
+ { organizationSlug },
+ )}`,
+ customer: hasCustomerId ? customerId : undefined,
+ ...(hasCustomerId && {
+ customer_update: { address: "auto", name: "auto", shipping: "auto" },
+ }),
+ line_items: [{ price: priceId, quantity: seatsUsed }],
+ metadata: {
+ customerEmail,
+ organizationId,
+ organizationSlug,
+ purchasedById,
+ },
+ mode: "subscription",
+ saved_payment_method_options: {
+ payment_method_save: "enabled",
+ },
+ subscription_data: {
+ metadata: {
+ customerEmail,
+ organizationId,
+ organizationSlug,
+ purchasedById,
+ },
+ },
+ success_url: `${baseUrl}${href(
+ "/organizations/:organizationSlug/settings/billing/success",
+ { organizationSlug },
+ )}?session_id={CHECKOUT_SESSION_ID}`,
+ // Show check box to allow purchasing as a business.
+ tax_id_collection: { enabled: true },
+ });
+
+ return session;
+}
+
+/**
+ * Creates a Stripe Customer Portal session for billing management.
+ *
+ * @param baseUrl - Your app's public URL.
+ * @param customerId - The Stripe customer ID.
+ * @param organizationSlug - The slug of the organization for return URL.
+ * @returns A Promise that resolves to the Stripe Billing Portal Session.
+ */
+export async function createStripeCustomerPortalSession({
+ baseUrl,
+ customerId,
+ organizationSlug,
+}: {
+ baseUrl: string;
+ customerId: string;
+ organizationSlug: string;
+}) {
+ const session = await stripeAdmin.billingPortal.sessions.create({
+ customer: customerId,
+ return_url: `${baseUrl}${href(
+ "/organizations/:organizationSlug/settings/billing",
+ { organizationSlug },
+ )}`,
+ });
+
+ return session;
+}
+
+/**
+ * Creates a Stripe Customer Portal session deep-linking to switch subscription
+ * plans.
+ *
+ * @param baseUrl - Your app's public URL.
+ * @param customerId - The Stripe customer ID.
+ * @param organizationSlug - The organization slug for return URL.
+ * @param subscriptionId - ID of the subscription to update.
+ * @param subscriptionItemId - ID of the subscription item to change.
+ * @param newPriceId - New Stripe Price ID for the subscription item.
+ * @param quantity - The quantity for the updated subscription item.
+ * Must match existing quantity to preserve it.
+ * @returns A Promise that resolves to the Stripe Billing Portal Session.
+ */
+export async function createStripeSwitchPlanSession({
+ baseUrl,
+ customerId,
+ organizationSlug,
+ subscriptionId,
+ subscriptionItemId,
+ newPriceId,
+ quantity,
+}: {
+ baseUrl: string;
+ customerId: string;
+ organizationSlug: Organization["slug"];
+ subscriptionId: string;
+ subscriptionItemId: string;
+ newPriceId: string;
+ /** This MUST be the existing quantity of the subscription item, if you
+ * want to preserve the quantity. Otherwise, Stripe will default to 1.
+ */
+ quantity: number;
+}) {
+ // This will deep-link straight to the "Confirm this update" page
+ const session = await stripeAdmin.billingPortal.sessions.create({
+ customer: customerId,
+ flow_data: {
+ subscription_update_confirm: {
+ items: [{ id: subscriptionItemId, price: newPriceId, quantity }],
+ subscription: subscriptionId,
+ },
+ type: "subscription_update_confirm",
+ },
+ return_url: `${baseUrl}${href(
+ "/organizations/:organizationSlug/settings/billing",
+ { organizationSlug },
+ )}`,
+ });
+
+ return session;
+}
+
+/**
+ * Updates a Stripe customer's email, name, and/or metadata.
+ *
+ * @param customerId - The Stripe customer ID to update.
+ * @param customerName - Optional new name for the customer.
+ * @param customerEmail - Optional new email for the customer.
+ * @param organizationId - Optional organization ID to store in metadata.
+ * @returns A Promise that resolves to the updated Stripe Customer object.
+ */
+export async function updateStripeCustomer({
+ customerId,
+ customerName,
+ customerEmail,
+ organizationId,
+}: {
+ customerId: string;
+ customerName?: string;
+ customerEmail?: string;
+ organizationId?: Organization["id"];
+}) {
+ const customer = await stripeAdmin.customers.update(customerId, {
+ ...(customerEmail ? { email: customerEmail } : {}),
+ ...(customerName ? { name: customerName } : {}),
+ ...(organizationId ? { metadata: { organizationId } } : {}),
+ });
+
+ return customer;
+}
+
+/**
+ * Creates a Stripe Customer Portal session deep-linking to cancel a
+ * subscription.
+ *
+ * @param baseUrl - Your app's public URL.
+ * @param customerId - The Stripe customer ID.
+ * @param organizationSlug - The slug of the organization for return URL.
+ * @param subscriptionId - The Stripe Subscription ID to cancel.
+ * @returns A Promise that resolves to the Stripe Billing Portal Session.
+ */
+export async function createStripeCancelSubscriptionSession({
+ baseUrl,
+ customerId,
+ organizationSlug,
+ subscriptionId,
+}: {
+ /** Your app's public URL (e.g. https://app.example.com) */
+ baseUrl: string;
+ /** Stripe Customer ID */
+ customerId: string;
+ /** Org slug for building return_url path */
+ organizationSlug: Organization["slug"];
+ /** The Stripe Subscription ID you want to let them cancel */
+ subscriptionId: string;
+}) {
+ const session = await stripeAdmin.billingPortal.sessions.create({
+ customer: customerId,
+ flow_data: {
+ subscription_cancel: {
+ subscription: subscriptionId,
+ // you can also configure a retention strategy here if desired:
+ // retention: { type: 'coupon_offer', coupon: '25OFF' },
+ },
+ // This invokes the "cancel subscription" deep-link
+ type: "subscription_cancel",
+ },
+ return_url: `${baseUrl}${href(
+ "/organizations/:organizationSlug/settings/billing",
+ { organizationSlug },
+ )}`,
+ });
+
+ return session;
+}
+
+/**
+ * Resumes a Stripe subscription if it's scheduled to cancel at period end.
+ *
+ * @param subscriptionId - The Stripe subscription ID to resume or retrieve.
+ * @returns A Promise that resolves to the resumed or current Subscription.
+ */
+export async function resumeStripeSubscription(subscriptionId: string) {
+ // 1) Retrieve current subscription
+ const subscription = await stripeAdmin.subscriptions.retrieve(subscriptionId);
+
+ // 2) If it's scheduled to cancel at period end, clear that flag
+ if (subscription.cancel_at_period_end) {
+ const renewed = await stripeAdmin.subscriptions.update(subscriptionId, {
+ cancel_at_period_end: false,
+ });
+ return renewed;
+ }
+
+ // 3) Otherwise, it's already active/not scheduled to cancel
+ return subscription;
+}
+
+/**
+ * Releases a Stripe Subscription Schedule, keeping the current subscription
+ * active.
+ *
+ * @param scheduleId - The ID of the SubscriptionSchedule to release.
+ * @returns A Promise that resolves to the released SubscriptionSchedule.
+ */
+export async function keepCurrentSubscription(
+ scheduleId: Stripe.SubscriptionSchedule["id"],
+) {
+ return await stripeAdmin.subscriptionSchedules.release(scheduleId);
+}
+
+/**
+ * Adjusts the seat quantity of a Stripe subscription and updates future phases.
+ *
+ * @param subscriptionId - The Stripe subscription ID to update.
+ * @param subscriptionItemId - The subscription item ID whose quantity should be
+ * updated.
+ * @param stripeScheduleId - Optional SubscriptionSchedule ID for future phases.
+ * @param newQuantity - The new seat quantity to set.
+ * @returns A Promise that resolves to an object with the updated subscription
+ * and schedule.
+ */
+export async function adjustSeats({
+ subscriptionId,
+ subscriptionItemId,
+ stripeScheduleId,
+ newQuantity,
+}: {
+ subscriptionId: Stripe.Subscription["id"];
+ subscriptionItemId: Stripe.SubscriptionItem["id"];
+ stripeScheduleId?: Stripe.SubscriptionSchedule["id"];
+ newQuantity: number;
+}) {
+ const updatedSub = await stripeAdmin.subscriptions.update(subscriptionId, {
+ items: [{ id: subscriptionItemId, quantity: newQuantity }],
+ });
+
+ if (stripeScheduleId) {
+ const sched =
+ await stripeAdmin.subscriptionSchedules.retrieve(stripeScheduleId);
+
+ const now = Math.floor(Date.now() / 1000);
+ const updatedPhases = sched.phases.map((phase) => ({
+ end_date: phase.end_date,
+ items: phase.items.map((item) => ({
+ quantity:
+ phase.start_date > now
+ ? newQuantity // bump only future phases
+ : item.quantity,
+ })),
+ start_date: phase.start_date,
+ }));
+
+ const updatedSched = await stripeAdmin.subscriptionSchedules.update(
+ stripeScheduleId,
+ { phases: updatedPhases },
+ );
+
+ return { updatedSched, updatedSub };
+ }
+
+ return { updatedSub };
+}
+
+/**
+ * Cancels all active subscriptions for a Stripe customer.
+ *
+ * @param customerId - The Stripe customer ID whose subscriptions to cancel.
+ * @returns A Promise that resolves to an object containing cancelled subscriptions.
+ */
+export async function deactivateStripeCustomer(customerId: string) {
+ const subscriptions = await stripeAdmin.subscriptions.list({
+ customer: customerId,
+ status: "active",
+ });
+
+ const cancelledSubscriptions = [];
+
+ for (const subscription of subscriptions.data) {
+ const cancelled = await stripeAdmin.subscriptions.cancel(subscription.id);
+ cancelledSubscriptions.push(cancelled);
+ }
+
+ return { cancelledSubscriptions };
+}
diff --git a/apps/react-router/saas-template/app/features/billing/stripe-prices-model.server.ts b/apps/react-router/saas-template/app/features/billing/stripe-prices-model.server.ts
new file mode 100644
index 0000000..be6b645
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/stripe-prices-model.server.ts
@@ -0,0 +1,102 @@
+import type { Stripe } from "stripe";
+
+import type { Prisma, StripePrice } from "~/generated/client";
+import { prisma } from "~/utils/database.server";
+
+/* CREATE */
+
+/**
+ * Saves a new Stripe price to the database.
+ *
+ * @param price - The Stripe price to save.
+ * @returns The saved Stripe price.
+ */
+export async function saveStripePriceToDatabase(
+ price: Prisma.StripePriceUncheckedCreateInput,
+) {
+ return prisma.stripePrice.create({ data: price });
+}
+
+/**
+ * Creates a new Stripe price in the database.
+ *
+ * @param price - The Stripe price to create.
+ * @returns The created Stripe price.
+ */
+export async function saveStripePriceFromAPIToDatabase(price: Stripe.Price) {
+ return prisma.stripePrice.create({
+ data: {
+ active: price.active,
+ currency: price.currency,
+ interval: price.recurring?.interval ?? "month",
+ lookupKey: price.lookup_key ?? "",
+ product: { connect: { stripeId: price.product as string } },
+ stripeId: price.id,
+ unitAmount: price.unit_amount ?? 0,
+ },
+ });
+}
+
+/* READ */
+/**
+ * Retrieves a Stripe price from the database by its lookup key.
+ *
+ * @param lookupKey - The lookup key of the price to retrieve.
+ * @returns The retrieved Stripe price.
+ */
+export async function retrieveStripePriceFromDatabaseByLookupKey(
+ lookupKey: StripePrice["lookupKey"],
+) {
+ return prisma.stripePrice.findUnique({ where: { lookupKey } });
+}
+
+/**
+ * Retrieves a Stripe price with its associated product from the database by lookup key.
+ *
+ * @param lookupKey - The lookup key of the price to retrieve.
+ * @returns The retrieved Stripe price with its product.
+ */
+export async function retrieveStripePriceWithProductFromDatabaseByLookupKey(
+ lookupKey: StripePrice["lookupKey"],
+) {
+ return prisma.stripePrice.findUnique({
+ include: { product: true },
+ where: { lookupKey },
+ });
+}
+
+/* UPDATE */
+
+/**
+ * Updates an existing Stripe price in the database.
+ *
+ * @param price - The Stripe price to update.
+ * @returns The updated Stripe price.
+ */
+export async function updateStripePriceFromAPIInDatabase(price: Stripe.Price) {
+ return prisma.stripePrice.update({
+ data: {
+ active: price.active,
+ currency: price.currency,
+ interval: price.recurring?.interval,
+ lookupKey: price.lookup_key ?? "",
+ product: { connect: { stripeId: price.product as string } },
+ unitAmount: price.unit_amount ?? 0,
+ },
+ where: { stripeId: price.id },
+ });
+}
+
+/* DELETE */
+
+/**
+ * Deletes a Stripe price from the database by its Stripe ID.
+ *
+ * @param stripeId - The Stripe ID of the price to delete.
+ * @returns The deleted price.
+ */
+export async function deleteStripePriceFromDatabaseById(
+ stripeId: StripePrice["stripeId"],
+) {
+ return prisma.stripePrice.delete({ where: { stripeId } });
+}
diff --git a/apps/react-router/saas-template/app/features/billing/stripe-product-model.server.ts b/apps/react-router/saas-template/app/features/billing/stripe-product-model.server.ts
new file mode 100644
index 0000000..bb70151
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/stripe-product-model.server.ts
@@ -0,0 +1,111 @@
+import type { Stripe } from "stripe";
+import { z } from "zod";
+
+import type { Prisma, StripeProduct } from "~/generated/client";
+import { prisma } from "~/utils/database.server";
+
+const maxSeatsSchema = z.preprocess((value) => {
+ const number = Number(value);
+ return Number.isInteger(number) && number > 0 ? number : undefined;
+}, z.number().int().positive().default(1));
+
+/* CREATE */
+
+/**
+ * Saves a new Stripe product to the database.
+ *
+ * @param product - The Stripe product to save.
+ * @returns The saved Stripe product.
+ */
+export async function saveStripeProductToDatabase(
+ product: Prisma.StripeProductCreateInput,
+) {
+ return prisma.stripeProduct.create({ data: product });
+}
+
+/**
+ * Creates a new Stripe product in the database.
+ *
+ * The `max_seats` metadata field is parsed using the `maxSeatsSchema` schema.
+ *
+ * @param product - The Stripe product to create.
+ * @returns The created Stripe product.
+ */
+export async function saveStripeProductFromAPIToDatabase(
+ product: Stripe.Product,
+) {
+ return prisma.stripeProduct.create({
+ data: {
+ active: product.active,
+ maxSeats: maxSeatsSchema.parse(product.metadata.max_seats),
+ name: product.name,
+ stripeId: product.id,
+ },
+ });
+}
+
+/* READ */
+
+/**
+ * Retrieves a Stripe product from the database by its Stripe ID.
+ *
+ * @param stripeId - The Stripe ID of the product to retrieve.
+ * @returns The retrieved Stripe product.
+ */
+export async function retrieveStripeProductFromDatabaseById(
+ stripeId: StripeProduct["stripeId"],
+) {
+ return prisma.stripeProduct.findUnique({ where: { stripeId } });
+}
+
+/**
+ * Retrieves Stripe products from the database by their price lookup keys.
+ *
+ * @param lookupKeys - The price lookup keys of the products to retrieve.
+ * @returns The retrieved Stripe products.
+ */
+export async function retrieveProductsFromDatabaseByPriceLookupKeys(
+ lookupKeys: string[],
+) {
+ return prisma.stripeProduct.findMany({
+ include: { prices: { where: { lookupKey: { in: lookupKeys } } } },
+ where: { prices: { some: { lookupKey: { in: lookupKeys } } } },
+ });
+}
+
+/* UPDATE */
+
+/**
+ * Updates an existing Stripe product in the database.
+ *
+ * The `max_seats` metadata field is parsed using the `maxSeatsSchema` schema.
+ *
+ * @param product - The Stripe product to update.
+ * @returns The updated Stripe product.
+ */
+export async function updateStripeProductFromAPIInDatabase(
+ product: Stripe.Product,
+) {
+ return prisma.stripeProduct.update({
+ data: {
+ active: product.active,
+ maxSeats: maxSeatsSchema.parse(product.metadata.max_seats),
+ name: product.name,
+ },
+ where: { stripeId: product.id },
+ });
+}
+
+/* DELETE */
+
+/**
+ * Deletes a Stripe product from the database by its Stripe ID.
+ *
+ * @param stripeId - The Stripe ID of the product to delete.
+ * @returns The deleted Stripe product.
+ */
+export async function deleteStripeProductFromDatabaseById(
+ stripeId: StripeProduct["stripeId"],
+) {
+ return prisma.stripeProduct.delete({ where: { stripeId } });
+}
diff --git a/apps/react-router/saas-template/app/features/billing/stripe-subscription-model.server.ts b/apps/react-router/saas-template/app/features/billing/stripe-subscription-model.server.ts
new file mode 100644
index 0000000..52e8779
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/stripe-subscription-model.server.ts
@@ -0,0 +1,180 @@
+import type Stripe from "stripe";
+
+import type {
+ Organization,
+ Prisma,
+ StripeSubscription,
+} from "~/generated/client";
+import { prisma } from "~/utils/database.server";
+
+/* CREATE */
+
+/**
+ * Saves a Stripe subscription to our database.
+ *
+ * @param subscription - The Stripe subscription to save.
+ * @returns The saved Stripe subscription.
+ */
+export async function saveStripeSubscriptionToDatabase(
+ subscription: Prisma.StripeSubscriptionUncheckedCreateInput,
+) {
+ return prisma.stripeSubscription.create({ data: subscription });
+}
+
+/**
+ * Creates a new Stripe subscription and its items in our database.
+ * Expects organizationId and purchasedById in subscription.metadata.
+ *
+ * @param stripeSubscription - Stripe.Subscription with metadata: organizationId, purchasedById.
+ * @returns The created StripeSubscription record.
+ */
+export async function createStripeSubscriptionInDatabase(
+ stripeSubscription: Stripe.Subscription,
+) {
+ const { metadata } = stripeSubscription;
+ const organizationId = metadata.organizationId;
+ const purchasedById = metadata.purchasedById;
+
+ return prisma.stripeSubscription.create({
+ data: {
+ cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
+ created: new Date(stripeSubscription.created * 1000),
+ items: {
+ create: stripeSubscription.items.data.map((item) => ({
+ currentPeriodEnd: new Date(item.current_period_end * 1000),
+ currentPeriodStart: new Date(item.current_period_start * 1000),
+ price: { connect: { stripeId: item.price.id } },
+ stripeId: item.id,
+ })),
+ },
+ organization: { connect: { id: organizationId } },
+ purchasedBy: { connect: { id: purchasedById } },
+ status: stripeSubscription.status,
+ stripeId: stripeSubscription.id,
+ },
+ });
+}
+
+/* READ */
+
+/**
+ * Retrieves a Stripe subscription from our database by its ID.
+ *
+ * @param stripeId - The ID of the Stripe subscription to retrieve
+ * @returns The retrieved StripeSubscription record
+ */
+export async function retrieveStripeSubscriptionFromDatabaseById(
+ stripeId: StripeSubscription["stripeId"],
+) {
+ return await prisma.stripeSubscription.findUnique({ where: { stripeId } });
+}
+
+/**
+ * Retrieves a Stripe subscription from our database by its ID, including its items.
+ *
+ * @param stripeId - The ID of the Stripe subscription to retrieve
+ * @returns The retrieved StripeSubscription record with its items
+ */
+export async function retrieveStripeSubscriptionWithItemsFromDatabaseById(
+ stripeId: StripeSubscription["stripeId"],
+) {
+ return await prisma.stripeSubscription.findUnique({
+ include: { items: true },
+ where: { stripeId },
+ });
+}
+
+/**
+ * Retrieves the latest Stripe subscription for an organization, regardless of
+ * status.
+ * Orders by creation date to ensure we get the most recent subscription.
+ *
+ * @param organizationId - The ID of the organization to retrieve the
+ * subscription for
+ * @returns The most recent Stripe subscription for the organization,
+ * including subscription items and prices. Returns null if no subscription
+ * exists.
+ */
+export async function retrieveLatestStripeSubscriptionWithActiveScheduleAndPhasesByOrganizationId(
+ organizationId: Organization["id"],
+) {
+ return await prisma.stripeSubscription.findFirst({
+ include: {
+ items: {
+ include: { price: true },
+ },
+ schedule: { include: { phases: { include: { price: true } } } },
+ },
+ orderBy: { created: "desc" },
+ where: { organizationId },
+ });
+}
+
+/* UPDATE */
+
+/**
+ * Updates a Stripe subscription in our database by its ID.
+ *
+ * @param id - The ID of the Stripe subscription to update
+ * @param subscription - The new data for the Stripe subscription
+ * @returns The updated StripeSubscription record
+ */
+export async function updateStripeSubscriptionInDatabaseById({
+ id,
+ subscription,
+}: {
+ id: StripeSubscription["stripeId"];
+ subscription: Omit;
+}) {
+ return await prisma.stripeSubscription.update({
+ data: subscription,
+ where: { stripeId: id },
+ });
+}
+
+/**
+ * Updates an existing Stripe subscription and its items in our database.
+ * Expects organizationId and purchasedById in subscription.metadata.
+ *
+ * @param stripeSubscription - Stripe.Subscription with metadata: organizationId, purchasedById.
+ * @returns The updated StripeSubscription record.
+ */
+export async function updateStripeSubscriptionFromAPIInDatabase(
+ stripeSubscription: Stripe.Subscription,
+) {
+ const { metadata } = stripeSubscription;
+ const purchasedById = metadata.purchasedById;
+
+ return prisma.stripeSubscription.update({
+ data: {
+ cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
+ created: new Date(stripeSubscription.created * 1000),
+ items: {
+ create: stripeSubscription.items.data.map((item) => ({
+ currentPeriodEnd: new Date(item.current_period_end * 1000),
+ currentPeriodStart: new Date(item.current_period_start * 1000),
+ price: { connect: { stripeId: item.price.id } },
+ stripeId: item.id,
+ })),
+ deleteMany: {},
+ },
+ purchasedBy: { connect: { id: purchasedById } },
+ status: stripeSubscription.status,
+ },
+ where: { stripeId: stripeSubscription.id },
+ });
+}
+
+/* DELETE */
+
+/**
+ * Deletes a Stripe subscription from our database by its ID.
+ *
+ * @param stripeId - The ID of the Stripe subscription to delete
+ * @returns The deleted StripeSubscription record
+ */
+export async function deleteStripeSubscriptionFromDatabaseById(
+ stripeId: StripeSubscription["stripeId"],
+) {
+ return await prisma.stripeSubscription.delete({ where: { stripeId } });
+}
diff --git a/apps/react-router/saas-template/app/features/billing/stripe-subscription-schedule-model.server.ts b/apps/react-router/saas-template/app/features/billing/stripe-subscription-schedule-model.server.ts
new file mode 100644
index 0000000..758e947
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/billing/stripe-subscription-schedule-model.server.ts
@@ -0,0 +1,180 @@
+import type Stripe from "stripe";
+
+import type { StripeSubscriptionScheduleWithPhasesAndPrice } from "./billing-factories.server";
+import type { StripeSubscriptionSchedule } from "~/generated/client";
+import { prisma } from "~/utils/database.server";
+
+/* CREATE */
+
+/**
+ * Saves a Stripe subscription schedule with its phases and prices to our database.
+ *
+ * @param stripeSchedule - Stripe.SubscriptionSchedule to save
+ * @returns The saved StripeSubscriptionSchedule record
+ */
+export async function saveSubscriptionScheduleWithPhasesAndPriceToDatabase(
+ stripeSchedule: StripeSubscriptionScheduleWithPhasesAndPrice,
+) {
+ return await prisma.stripeSubscriptionSchedule.create({
+ data: {
+ created: stripeSchedule.created,
+ currentPhaseEnd: stripeSchedule.currentPhaseEnd,
+ currentPhaseStart: stripeSchedule.currentPhaseStart,
+ phases: {
+ create: stripeSchedule.phases.map((phase) => ({
+ endDate: phase.endDate,
+ price: { connect: { stripeId: phase.price.stripeId } },
+ quantity: phase.quantity,
+ startDate: phase.startDate,
+ })),
+ },
+ stripeId: stripeSchedule.stripeId,
+ subscription: {
+ connect: { stripeId: stripeSchedule.subscriptionId },
+ },
+ },
+ include: { phases: true },
+ });
+}
+
+/**
+ * Creates a new Stripe subscription schedule in the database.
+ *
+ * @param stripeSchedule - Stripe.SubscriptionSchedule to create
+ * @returns The created StripeSubscriptionSchedule record
+ */
+export async function saveStripeSubscriptionScheduleFromAPIToDatabase(
+ stripeSchedule: Stripe.SubscriptionSchedule,
+) {
+ const createPhases = stripeSchedule.phases.map((phase) => {
+ if (!phase.items?.[0]?.price || typeof phase.items[0].price !== "string") {
+ throw new Error("Each phase must have at least one item with a price ID");
+ }
+
+ return {
+ endDate: new Date(phase.end_date * 1000),
+ price: {
+ connect: { stripeId: phase.items[0].price },
+ },
+ quantity: phase.items[0].quantity ?? 1,
+ startDate: new Date(phase.start_date * 1000),
+ };
+ });
+
+ return prisma.stripeSubscriptionSchedule.create({
+ data: {
+ created: new Date(stripeSchedule.created * 1000),
+ currentPhaseEnd: stripeSchedule.current_phase?.end_date
+ ? new Date(stripeSchedule.current_phase.end_date * 1000)
+ : null,
+ currentPhaseStart: stripeSchedule.current_phase?.start_date
+ ? new Date(stripeSchedule.current_phase.start_date * 1000)
+ : null,
+ phases: {
+ create: createPhases,
+ },
+ stripeId: stripeSchedule.id,
+ subscription: {
+ connect: { stripeId: stripeSchedule.subscription as string },
+ },
+ },
+ include: { phases: true },
+ });
+}
+
+/* READ */
+
+/**
+ * Retrieves a Stripe subscription schedule from our database by its ID.
+ *
+ * @param scheduleId - The ID of the Stripe subscription schedule to retrieve
+ * @returns The retrieved StripeSubscriptionSchedule record
+ */
+export async function retrieveStripeSubscriptionScheduleFromDatabaseById(
+ scheduleId: StripeSubscriptionSchedule["stripeId"],
+) {
+ return await prisma.stripeSubscriptionSchedule.findUnique({
+ where: { stripeId: scheduleId },
+ });
+}
+
+/**
+ * Retrieves a Stripe subscription schedule from our database by its ID.
+ *
+ * @param scheduleId - The ID of the Stripe subscription schedule to retrieve
+ * @returns The retrieved StripeSubscriptionSchedule record
+ */
+export async function retrieveStripeSubscriptionScheduleWithPhasesFromDatabaseById(
+ scheduleId: StripeSubscriptionSchedule["stripeId"],
+) {
+ return await prisma.stripeSubscriptionSchedule.findUnique({
+ include: { phases: true },
+ where: { stripeId: scheduleId },
+ });
+}
+
+/* UPDATE */
+
+/**
+ * Updates an existing Stripe subscription schedule in the database.
+ * All existing phases are deleted and replaced with new ones since
+ * Stripe doesn't provide real IDs for phases.
+ *
+ * @param stripeSchedule - Stripe.SubscriptionSchedule to update
+ * @returns The updated StripeSubscriptionSchedule record
+ */
+export async function updateStripeSubscriptionScheduleFromAPIInDatabase(
+ stripeSchedule: Stripe.SubscriptionSchedule,
+) {
+ const createPhases = stripeSchedule.phases.map((phase) => {
+ if (!phase.items?.[0]?.price || typeof phase.items[0].price !== "string") {
+ throw new Error("Each phase must have at least one item with a price ID");
+ }
+
+ return {
+ endDate: new Date(phase.end_date * 1000),
+ price: {
+ connect: { stripeId: phase.items[0].price },
+ },
+ quantity: phase.items[0].quantity ?? 1,
+ startDate: new Date(phase.start_date * 1000),
+ };
+ });
+
+ return prisma.stripeSubscriptionSchedule.update({
+ data: {
+ created: new Date(stripeSchedule.created * 1000),
+ currentPhaseEnd: stripeSchedule.current_phase?.end_date
+ ? new Date(stripeSchedule.current_phase.end_date * 1000)
+ : null,
+ currentPhaseStart: stripeSchedule.current_phase?.start_date
+ ? new Date(stripeSchedule.current_phase.start_date * 1000)
+ : null,
+ phases: {
+ // Then create new ones
+ create: createPhases,
+ // First delete all existing phases
+ deleteMany: {},
+ },
+ },
+ include: { phases: true },
+ where: { stripeId: stripeSchedule.id },
+ });
+}
+
+/* DELETE */
+
+/**
+ * Deletes a Stripe subscription schedule from our database.
+ * This should be called after canceling a schedule in Stripe.
+ *
+ * @param scheduleId - The ID of the Stripe subscription schedule to delete
+ * @returns The deleted StripeSubscriptionSchedule record
+ */
+export async function deleteStripeSubscriptionScheduleFromDatabaseById(
+ scheduleId: StripeSubscriptionSchedule["stripeId"],
+) {
+ return prisma.stripeSubscriptionSchedule.delete({
+ where: { stripeId: scheduleId },
+ });
+}
diff --git a/apps/react-router/saas-template/app/features/color-scheme/color-scheme-constants.ts b/apps/react-router/saas-template/app/features/color-scheme/color-scheme-constants.ts
new file mode 100644
index 0000000..708ccd1
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/color-scheme/color-scheme-constants.ts
@@ -0,0 +1,10 @@
+export const THEME_TOGGLE_INTENT = "themeToggle";
+export const COLOR_SCHEME_FORM_KEY = "colorScheme";
+
+export const colorSchemes = {
+ dark: "dark",
+ light: "light",
+ system: "system",
+} as const;
+
+export type ColorScheme = (typeof colorSchemes)[keyof typeof colorSchemes];
diff --git a/apps/react-router/saas-template/app/features/color-scheme/color-scheme.server.ts b/apps/react-router/saas-template/app/features/color-scheme/color-scheme.server.ts
new file mode 100644
index 0000000..137a7d4
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/color-scheme/color-scheme.server.ts
@@ -0,0 +1,46 @@
+import type { ActionFunction } from "react-router";
+import { createCookie, data } from "react-router";
+import { createTypedCookie } from "remix-utils/typed-cookie";
+import { z } from "zod";
+
+import type { ColorScheme } from "./color-scheme-constants";
+import { COLOR_SCHEME_FORM_KEY, colorSchemes } from "./color-scheme-constants";
+
+const cookie = createCookie("color-scheme", {
+ httpOnly: true,
+ path: "/",
+ sameSite: "lax",
+ secrets: [process.env.COOKIE_SECRET ?? "secret"],
+});
+
+const schema = z
+ .enum([colorSchemes.dark, colorSchemes.light, colorSchemes.system]) // Possible color schemes
+ .default(colorSchemes.system) // If there's no cookie, default to "system"
+ .catch(colorSchemes.system); // In case of an error, default to "system"
+
+const typedCookie = createTypedCookie({ cookie, schema });
+
+export async function getColorScheme(request: Request): Promise {
+ const colorScheme = await typedCookie.parse(request.headers.get("Cookie"));
+ return colorScheme ?? colorSchemes.system; // Default to "system" if no cookie is found
+}
+
+export async function setColorScheme(colorScheme: ColorScheme) {
+ return await typedCookie.serialize(colorScheme);
+}
+
+/**
+ * Action handler for updating the user's color scheme preference.
+ * Validates the color scheme from form data and sets it in a cookie.
+ *
+ * @param param0 - The action parameters containing the request
+ * @returns A redirect response with the updated color scheme cookie
+ * @throws {Response} 400 Bad Request if the color scheme is invalid
+ */
+export const colorSchemeAction: ActionFunction = async ({ request }) => {
+ const formData = await request.formData();
+ const colorScheme = schema.parse(formData.get(COLOR_SCHEME_FORM_KEY));
+ return data(null, {
+ headers: { "Set-Cookie": await setColorScheme(colorScheme) },
+ });
+};
diff --git a/apps/react-router/saas-template/app/features/color-scheme/theme-toggle.test.tsx b/apps/react-router/saas-template/app/features/color-scheme/theme-toggle.test.tsx
new file mode 100644
index 0000000..9a90cf6
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/color-scheme/theme-toggle.test.tsx
@@ -0,0 +1,68 @@
+import userEvent from "@testing-library/user-event";
+import { createRoutesStub } from "react-router";
+import { describe, expect, test } from "vitest";
+
+import { ThemeToggle } from "./theme-toggle";
+import { render, screen } from "~/test/react-test-utils";
+
+function renderThemeToggle() {
+ const path = "/test";
+ const RouterStub = createRoutesStub([{ Component: ThemeToggle, path }]);
+
+ return render(
+ ,
+ );
+}
+
+describe("ThemeToggle Component", () => {
+ test("given: default render, should: show a button with correct aria-label and dropdown closed", () => {
+ renderThemeToggle();
+
+ const button = screen.getByRole("button", {
+ name: /open theme menu/i,
+ });
+ expect(button).toBeInTheDocument();
+
+ // Verify dropdown is initially closed
+ expect(screen.queryByText(/appearance/i)).not.toBeInTheDocument();
+ });
+
+ test("given: user interactions, should: handle dropdown menu accessibility and keyboard navigation", async () => {
+ const user = userEvent.setup();
+ renderThemeToggle();
+
+ // Get the button and verify initial state
+ const button = screen.getByRole("button", {
+ name: /open theme menu/i,
+ });
+
+ // Click to open dropdown
+ await user.click(button);
+
+ // Verify dropdown is open and menu items are visible
+ const menu = screen.getByText(/appearance/i);
+ expect(menu).toBeInTheDocument();
+
+ // Verify all theme options are present
+ expect(
+ screen.getByRole("menuitem", { name: /light/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("menuitem", { name: /dark/i })).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /system/i }),
+ ).toBeInTheDocument();
+
+ // Test keyboard navigation - Escape to close
+ await user.keyboard("{Escape}");
+ expect(screen.queryByText(/appearance/i)).not.toBeInTheDocument();
+
+ // Test keyboard navigation - open with Enter
+ button.focus();
+ expect(button).toHaveFocus();
+ await user.keyboard("{Enter}");
+ expect(screen.getByText(/appearance/i)).toBeInTheDocument();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/color-scheme/theme-toggle.tsx b/apps/react-router/saas-template/app/features/color-scheme/theme-toggle.tsx
new file mode 100644
index 0000000..d768279
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/color-scheme/theme-toggle.tsx
@@ -0,0 +1,104 @@
+import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
+import type { ComponentProps } from "react";
+import { useTranslation } from "react-i18next";
+import { TbBrightness } from "react-icons/tb";
+import { Form } from "react-router";
+import { useHydrated } from "remix-utils/use-hydrated";
+
+import {
+ COLOR_SCHEME_FORM_KEY,
+ colorSchemes,
+ THEME_TOGGLE_INTENT,
+} from "./color-scheme-constants";
+import { useColorScheme } from "./use-color-scheme";
+import { Button } from "~/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "~/components/ui/dropdown-menu";
+import { cn } from "~/lib/utils";
+
+function ColorSchemeButton({
+ className,
+ value,
+ ...props
+}: ComponentProps<"button">) {
+ const currentColorScheme = useColorScheme();
+ const isActive = currentColorScheme === value;
+
+ return (
+
+ }
+ />
+ );
+}
+
+export function ThemeToggle() {
+ const { t } = useTranslation("colorScheme");
+ const hydrated = useHydrated();
+
+ return (
+
+
+ }
+ >
+
+
+
+
+
+ }
+ >
+
+ {t("dropdownMenuLabel")}
+
+
+
+
+
+
+
+
+ {t("dropdownMenuItemLight")}
+
+
+
+
+ {t("dropdownMenuItemDark")}
+
+
+
+
+ {t("dropdownMenuItemSystem")}
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/color-scheme/use-color-scheme.ts b/apps/react-router/saas-template/app/features/color-scheme/use-color-scheme.ts
new file mode 100644
index 0000000..7b050bc
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/color-scheme/use-color-scheme.ts
@@ -0,0 +1,18 @@
+import { useNavigation, useRouteLoaderData } from "react-router";
+
+import type { ColorScheme } from "./color-scheme-constants";
+import { COLOR_SCHEME_FORM_KEY, colorSchemes } from "./color-scheme-constants";
+import type { loader as rootLoader } from "~/root";
+
+export function useColorScheme(): ColorScheme {
+ const rootLoaderData = useRouteLoaderData("root");
+
+ const { formData } = useNavigation();
+ const optimisticColorScheme = formData?.get(
+ COLOR_SCHEME_FORM_KEY,
+ ) as ColorScheme | null;
+
+ return (
+ optimisticColorScheme ?? rootLoaderData?.colorScheme ?? colorSchemes.system
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/bento-grid.tsx b/apps/react-router/saas-template/app/features/landing/bento-grid.tsx
new file mode 100644
index 0000000..8f8fd1f
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/bento-grid.tsx
@@ -0,0 +1,67 @@
+import type { ComponentProps } from "react";
+
+import { cn } from "~/lib/utils";
+
+export function BentoGrid({ className, ...props }: ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export function BentoCard({ className, ...props }: ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export function BentoCardHeader({
+ className,
+ ...props
+}: ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export function BentoCardEyeBrow({
+ className,
+ ...props
+}: ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+export function BentoCardTitle({ className, ...props }: ComponentProps<"h3">) {
+ return (
+
+ );
+}
+
+export function BentoCardDescription({
+ className,
+ ...props
+}: ComponentProps<"p">) {
+ return (
+
+ );
+}
+
+export function BentoCardMedia({ className, ...props }: ComponentProps<"div">) {
+ return
;
+}
diff --git a/apps/react-router/saas-template/app/features/landing/cta.tsx b/apps/react-router/saas-template/app/features/landing/cta.tsx
new file mode 100644
index 0000000..2140d56
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/cta.tsx
@@ -0,0 +1,56 @@
+import { IconBook2 } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+
+import { Button } from "~/components/ui/button";
+
+export function CTA() {
+ const { t } = useTranslation("landing", { keyPrefix: "cta" });
+
+ return (
+
+
+
+
+ {t("title")}
+
+
+
+ {t("description")}
+
+
+
+
+ }>
+ {t("buttons.primary")}
+
+
+
+ }
+ variant="link"
+ >
+ {t("buttons.secondary")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/description.tsx b/apps/react-router/saas-template/app/features/landing/description.tsx
new file mode 100644
index 0000000..a1f3cb9
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/description.tsx
@@ -0,0 +1,85 @@
+import {
+ IconAdjustments,
+ IconBolt,
+ IconBook,
+ IconTestPipe,
+} from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+
+import { cn } from "~/lib/utils";
+
+const featureIcons = [IconBolt, IconTestPipe, IconBook, IconAdjustments];
+
+const imageClassNames =
+ "border-border w-3xl max-w-none rounded-xl border sm:w-228 md:-ml-4 lg:-ml-0";
+
+export function Description() {
+ const { t } = useTranslation("landing", { keyPrefix: "description" });
+ const features = t("features", { returnObjects: true }) as {
+ title: string;
+ description: string;
+ }[];
+
+ return (
+
+
+
+
+
+
+ {t("eyebrow")}
+
+
+
+ {t("title")}
+
+
+
+ {t("subtitle")}
+
+
+
+ {features.map((feature, index) => {
+ const Icon = featureIcons[index];
+
+ if (!Icon) {
+ return null;
+ }
+
+ return (
+
+
+
+ {feature.title}
+ {" "}
+ {feature.description}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/faq.tsx b/apps/react-router/saas-template/app/features/landing/faq.tsx
new file mode 100644
index 0000000..25f828e
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/faq.tsx
@@ -0,0 +1,65 @@
+import { Trans, useTranslation } from "react-i18next";
+
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "~/components/ui/accordion";
+import { buttonVariants } from "~/components/ui/button";
+import { cn } from "~/lib/utils";
+
+type FAQItem = {
+ question: string;
+ answer: string;
+ links?: Record;
+};
+
+export function FAQ() {
+ const { t } = useTranslation("landing", { keyPrefix: "faq" });
+ const items = t("items", { returnObjects: true }) as FAQItem[];
+
+ return (
+
+
+
+ {t("title")}
+
+
+
+ {items.map((item, index) => (
+
+ {item.question}
+
+
+ {item.links ? (
+ [
+ index_ + 1,
+ // biome-ignore lint/a11y/useAnchorContent: the trans component adds the anchor content
+ ,
+ ]),
+ )}
+ // @ts-expect-error - dynamic key based on index
+ i18nKey={`faq.items.${index}.answer`}
+ ns="landing"
+ />
+ ) : (
+ item.answer
+ )}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/features.tsx b/apps/react-router/saas-template/app/features/landing/features.tsx
new file mode 100644
index 0000000..e7e09ce
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/features.tsx
@@ -0,0 +1,201 @@
+import type { CSSProperties } from "react";
+import { useTranslation } from "react-i18next";
+
+import {
+ BentoCard,
+ BentoCardDescription,
+ BentoCardEyeBrow,
+ BentoCardHeader,
+ BentoCardMedia,
+ BentoCardTitle,
+ BentoGrid,
+} from "./bento-grid";
+import { Iphone15Pro } from "~/components/magicui/iphone-15-pro";
+import { cn } from "~/lib/utils";
+
+const imageClassNames = "w-full rounded-t-lg h-full object-cover object-left";
+const imageFadeStyle: CSSProperties = {
+ maskImage: "linear-gradient(to bottom, black 75%, transparent)",
+ WebkitMaskImage: "linear-gradient(to bottom, black 75%, transparent)",
+};
+
+type BentoCardTranslations = {
+ eyebrow?: string;
+ title: string;
+ description: string;
+ image?: {
+ light: string;
+ dark: string;
+ };
+};
+
+// Must match the number of cards in public/locales/en/landing.json > features.cards
+type BentoCards = [
+ BentoCardTranslations,
+ BentoCardTranslations,
+ BentoCardTranslations,
+ BentoCardTranslations,
+ BentoCardTranslations,
+ BentoCardTranslations,
+ BentoCardTranslations,
+ BentoCardTranslations,
+];
+
+export function Features() {
+ const { t } = useTranslation("landing", { keyPrefix: "features" });
+ const cards = t("cards", { returnObjects: true }) as BentoCards;
+
+ return (
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/footer.tsx b/apps/react-router/saas-template/app/features/landing/footer.tsx
new file mode 100644
index 0000000..28f00ec
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/footer.tsx
@@ -0,0 +1,76 @@
+/** biome-ignore-all lint/a11y/useAnchorContent: anchor receives props */
+import type { ComponentProps } from "react";
+import { useTranslation } from "react-i18next";
+import { FaGithub, FaLinkedin, FaXTwitter } from "react-icons/fa6";
+
+import { ThemeToggle } from "../color-scheme/theme-toggle";
+import { ReactsquadLogoIcon } from "./svgs/reactsquad-logo-icon";
+import { Button } from "~/components/ui/button";
+import { Separator } from "~/components/ui/separator";
+import { cn } from "~/lib/utils";
+
+export function Footer({ className, ...props }: ComponentProps<"footer">) {
+ const { t } = useTranslation("landing", { keyPrefix: "footer" });
+
+ return (
+
+
+
+
+ }
+ size="icon"
+ variant="outline"
+ >
+
+
+
+
}
+ size="icon"
+ variant="outline"
+ >
+
+
+
+
}
+ size="icon"
+ variant="outline"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("madeWithLove")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/header.tsx b/apps/react-router/saas-template/app/features/landing/header.tsx
new file mode 100644
index 0000000..94c1c89
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/header.tsx
@@ -0,0 +1,53 @@
+import { IconLayoutList } from "@tabler/icons-react";
+import type { ComponentProps } from "react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+
+import { Button } from "~/components/ui/button";
+import { cn } from "~/lib/utils";
+
+export function Header({ className, ...props }: ComponentProps<"header">) {
+ const { t } = useTranslation("landing", { keyPrefix: "header" });
+ const { t: tCommon } = useTranslation("translation");
+
+ return (
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/hero.tsx b/apps/react-router/saas-template/app/features/landing/hero.tsx
new file mode 100644
index 0000000..0520167
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/hero.tsx
@@ -0,0 +1,127 @@
+import { IconBook2 } from "@tabler/icons-react";
+import type { CSSProperties } from "react";
+import { Trans, useTranslation } from "react-i18next";
+import { Link } from "react-router";
+
+import { RRLockupDarkIcon } from "./svgs/rr-lockup-dark-icon";
+import { RRLockupLightIcon } from "./svgs/rr-lockup-light-icon";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import { cn } from "~/lib/utils";
+
+const imageClassNames = "border-border rounded-xl border object-contain";
+const imageFadeStyle: CSSProperties = {
+ maskImage: "linear-gradient(to bottom, black 75%, transparent)",
+ WebkitMaskImage: "linear-gradient(to bottom, black 75%, transparent)",
+};
+
+export function Hero() {
+ const { t } = useTranslation("landing", { keyPrefix: "hero" });
+ const { t: tCommon } = useTranslation("translation");
+
+ return (
+
+
+
+
+
+ ,
+ }}
+ i18nKey="hero.badge"
+ ns="landing"
+ />
+
+
+
+
+
+
+
+
+
+ {t("title")}
+
+
+ {tCommon("appName")}
+
+
+
+
+
+ free
+
+ ),
+ }}
+ i18nKey="hero.description"
+ ns="landing"
+ />
+
+
+
+
+ }>{t("cta.primary")}
+
+
+ }
+ variant="link"
+ >
+ {t("cta.secondary")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/landing-page.tsx b/apps/react-router/saas-template/app/features/landing/landing-page.tsx
new file mode 100644
index 0000000..0104b3c
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/landing-page.tsx
@@ -0,0 +1,27 @@
+import { CTA } from "./cta";
+import { Description } from "./description";
+import { FAQ } from "./faq";
+import { Features } from "./features";
+import { Footer } from "./footer";
+import { Header } from "./header";
+import { Hero } from "./hero";
+import { Logos } from "./logos";
+
+export default function LandingPage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/logos.tsx b/apps/react-router/saas-template/app/features/landing/logos.tsx
new file mode 100644
index 0000000..f8ab885
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/logos.tsx
@@ -0,0 +1,121 @@
+import { useTranslation } from "react-i18next";
+import { FaStripe } from "react-icons/fa6";
+import {
+ SiEslint,
+ SiMockserviceworker,
+ SiPostgresql,
+ SiPrettier,
+ SiPrisma,
+ SiShadcnui,
+ SiSupabase,
+ SiTailwindcss,
+ SiTestinglibrary,
+ SiTypescript,
+ SiVitest,
+} from "react-icons/si";
+
+import { PlaywrightIcon } from "./svgs/playwright-icon";
+import { RRLockupDarkIcon } from "./svgs/rr-lockup-dark-icon";
+import { RRLockupLightIcon } from "./svgs/rr-lockup-light-icon";
+import { Marquee } from "~/components/magicui/marquee";
+
+export function Logos() {
+ const { t } = useTranslation("landing", { keyPrefix: "logos" });
+ return (
+
+
+ {t("title")}
+
+
+
+ {/* Marquee with fading edges */}
+
+ {[
+ {
+ icon: (
+ <>
+
+
+ >
+ ),
+ key: "react-router",
+ },
+ {
+ icon: ,
+ key: "typescript",
+ },
+ {
+ icon: ,
+ key: "supabase",
+ },
+ {
+ icon: ,
+ key: "stripe",
+ },
+ {
+ icon: ,
+ key: "tailwind",
+ },
+ {
+ icon: ,
+ key: "shadcn",
+ },
+ {
+ icon: ,
+ key: "vitest",
+ },
+ {
+ icon: ,
+ key: "playwright",
+ },
+ {
+ icon: ,
+ key: "postgresql",
+ },
+ {
+ icon: ,
+ key: "prisma",
+ },
+ {
+ icon: (
+
+ ),
+ key: "msw",
+ },
+ {
+ icon: (
+
+ ),
+ key: "rtl",
+ },
+ {
+ icon: ,
+ key: "eslint",
+ },
+ {
+ icon: ,
+ key: "prettier",
+ },
+ ].map(({ key, icon }) => (
+
+ {icon}
+
+ ))}
+
+
+ {/* Fading edges */}
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/svgs/playwright-icon.tsx b/apps/react-router/saas-template/app/features/landing/svgs/playwright-icon.tsx
new file mode 100644
index 0000000..26f05ed
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/svgs/playwright-icon.tsx
@@ -0,0 +1,50 @@
+import type { IconProps } from "~/utils/types";
+
+export function PlaywrightIcon({ className, ...props }: IconProps) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/svgs/reactsquad-logo-icon.tsx b/apps/react-router/saas-template/app/features/landing/svgs/reactsquad-logo-icon.tsx
new file mode 100644
index 0000000..c0f11a4
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/svgs/reactsquad-logo-icon.tsx
@@ -0,0 +1,40 @@
+import type { IconProps } from "~/utils/types";
+
+export function ReactsquadLogoIcon({ className = "", ...props }: IconProps) {
+ return (
+
+ Reactsquad Logo
+
+ {" "}
+ {/* Unique ID for clipPath */}
+
+
+
+
+
+
+ {" "}
+ {/* Ensure this ID is unique if you use multiple SVGs with clipPaths on one page */}
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/svgs/rr-lockup-dark-icon.tsx b/apps/react-router/saas-template/app/features/landing/svgs/rr-lockup-dark-icon.tsx
new file mode 100644
index 0000000..f6c0578
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/svgs/rr-lockup-dark-icon.tsx
@@ -0,0 +1,32 @@
+import type { IconProps } from "~/utils/types";
+
+export function RRLockupDarkIcon({ className, ...props }: IconProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/landing/svgs/rr-lockup-light-icon.tsx b/apps/react-router/saas-template/app/features/landing/svgs/rr-lockup-light-icon.tsx
new file mode 100644
index 0000000..ae17468
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/landing/svgs/rr-lockup-light-icon.tsx
@@ -0,0 +1,32 @@
+import type { IconProps } from "~/utils/types";
+
+export function RRLockupLightIcon({ className, ...props }: IconProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/localization/i18next-middleware.server.ts b/apps/react-router/saas-template/app/features/localization/i18next-middleware.server.ts
new file mode 100644
index 0000000..5f64351
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/i18next-middleware.server.ts
@@ -0,0 +1,27 @@
+import { initReactI18next } from "react-i18next";
+import { createCookie } from "react-router";
+import { createI18nextMiddleware } from "remix-i18next/middleware";
+
+import resources from "./locales";
+
+// This cookie will be used to store the user locale preference
+export const localeCookie = createCookie("lng", {
+ httpOnly: true,
+ path: "/",
+ sameSite: "lax",
+ secure: process.env.NODE_ENV === "production",
+});
+
+export const [i18nextMiddleware, getLocale, getInstance] =
+ createI18nextMiddleware({
+ detection: {
+ cookie: localeCookie, // The cookie to store the user preference
+ fallbackLanguage: "en", // Your fallback language
+ supportedLanguages: ["de", "en"], // Your supported languages, the fallback should be last
+ },
+ i18next: {
+ interpolation: { escapeValue: false },
+ resources,
+ },
+ plugins: [initReactI18next], // Plugins you may need, like react-i18next
+ });
diff --git a/apps/react-router/saas-template/app/features/localization/locales/de/billing.ts b/apps/react-router/saas-template/app/features/localization/locales/de/billing.ts
new file mode 100644
index 0000000..27a9c1e
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/de/billing.ts
@@ -0,0 +1,265 @@
+/** biome-ignore-all lint/suspicious/noTemplateCurlyInString: It's a currency */
+export default {
+ billingPage: {
+ breadcrumb: "Abrechnung",
+ cancelAtPeriodEndBanner: {
+ button: "Abonnement fortsetzen",
+ description: "Dein Abonnement läuft am {{date}} aus.",
+ resumeSuccessTitle: "Abonnement fortgesetzt",
+ resumingSubscription: "Abonnement wird fortgesetzt ...",
+ title: "Dein Abonnement endet bald.",
+ },
+ cancelSubscriptionModal: {
+ cancellingSubscription: "Abonnement wird gekündigt ...",
+ changePlan: "Wähle einen anderen Plan",
+ confirm: "Abonnement kündigen",
+ description:
+ "Wenn du dein Abonnement kündigst, verlierst du am Ende deines Abrechnungszeitraums den Zugriff auf deine Vorteile.",
+ features: [
+ "SSO",
+ "Unbegrenzte Mitglieder",
+ "Unbegrenzte private Projekte",
+ "Prioritäts-Support",
+ ],
+ title: "Bist du sicher, dass du dein Abonnement kündigen möchtest?",
+ },
+ freeTrialBanner: {
+ button: "Zahlungsinformationen hinzufügen",
+ description: "Deine kostenlose Testphase endet am {{date}}.",
+ modal: {
+ description: "Wähle einen Plan, der zu deinen Bedürfnissen passt.",
+ title: "Wähle deinen Plan",
+ },
+ title:
+ "Deine Organisation befindet sich derzeit in der kostenlosen Testphase.",
+ },
+ openingCustomerPortal: "Kundenportal wird geöffnet ...",
+ pageDescription: "Verwalte deine Abrechnungsinformationen.",
+ pageTitle: "Abrechnung",
+ paymentInformation: {
+ billingEmail: "Rechnungs-E-Mail",
+ editButton: "Bearbeiten",
+ heading: "Zahlungsinformationen",
+ },
+ pendingDowngradeBanner: {
+ button: "Aktuelles Abonnement beibehalten",
+ description:
+ "Dein Abonnement wird am {{date}} auf den {{planName}}-Plan ({{billingInterval}}) herabgestuft.",
+ intervals: {
+ annual: "jährlich",
+ monthly: "monatlich",
+ },
+ loadingButton: "Abonnement wird aktualisiert ...",
+ successTitle: "Aktuelles Abonnement beibehalten",
+ title: "Herabstufung geplant",
+ },
+ planInformation: {
+ amountFormat: "${{amount}}",
+ currentPlan: "Aktueller Plan",
+ heading: "Dein Plan",
+ managePlan: "Plan verwalten",
+ manageUsers: "Benutzer verwalten",
+ nextBillingDate: "Nächstes Abrechnungsdatum",
+ projectedTotal: "Voraussichtliche Summe",
+ rateFormatAnnual: "${{amount}} <1>pro Benutzer jährlich abgerechnet1>",
+ rateFormatMonthly:
+ "${{amount}} <1>pro Benutzer monatlich abgerechnet1>",
+ users: "Benutzer",
+ usersFormat: "{{current}} / {{max}}",
+ viewInvoices: "Rechnungen ansehen",
+ },
+ pricingModal: {
+ addingPaymentInformation: "Zahlungsinformationen werden hinzugefügt ...",
+ addPaymentInformation: "Zahlungsinformationen hinzufügen",
+ cancelSubscriptionBanner: {
+ button: "Abonnement kündigen",
+ description:
+ "Nach der Kündigung deines Abonnements kannst du dein Konto bis zum Ende des aktuellen Abrechnungszeitraums weiter nutzen.",
+ title: "Abonnement kündigen",
+ },
+ currentPlan: "Aktueller Plan",
+ description: "Wähle einen Plan, der zu deinen Bedürfnissen passt.",
+ downgradeButton: "Herabstufen",
+ downgrading: "Wird herabgestuft ...",
+ switchToAnnualButton: "Zu jährlicher Abrechnung wechseln",
+ switchToMonthlyButton: "Zu monatlicher Abrechnung wechseln",
+ title: "Plan verwalten",
+ upgradeButton: "Upgrade durchführen",
+ upgrading: "Upgrade wird durchgeführt ...",
+ },
+ subscriptionCancelledBanner: {
+ button: "Abonnement reaktivieren",
+ description: "Dein Abonnement wurde gekündigt.",
+ modal: {
+ description: "Wähle einen Plan, der zu deinen Bedürfnissen passt.",
+ title: "Wähle deinen Plan, um dein Abonnement zu reaktivieren",
+ },
+ title: "Dein Abonnement ist inaktiv.",
+ },
+ updateBillingEmailModal: {
+ description: "Deine Rechnungen werden an diese E-Mail-Adresse gesendet.",
+ emailInvalid: "Eine gültige E-Mail besteht aus Zeichen, '@' und '.'.",
+ emailLabel: "E-Mail",
+ emailPlaceholder: "abrechnung@firma.de",
+ emailRequired: "Bitte gib eine gültige E-Mail ein (erforderlich).",
+ savingChanges: "Änderungen werden gespeichert ...",
+ submitButton: "Änderungen speichern",
+ successTitle: "Rechnungs-E-Mail aktualisiert",
+ title: "Bearbeite deine Rechnungs-E-Mail",
+ },
+ },
+ billingSidebarCard: {
+ activeTrial: {
+ button: "Zahlungsinformationen hinzufügen",
+ description: "Testphase endet am {{date}}.",
+ title: "Business Plan (Testphase)",
+ },
+ billingModal: {
+ description: "Wähle einen Plan, der zu deinen Bedürfnissen passt.",
+ title: "Wähle deinen Plan",
+ },
+ subscriptionInactive: {
+ button: "Plan auswählen",
+ description: "Verlängere, um die App weiter zu nutzen.",
+ modal: {
+ description: "Wähle einen Plan, der zu deinen Bedürfnissen passt.",
+ title: "Wähle deinen Plan, um dein Abonnement zu reaktivieren",
+ },
+ title: "Abonnement inaktiv",
+ },
+ trialEnded: {
+ button: "Abonnement fortsetzen",
+ description: "Testphase endete am {{date}}.",
+ title: "Business Plan (Testphase)",
+ },
+ },
+ billingSuccessPage: {
+ goToDashboard: "Zum Dashboard",
+ pageTitle: "Erfolgreich abonniert!",
+ paymentSuccessful: "Zahlung erfolgreich",
+ productReady:
+ "Dein SaaS-Produkt ist bereit und freut sich darauf, dir zu helfen, deine Zeit optimal zu nutzen und deine Kunden zu bedienen. Verabschiede dich von mühsamer Einrichtung und konzentriere dich aufs Entwickeln.",
+ thankYou:
+ "Vielen Dank, dass du React Router SaaS Template vertraust. Deine erfolgreiche Reise zum Aufbau und zur Pflege eines SaaS-Produkts beginnt jetzt!",
+ },
+ contactSales: {
+ company: "Firma",
+ companyNameLabel: "Firma",
+ companyNamePlaceholder: "Firmenname",
+ companyNameRequired: "Bitte gib deinen Firmennamen ein (erforderlich).",
+ companyNameTooLong:
+ "Dein Firmenname darf nicht mehr als 255 Zeichen enthalten.",
+ companyPlaceholder: "Firmenname",
+ contactSalesDescription:
+ "Wir besprechen deine Anforderungen, führen eine Produktdemo durch und richten den richtigen Plan und Preis für dich ein.",
+ contactSalesTitle:
+ "Sprich mit unserem Vertriebsteam über deine Bedürfnisse.",
+ enterpriseSales: "Enterprise-Vertrieb",
+ firstNameLabel: "Vorname",
+ firstNamePlaceholder: "Vorname",
+ firstNameRequired: "Bitte gib deinen Vornamen ein (erforderlich).",
+ firstNameTooLong: "Dein Vorname darf nicht mehr als 255 Zeichen enthalten.",
+ lastNameLabel: "Nachname",
+ lastNamePlaceholder: "Nachname",
+ lastNameRequired: "Bitte gib deinen Nachnamen ein (erforderlich).",
+ lastNameTooLong: "Dein Nachname darf nicht mehr als 255 Zeichen enthalten.",
+ messageLabel: "Nachricht",
+ messagePlaceholder:
+ "Beschreibe dein Projekt, deine Bedürfnisse und deinen Zeitplan.",
+ messageRequired:
+ "Bitte gib eine Nachricht ein, die deine Bedürfnisse beschreibt (erforderlich).",
+ messageTooLong:
+ "Deine Nachricht darf nicht mehr als 5000 Zeichen enthalten.",
+ pageTitle: "Vertrieb kontaktieren",
+ phoneNumberLabel: "Telefonnummer",
+ phoneNumberPlaceholder: "Wo sollen wir dich anrufen?",
+ phoneNumberRequired: "Bitte gib deine Telefonnummer ein (erforderlich).",
+ submitButton: "Vertrieb kontaktieren",
+ submitButtonLoading: "Vertrieb wird kontaktiert ...",
+ submitDisclaimer:
+ "Mit dem Absenden dieses Formulars erkläre ich mich damit einverstanden, vom Vertriebsteam kontaktiert zu werden.",
+ success: "Erfolg!",
+ thankYou:
+ "Vielen Dank, dass du uns kontaktiert hast. Wir werden uns in Kürze bei dir melden.",
+ workEmailInvalid:
+ "Bitte gib eine gültige geschäftliche E-Mail ein, die '@' und '.' enthält.",
+ workEmailLabel: "Geschäftliche E-Mail",
+ workEmailPlaceholder: "name@firma.de",
+ workEmailRequired:
+ "Bitte gib deine geschäftliche E-Mail ein (erforderlich).",
+ workEmailTooLong:
+ "Deine geschäftliche E-Mail darf nicht mehr als 255 Zeichen enthalten.",
+ },
+ noCurrentPlanModal: {
+ annual: "Jährlich",
+ disabledPlansAlert: {
+ descriptionPlural:
+ "Du hast derzeit {{currentSeats}} Benutzer, und der {{plan1Title}}-Plan unterstützt nur {{plan1Capacity}} Benutzer, während der {{plan2Title}}-Plan nur {{plan2Capacity}} Benutzer unterstützt. Bitte wähle einen Plan, der mindestens {{currentSeats}} Plätze unterstützt.",
+ descriptionSingular:
+ "Du hast derzeit {{currentSeats}} Benutzer, und der {{planTitle}}-Plan unterstützt nur {{planCapacity}} Benutzer. Bitte wähle einen Plan, der mindestens {{currentSeats}} Plätze unterstützt.",
+ title: "Warum sind einige Pläne deaktiviert?",
+ },
+ monthly: "Monatlich",
+ tierCardBusy: "Abonnement wird abgeschlossen ...",
+ tierCardCta: "Jetzt abonnieren",
+ },
+ pricing: {
+ annual: "Jährlich",
+ custom: "Individuell",
+ free: "Kostenlos",
+ monthly: "Monatlich",
+ mostPopular: "Am beliebtesten",
+ plans: {
+ enterprise: {
+ cta: "Vertrieb kontaktieren",
+ description:
+ "Für große Organisationen, die maßgeschneiderte Lösungen benötigen.",
+ features: [
+ "Individuelle Integrationen",
+ "Unbegrenzte Mitglieder",
+ "Dedizierter Support",
+ ],
+ featuresTitle: "Alle Business-Funktionen plus:",
+ title: "Enterprise",
+ },
+ high: {
+ cta: "14 Tage kostenlos testen",
+ description: "Für Profis und Unternehmen, die wachsen möchten.",
+ features: ["SSO", "Bis zu 25 Mitglieder", "Prioritäts-Support"],
+ featuresTitle: "Alles in Startup plus:",
+ title: "Business",
+ },
+ low: {
+ cta: "Jetzt starten",
+ description: "Für Hobbyisten und Anfänger, die lernen und erkunden.",
+ features: [
+ "Unbegrenzte öffentliche Projekte",
+ "Community-Support",
+ "1 Mitglied",
+ ],
+ featuresTitle: "Funktionen:",
+ title: "Hobby",
+ },
+ mid: {
+ cta: "14 Tage kostenlos testen",
+ description:
+ "Perfekt für Startups mit kleinen Teams, die skalieren müssen.",
+ features: [
+ "Unbegrenzte private Projekte",
+ "Branding entfernen",
+ "Bis zu 5 Mitglieder",
+ ],
+ featuresTitle: "Alles in Hobby plus:",
+ title: "Startup",
+ },
+ },
+ price: "{{price}} <1>/Benutzer pro Monat1>",
+ saveAnnually: "Spare bis zu 20% mit dem Jahresplan.",
+ },
+ pricingPage: {
+ pageDescription:
+ "Natürlich ist dieses Template kostenlos. Aber so könnte deine Preisgestaltung aussehen.",
+ pageTitle: "Preise",
+ pricingHeading: "Wähle deinen Plan",
+ },
+} satisfies typeof import("../en/billing").default;
diff --git a/apps/react-router/saas-template/app/features/localization/locales/de/color-scheme.ts b/apps/react-router/saas-template/app/features/localization/locales/de/color-scheme.ts
new file mode 100644
index 0000000..b995895
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/de/color-scheme.ts
@@ -0,0 +1,7 @@
+export default {
+ dropdownMenuButtonLabel: "Theme-Menü öffnen",
+ dropdownMenuItemDark: "Dunkel",
+ dropdownMenuItemLight: "Hell",
+ dropdownMenuItemSystem: "System",
+ dropdownMenuLabel: "Darstellung",
+} satisfies typeof import("../en/color-scheme").default;
diff --git a/apps/react-router/saas-template/app/features/localization/locales/de/drag-and-drop.ts b/apps/react-router/saas-template/app/features/localization/locales/de/drag-and-drop.ts
new file mode 100644
index 0000000..bee78ec
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/de/drag-and-drop.ts
@@ -0,0 +1,4 @@
+export default {
+ extensions: "{{extensions}} bis zu {{maxFileSize}}",
+ heading: "Ziehe Dateien hierher oder <1>wähle Dateien aus1> zum Hochladen",
+} satisfies typeof import("../en/drag-and-drop").default;
diff --git a/apps/react-router/saas-template/app/features/localization/locales/de/index.ts b/apps/react-router/saas-template/app/features/localization/locales/de/index.ts
new file mode 100644
index 0000000..d596a3d
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/de/index.ts
@@ -0,0 +1,23 @@
+import billing from "./billing";
+import colorScheme from "./color-scheme";
+import dragAndDrop from "./drag-and-drop";
+import landing from "./landing";
+import notifications from "./notifications";
+import onboarding from "./onboarding";
+import organizations from "./organizations";
+import settings from "./settings";
+import translation from "./translation";
+import userAuthentication from "./user-authentication";
+
+export default {
+ billing,
+ colorScheme,
+ dragAndDrop,
+ landing,
+ notifications,
+ onboarding,
+ organizations,
+ settings,
+ translation,
+ userAuthentication,
+} satisfies typeof import("../en/index").default;
diff --git a/apps/react-router/saas-template/app/features/localization/locales/de/landing.ts b/apps/react-router/saas-template/app/features/localization/locales/de/landing.ts
new file mode 100644
index 0000000..40633a9
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/de/landing.ts
@@ -0,0 +1,188 @@
+export default {
+ cta: {
+ buttons: {
+ primary: "Jetzt starten",
+ secondary: "Dokumentation",
+ },
+ description:
+ "Richte dein Projekt heute ein und beginne morgen mit der Entwicklung. Spare Monate bei der Einrichtung, damit du dich auf Features konzentrieren kannst.",
+ title: "Starte dein SaaS so schnell wie möglich.",
+ },
+ description: {
+ eyebrow: "Warum dieses Template verwenden?",
+ features: [
+ {
+ description:
+ "TypeScript, ESLint & Prettier, Commitlint und GitHub Actions sind alle eingerichtet, damit dein Team von Tag eins an sauberen, konsistenten Code schreibt.",
+ title: "Werkzeuge ohne Konfiguration.",
+ },
+ {
+ description:
+ "In ein Template mit Zehntausenden von Codezeilen einzusteigen ist beängstigend. Vollständige Unit-, Integrations-, Komponenten- und E2E-Tests (Vitest, React Testing Library & Playwright) lassen dich ohne Angst refaktorieren.",
+ title: "Mit TDD entwickelt.",
+ },
+ {
+ description:
+ "Sieh dir reale Muster für Bild-Uploads (Client vs. Server), MSW-umhüllte Mocks auf Client und Server, Factory-Funktionen und Test-Helfer für einfaches Testen, nahtlosen Dark-Mode ohne Flackern, Stripe-Zahlungsintegration, Authentifizierung und mehr an.",
+ title: "Lerne durch Beispiele.",
+ },
+ {
+ description:
+ "Alles ist modular - entferne, was du nicht brauchst, passe die Ordnerstruktur an und erfasse neue Anforderungen mit Tests, während du wächst.",
+ title: "Vollständig anpassbar.",
+ },
+ ],
+ image: {
+ dark: "Produkt-Screenshot (dunkel)",
+ light: "Produkt-Screenshot (hell)",
+ },
+ subtitle:
+ "Wenn du ein SaaS startest, ist dein größter Vorteil Geschwindigkeit. Nutze diese produktionsreife Grundlage, damit du Monate der Einrichtung überspringen und direkt mit dem Ausliefern von Features beginnen kannst.",
+ title: "Fokussiere dich auf dein PMF",
+ },
+ faq: {
+ items: [
+ {
+ answer:
+ "Ja! Dies ist ein Open-Source-Projekt und kann kostenlos unter der MIT-Lizenz genutzt werden. Einige der integrierten Dienste (zum Beispiel Supabase, Stripe, Hosting-Anbieter usw.) können jedoch eigene Nutzungsgebühren verursachen, für die du verantwortlich bist.",
+ question: "Ist das kostenlos?",
+ },
+ {
+ answer:
+ 'Nein. Dies ist ein unabhängiges, von der Community gepflegtes Open-Source-Template. Es wird nicht von Shopify Inc. gesponsert, ist nicht mit ihnen verbunden oder wird von ihnen unterstützt. "React Router" und seine Logos sind Marken von Shopify Inc., und dieses Projekt erhebt keinen Anspruch auf eine offizielle Partnerschaft oder Unterstützung.',
+ question:
+ "Wird dieses Template offiziell von Shopify Inc. unterstützt oder befürwortet?",
+ },
+ {
+ answer:
+ "Schön, dass du fragst! Wir suchen immer nach Hilfe für das Projekt. Wenn du daran interessiert bist, beizutragen, schau dir bitte unseren <1>Leitfaden für Mitwirkende1> an.",
+ links: {
+ contributing:
+ "https://github.com/janhesters/react-router-saas-template/blob/main/CONTRIBUTING.md",
+ },
+ question: "Wie kann ich beitragen?",
+ },
+ {
+ answer:
+ "Du kannst in den GitHub-Diskussionen fragen. Wenn du an professioneller Hilfe beim Aufbau deiner App von erfahrenen React-Entwicklern interessiert bist, wende dich an <1>ReactSquad1>.",
+ links: {
+ reactsquad: "https://reactsquad.io",
+ },
+ question: "Ich stecke fest! Wo kann ich Hilfe bekommen?",
+ },
+ {
+ answer:
+ "Vielen Dank! Du kannst Jan Hesters auf <1>X1>, <2>LinkedIn2> oder <3>YouTube3> folgen und ein Dankeschön hinterlassen. Und wenn du jemanden kennst, der erfahrene React-Entwickler braucht, empfiehl bitte <4>ReactSquad4>. Vielen Dank!",
+ links: {
+ linkedin: "https://www.linkedin.com/in/jan-hesters/",
+ reactsquad: "https://reactsquad.io",
+ x: "https://x.com/janhesters",
+ youtube: "https://www.youtube.com/@janhesters",
+ },
+ question: "Das ist großartig! Wie kann ich dich unterstützen?",
+ },
+ ],
+ title: "Häufig gestellte Fragen",
+ },
+ features: {
+ cards: [
+ {
+ description:
+ "Jeder Bildschirm funktioniert auf Desktop, Tablet und Mobilgerät. So kannst du alle Kunden bedienen.",
+ eyebrow: "TailwindCSS & Shadcn",
+ image: {
+ dark: "Mobil-Screenshot (dunkel)",
+ light: "Mobil-Screenshot (hell)",
+ },
+ title: "Responsivität & Barrierefreiheit",
+ },
+ {
+ description:
+ "Die meisten SaaS-Apps erheben eine Form von wiederkehrendem Abonnement. Dieses Template kommt mit drei vorkonfigurierten Stufen. Aber selbst wenn deine Bedürfnisse anders sind, gibt dir dies einen Vorsprung.",
+ eyebrow: "Stripe",
+ image: {
+ dark: "Abrechnung (dunkel)",
+ light: "Abrechnung (hell)",
+ },
+ title: "Abrechnung",
+ },
+ {
+ description:
+ "Mit Supabase Auth (E-Mail Magic Links & Google OAuth), einer verwalteten Postgres-Datenbank und Supabase Storage kümmert sich dieses Template um dein Backend. Storage bietet sogar zwei Upload-Flows: direkte Client-Uploads für große Dateien und server-vermittelte Uploads für kleine Assets wie Profilavatare.",
+ eyebrow: "Supabase",
+ image: {
+ dark: "Authentifizierung (dunkel)",
+ light: "Authentifizierung (hell)",
+ },
+ title: "Authentifizierung & Datenbank",
+ },
+ {
+ description:
+ "Dieses Template enthält ein Benachrichtigungssystem, das Text, Erwähnungen und Links unterstützt, komplett mit Gelesen/Ungelesen-Tracking.",
+ image: {
+ dark: "Benachrichtigungen (dunkel)",
+ light: "Benachrichtigungen (hell)",
+ },
+ title: "Benachrichtigungen",
+ },
+ {
+ description:
+ "Der integrierte Cookie-basierte Dark Mode verhindert Flackern beim Laden und respektiert standardmäßig helle, dunkle oder Systemeinstellungen.",
+ title: "Dark Mode",
+ },
+ {
+ description:
+ "Füge Mitglieder über teilbare Einladungslinks oder E-Mail-Einladungen hinzu, bei denen du Rollen zuweisen kannst - Owner, Admin oder Member - um Zugriff und Berechtigungen zu steuern.",
+ eyebrow: "Multi-Tenancy",
+ title: "Mitgliederverwaltung",
+ },
+ {
+ description:
+ "Verwalte Übersetzungen, wechsle Sprachen im laufenden Betrieb und handhabe gebietsspezifische Formatierung (Daten, Zahlen, Währungen) ohne zusätzliche Einrichtung.",
+ eyebrow: "React i18next",
+ title: "Internationalisierung",
+ },
+ {
+ description:
+ "Enthält Benutzerkontoeinstellungen, E-Mail-Versand (mit Resend), einen Onboarding-Flow und eine Vielzahl anderer Hilfsprogramme, die dir helfen, sofort loszulegen.",
+ eyebrow: "Verschiedenes",
+ title: "Und vieles mehr ...",
+ },
+ ],
+ eyebrow: "Features",
+ title: "Alles, was dein SaaS braucht",
+ },
+ footer: {
+ madeWithLove: "Gemacht mit ❤️ von",
+ reactsquad: "ReactSquad",
+ social: {
+ github: "Github",
+ linkedin: "LinkedIn",
+ twitter: "X ehemals bekannt als Twitter",
+ },
+ },
+ header: {
+ login: "Anmelden",
+ navLinks: {
+ pricing: "Preise",
+ },
+ register: "Registrieren",
+ },
+ hero: {
+ badge: "<1>KEIN1> offizielles Template",
+ cta: {
+ primary: "Jetzt starten",
+ secondary: "Dokumentation",
+ },
+ description:
+ "Spare deinem Team Monate beim Aufbau von B2B & B2C SaaS-Anwendungen mit diesem <1>kostenlosen1> React Router Community-Template.",
+ image: {
+ dark: "App-Screenshot (dunkel)",
+ light: "App-Screenshot (hell)",
+ },
+ title: "SaaS Template",
+ },
+ logos: {
+ title: "Der Stack hinter dem Template",
+ },
+} satisfies typeof import("../en/landing").default;
diff --git a/apps/react-router/saas-template/app/features/localization/locales/de/notifications.ts b/apps/react-router/saas-template/app/features/localization/locales/de/notifications.ts
new file mode 100644
index 0000000..129fa91
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/de/notifications.ts
@@ -0,0 +1,21 @@
+export default {
+ notificationMenu: {
+ markAsRead: "Als gelesen markieren",
+ triggerButton: "Benachrichtigungsmenü öffnen",
+ },
+ notificationsButton: {
+ all: "Alle",
+ markAllAsRead: "Alle als gelesen markieren",
+ notifications: "Benachrichtigungen",
+ openNotifications: "Benachrichtigungen öffnen",
+ openUnreadNotifications: "Ungelesene Benachrichtigungen öffnen",
+ unread: "Ungelesen",
+ },
+ notificationsPanel: {
+ markAsRead: "Als gelesen markieren",
+ noNotificationsDescription:
+ "Deine Benachrichtigungen werden hier angezeigt.",
+ noNotificationsTitle: "Keine Benachrichtigungen",
+ openMenu: "Benachrichtigungsmenü öffnen",
+ },
+} satisfies typeof import("../en/notifications").default;
diff --git a/apps/react-router/saas-template/app/features/localization/locales/de/onboarding.ts b/apps/react-router/saas-template/app/features/localization/locales/de/onboarding.ts
new file mode 100644
index 0000000..a906f69
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/de/onboarding.ts
@@ -0,0 +1,107 @@
+export default {
+ layout: {
+ quote:
+ "Diese Plattform hat unseren gesamten Workflow optimiert. Der Onboarding-Prozess war intuitiv und hat unser Team in wenigen Minuten einsatzbereit gemacht.",
+ quoteAuthor: "Sarah Mitchell, Product Manager",
+ },
+ organization: {
+ companySize: {
+ "1-10": "1-10 Mitarbeiter",
+ "11-50": "11-50 Mitarbeiter",
+ "51-200": "51-200 Mitarbeiter",
+ "201-500": "201-500 Mitarbeiter",
+ "501-1000": "501-1000 Mitarbeiter",
+ "1001+": "1001+ Mitarbeiter",
+ },
+ companySizeDescription:
+ "Wie viele Personen arbeiten in deiner Organisation? (optional)",
+ companySizeLabel: "Unternehmensgröße",
+ companySizePlaceholder: "Unternehmensgröße auswählen (optional)",
+ companyType: {
+ agency: "Agentur",
+ enterprise: "Großunternehmen",
+ government: "Öffentliche Verwaltung",
+ midMarket: "Mittelstand",
+ nonprofit: "Gemeinnützig",
+ startup: "Startup",
+ },
+ companyTypesDescription: "Wähle alle zutreffenden aus.",
+ companyTypesLabel: "Für welche Art von Unternehmen arbeitest du?",
+ companyWebsiteDescription:
+ "Die Website-URL deines Unternehmens (optional).",
+ companyWebsiteLabel: "Unternehmenswebsite",
+ companyWebsitePlaceholder: "https://example.com",
+ earlyAccessDescription:
+ "Erhalte frühen Zugang zu neuen Funktionen und hilf mit, die Zukunft unserer Plattform zu gestalten.",
+ earlyAccessLabel: "Early Access Programm",
+ earlyAccessTitle: "Tritt unserem Early Access Programm bei",
+ errors: {
+ companySizeRequired: "Bitte wähle die Größe deines Unternehmens.",
+ createOrganizationFailedDescription:
+ "Bitte versuche es erneut. Wenn das Problem weiterhin besteht, kontaktiere bitte den Support.",
+ createOrganizationFailedTitle: "Fehler beim Erstellen der Organisation",
+ invalidFileType: "Bitte lade eine gültige Bilddatei hoch.",
+ logoTooLarge: "Das Logo muss kleiner als 1 MB sein.",
+ nameMax: "Dein Organisationsname darf höchstens 72 Zeichen lang sein.",
+ nameMin: "Dein Organisationsname muss mindestens 3 Zeichen lang sein.",
+ },
+ heading: "Erstelle deine Organisation",
+ logoDescription:
+ "Lade ein Logo hoch, um deine Organisation zu repräsentieren.",
+ logoFormats: "PNG, JPG, GIF bis zu 1 MB",
+ logoLabel: "Logo",
+ logoPreviewAlt: "Vorschau des Organisationslogos",
+ nameDescription: "Bitte gib den Namen deiner Organisation ein.",
+ nameLabel: "Organisationsname",
+ namePlaceholder: "Der Name deiner Organisation ...",
+ recruitingPainPointDescription:
+ "Hilf uns, deine größten Herausforderungen zu verstehen (optional).",
+ recruitingPainPointLabel:
+ "Was ist dein größter Schmerzpunkt bei der Personalbeschaffung?",
+ recruitingPainPointPlaceholder:
+ "Erzähle uns von deinen Herausforderungen bei der Einstellung, dem Onboarding oder dem Team-Management...",
+ referralSource: {
+ blogArticle: "Blog-Artikel",
+ colleagueReferral: "Empfehlung eines Kollegen",
+ industryEvent: "Branchenveranstaltung",
+ onlineAd: "Online-Anzeige",
+ other: "Sonstiges",
+ partnerReferral: "Partner-Empfehlung",
+ podcast: "Podcast",
+ productHunt: "Product Hunt",
+ searchEngine: "Suchmaschine",
+ socialMedia: "Soziale Medien",
+ wordOfMouth: "Mundpropaganda",
+ },
+ referralSourcesDescription: "Wähle alle zutreffenden aus (optional).",
+ referralSourcesLabel: "Wie hast du von uns erfahren?",
+ save: "Weiter",
+ saving: "Wird erstellt ...",
+ subtitle:
+ "Du kannst später weitere Benutzer einladen, deiner Organisation über die Organisationseinstellungen beizutreten.",
+ title: "Organisation",
+ },
+ userAccount: {
+ errors: {
+ invalidFileType: "Bitte lade eine gültige Bilddatei hoch.",
+ nameMax: "Dein Name darf höchstens 128 Zeichen lang sein.",
+ nameMin: "Dein Name muss mindestens 2 Zeichen lang sein.",
+ photoTooLarge: "Das Profilfoto muss kleiner als 1 MB sein.",
+ },
+ heading: "Erstelle dein Konto",
+ nameDescription:
+ "Bitte gib deinen vollständigen Namen für die öffentliche Anzeige innerhalb deiner Organisation ein.",
+ nameLabel: "Name",
+ namePlaceholder: "Dein vollständiger Name ...",
+ profilePhotoDescription:
+ "Lade ein Profilfoto hoch, um dein Konto zu personalisieren.",
+ profilePhotoFormats: "PNG, JPG, GIF bis zu 1 MB",
+ profilePhotoLabel: "Profilfoto",
+ profilePhotoPreviewAlt: "Profilfoto-Vorschau",
+ save: "Weiter",
+ saving: "Wird gespeichert ...",
+ subtitle:
+ "Willkommen beim React Router SaaS Template! Bitte erstelle dein Benutzerkonto, um loszulegen.",
+ title: "Benutzerkonto",
+ },
+} satisfies typeof import("../en/onboarding").default;
diff --git a/apps/react-router/saas-template/app/features/localization/locales/de/organizations.ts b/apps/react-router/saas-template/app/features/localization/locales/de/organizations.ts
new file mode 100644
index 0000000..d44776c
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/de/organizations.ts
@@ -0,0 +1,313 @@
+export default {
+ acceptEmailInvite: {
+ acceptInvite: "Einladung annehmen",
+ acceptInviteInstructions:
+ "Klicke auf die Schaltfläche unten, um dich zu registrieren. Mit dieser E-Mail-Einladung trittst du automatisch der richtigen Organisation bei.",
+ acceptingInvite: "Einladung wird angenommen ...",
+ alreadyMemberToastDescription:
+ "Du bist bereits Mitglied von {{organizationName}}",
+ alreadyMemberToastTitle: "Bereits Mitglied",
+ inviteEmailInvalidToastDescription:
+ "Die E-Mail-Einladung ist ungültig oder abgelaufen",
+ inviteEmailInvalidToastTitle: "Einladung konnte nicht angenommen werden",
+ inviteEmailValidToastDescription:
+ "Bitte registriere dich oder melde dich an, um die Einladung anzunehmen.",
+ inviteEmailValidToastTitle: "Erfolg",
+ inviteYouToJoin:
+ "{{inviterName}} lädt dich ein, {{organizationName}} beizutreten",
+ joinSuccessToastDescription:
+ "Du bist jetzt Mitglied von {{organizationName}}",
+ joinSuccessToastTitle: "Organisation erfolgreich beigetreten",
+ organizationFullToastDescription:
+ "Die Organisation hat ihre Mitgliedergrenze erreicht",
+ organizationFullToastTitle: "Organisation ist voll",
+ pageTitle: "Einladung",
+ welcomeToAppName: "Willkommen bei {{appName}}",
+ },
+ acceptInviteLink: {
+ acceptInvite: "Einladung annehmen",
+ acceptInviteInstructions:
+ "Klicke auf die Schaltfläche unten, um dich zu registrieren. Mit diesem Link trittst du automatisch der richtigen Organisation bei.",
+ acceptingInvite: "Einladung wird angenommen ...",
+ alreadyMemberToastDescription:
+ "Du bist bereits Mitglied von {{organizationName}}",
+ alreadyMemberToastTitle: "Bereits Mitglied",
+ inviteLinkInvalidToastDescription:
+ "Der Einladungslink ist ungültig oder abgelaufen",
+ inviteLinkInvalidToastTitle: "Einladung konnte nicht angenommen werden",
+ inviteLinkValidToastDescription:
+ "Bitte registriere dich oder melde dich an, um die Einladung anzunehmen",
+ inviteLinkValidToastTitle: "Erfolg",
+ inviteYouToJoin:
+ "{{inviterName}} lädt dich ein, {{organizationName}} beizutreten",
+ joinSuccessToastDescription:
+ "Du bist jetzt Mitglied von {{organizationName}}",
+ joinSuccessToastTitle: "Organisation erfolgreich beigetreten",
+ organizationFullToastDescription:
+ "Die Organisation hat ihre Mitgliedergrenze erreicht",
+ organizationFullToastTitle: "Organisation ist voll",
+ pageTitle: "Einladung",
+ welcomeToAppName: "Willkommen bei {{appName}}",
+ },
+ analytics: {
+ breadcrumb: "Analytik",
+ pageTitle: "Analytik",
+ },
+ dashboard: {
+ breadcrumb: "Dashboard",
+ pageTitle: "Dashboard",
+ },
+ getHelp: {
+ breadcrumb: "Hilfe erhalten",
+ pageTitle: "Hilfe erhalten",
+ },
+ layout: {
+ appSidebar: {
+ nav: {
+ app: {
+ analytics: "Analytik",
+ dashboard: "Dashboard",
+ projects: {
+ active: "Aktiv",
+ all: "Alle",
+ title: "Projekte",
+ },
+ title: "App",
+ },
+ settings: {
+ getHelp: "Hilfe erhalten",
+ organizationSettings: "Organisationseinstellungen",
+ title: "Einstellungen",
+ },
+ sidebar: "Seitenleiste",
+ },
+ },
+ navUser: {
+ account: "Konto",
+ logOut: "Abmelden",
+ userMenuButtonLabel: "Benutzermenü öffnen",
+ },
+ organizationSwitcher: {
+ newOrganization: "Neue Organisation",
+ organizations: "Organisationen",
+ },
+ },
+ new: {
+ backButtonLabel: "Zurück",
+ form: {
+ cardDescription:
+ "Erzähle uns von deiner Organisation. Du bleibst weiterhin Mitglied deiner aktuellen Organisation und kannst jederzeit zwischen ihnen wechseln.",
+ cardTitle: "Neue Organisation erstellen",
+ logoInvalid: "Das Logo muss gültig sein.",
+ logoLabel: "Logo",
+ logoMustBeUrl: "Das Logo muss eine gültige URL sein.",
+ nameLabel: "Organisationsname",
+ nameMaxLength:
+ "Der Organisationsname darf höchstens 255 Zeichen lang sein.",
+ nameMinLength:
+ "Der Organisationsname muss mindestens 3 Zeichen lang sein.",
+ namePlaceholder: "Organisationsname ...",
+ nameRequired: "Der Organisationsname ist erforderlich.",
+ save: "Speichern",
+ saving: "Wird gespeichert ...",
+ submitButton: "Organisation erstellen",
+ termsAndPrivacy:
+ "Durch das Erstellen einer Organisation stimmst du unseren <1>Nutzungsbedingungen1> und der <2>Datenschutzerklärung2> zu.",
+ },
+ pageTitle: "Neue Organisation",
+ },
+ organizationsList: {
+ cardDescription:
+ "Dies ist eine Liste aller Organisationen, bei denen du Mitglied bist. Wähle eine aus, um sie zu betreten und ihr Dashboard zu besuchen.",
+ cardTitle: "Deine Organisationen",
+ pageTitle: "Organisationsliste",
+ roles: {
+ admin: "Admin",
+ member: "Mitglied",
+ owner: "Eigentümer",
+ },
+ title: "Organisationsliste",
+ },
+ projects: {
+ breadcrumb: "Alle Projekte",
+ pageTitle: "Alle Projekte",
+ },
+ projectsActive: {
+ breadcrumb: "Aktive Projekte",
+ pageTitle: "Aktive Projekte",
+ },
+ settings: {
+ breadcrumb: "Einstellungen",
+ general: {
+ breadcrumb: "Allgemein",
+ dangerZone: {
+ cancelButton: "Abbrechen",
+ confirmationLabel:
+ 'Zur Bestätigung gib "{{organizationName}}" in das Feld unten ein',
+ confirmationPlaceholder: "Organisationsnamen eingeben...",
+ deleteButton: "Diese Organisation löschen",
+ deleteButtonSubmitting: "Organisation wird gelöscht...",
+ deleteDescription:
+ "Sobald sie gelöscht ist, ist sie für immer weg. Bitte sei dir sicher.",
+ deleteTitle: "Diese Organisation löschen",
+ dialogDescription:
+ "Bist du sicher, dass du diese Organisation löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
+ dialogTitle: "Organisation löschen",
+ errors: {
+ confirmationMismatch:
+ "Der Bestätigungstext stimmt nicht mit dem Organisationsnamen überein.",
+ confirmationRequired:
+ "Bitte gib den Organisationsnamen zur Bestätigung ein.",
+ },
+ title: "Gefahrenzone",
+ triggerButton: "Organisation löschen",
+ },
+ description: "Allgemeine Einstellungen für diese Organisation.",
+ errors: {
+ invalidFileType:
+ "Ungültiger Dateityp. Nur PNG-, JPG-, JPEG-, GIF- und WebP-Bilder sind erlaubt.",
+ logoTooLarge: "Das Logo muss kleiner als 1 MB sein.",
+ nameMax: "Der Organisationsname darf höchstens 255 Zeichen lang sein.",
+ nameMin: "Der Organisationsname muss mindestens 3 Zeichen lang sein.",
+ },
+ form: {
+ logoDescription:
+ "Das Logo deiner Organisation wird in der gesamten Anwendung angezeigt.",
+ logoFormats: "PNG, JPG, GIF oder WebP (max. 1 MB)",
+ logoLabel: "Organisationslogo",
+ logoPreviewAlt: "Vorschau des Organisationslogos",
+ nameDescription:
+ "Der öffentliche Anzeigename deiner Organisation. Warnung: Das Ändern des Namens bricht alle bestehenden Links zu deiner Organisation.",
+ nameLabel: "Organisationsname",
+ namePlaceholder: "Organisationsname ...",
+ nameWarningContent:
+ "Wenn du den Namen deiner Organisation änderst, ändert sich auch der URL-Slug. Das bedeutet, dass alle mit Lesezeichen versehenen Links oder geteilten URLs nicht mehr funktionieren. Stelle sicher, dass du alle Verweise auf die alte URL aktualisierst.",
+ save: "Änderungen speichern",
+ saving: "Änderungen werden gespeichert ...",
+ },
+ organizationInfo: {
+ logoAlt: "Organisationslogo",
+ logoDescription: "Das Logo, das deine Organisation repräsentiert.",
+ logoTitle: "Organisationslogo",
+ nameDescription:
+ "Der Name deiner Organisation, wie er anderen angezeigt wird.",
+ nameTitle: "Organisationsname",
+ },
+ pageTitle: "Allgemein",
+ toast: {
+ organizationDeleted: "Organisation wurde gelöscht",
+ organizationProfileUpdated: "Organisation wurde aktualisiert",
+ },
+ },
+ layout: {
+ billing: "Abrechnung",
+ general: "Allgemein",
+ settingsNav: "Einstellungsnavigation",
+ teamMembers: "Teammitglieder",
+ },
+ meta: {
+ title: "Einstellungen",
+ },
+ teamMembers: {
+ breadcrumb: "Teammitglieder",
+ description: "Verwalte deine Teammitglieder und ihre Berechtigungen.",
+ descriptionMember: "Sieh, wer Mitglied deiner Organisation ist.",
+ inviteByEmail: {
+ cardDescription:
+ "Gib die E-Mail-Adressen deiner Kollegen ein, und wir senden ihnen eine personalisierte Einladung, um deiner Organisation beizutreten. Du kannst auch die Rolle auswählen, mit der sie beitreten werden.",
+ cardTitle: "Per E-Mail einladen",
+ form: {
+ email: "E-Mail",
+ emailAlreadyMember: "{{email}} ist bereits Mitglied",
+ emailInvalid: "Eine gültige E-Mail besteht aus Zeichen, '@' und '.'.",
+ emailPlaceholder: "Die E-Mail deines Kollegen ...",
+ emailRequired: "Bitte gib eine gültige E-Mail ein (erforderlich).",
+ inviting: "E-Mail-Einladung wird gesendet ...",
+ organizationFull:
+ "Du hast die maximale Anzahl von Plätzen für dein Abonnement erreicht.",
+ role: "Rolle",
+ roleAdmin: "Admin",
+ roleMember: "Mitglied",
+ roleOwner: "Eigentümer",
+ rolePlaceholder: "Rolle auswählen ...",
+ submitButton: "E-Mail-Einladung senden",
+ },
+ inviteEmail: {
+ buttonText: "{{organizationName}} beitreten",
+ callToAction:
+ "Tritt jetzt bei, um mit deinen Kollegen zusammenzuarbeiten!",
+ description:
+ "{{inviterName}} hat dich eingeladen, {{organizationName}} in {{appName}} beizutreten. Diese Einladung läuft in 48 Stunden ab.",
+ subject: "{{inviteName}} hat dich zu {{appName}} eingeladen",
+ title: "Du wurdest zu {{appName}} eingeladen!",
+ },
+ organizationFullToastDescription:
+ "Du hast alle verfügbaren Plätze aufgebraucht.",
+ organizationFullToastTitle: "Organisation ist voll",
+ successToastTitle: "E-Mail-Einladung gesendet",
+ },
+ inviteLink: {
+ cardDescription:
+ 'Nach dem Generieren des Links ist er 48 Stunden lang gültig. Kopiere und teile ihn mit jedem, den du mit der Rolle „Mitglied" einladen möchtest.',
+ cardTitle: "Einladungslink teilen",
+ copied: "Kopiert!",
+ copyInviteLink: "Einladungslink in die Zwischenablage kopieren",
+ createNewInviteLink: "Neuen Einladungslink erstellen",
+ creating: "Wird erstellt ...",
+ deactivateLink: "Link deaktivieren",
+ deactivating: "Wird deaktiviert ...",
+ goToLink: "Zur Seite des Einladungslinks gehen",
+ inviteLinkCopied: "Einladungslink in die Zwischenablage kopiert",
+ linkValidUntil: "Dein Link ist gültig bis {{date}}.",
+ newLinkDeactivatesOld:
+ "Das Generieren eines neuen Links deaktiviert automatisch den alten.",
+ regenerateLink: "Link neu generieren",
+ regenerating: "Wird neu generiert ...",
+ },
+ organizationIsFullAlert: {
+ button: "Zur Abrechnung gehen",
+ description:
+ "Wechsle zu einem höheren Tarif oder kontaktiere den Vertrieb, um neue Mitglieder einzuladen.",
+ title: "Keine Plätze mehr verfügbar",
+ },
+ pageTitle: "Teammitglieder",
+ table: {
+ avatarHeader: "Avatar",
+ emailHeader: "E-Mail",
+ nameHeader: "Name",
+ noResults: "Keine Mitglieder gefunden.",
+ pagination: {
+ goToFirst: "Zur ersten Seite gehen",
+ goToLast: "Zur letzten Seite gehen",
+ goToNext: "Zur nächsten Seite gehen",
+ goToPrevious: "Zur vorherigen Seite gehen",
+ pageInfo: "Seite {{current}} von {{total}}",
+ rowsPerPage: "Zeilen pro Seite",
+ },
+ roleHeader: "Rolle",
+ roleSwitcher: {
+ admin: "Admin",
+ adminDescription:
+ "Kann die Organisation bearbeiten und die Abrechnung verwalten.",
+ commandLabel: "Neue Rolle auswählen",
+ deactivated: "Deaktiviert",
+ deactivatedDescription: "Zugriff auf alles widerrufen.",
+ member: "Mitglied",
+ memberDescription: "Zugriff auf Standardfunktionen.",
+ noRolesFound: "Keine Rollen gefunden.",
+ owner: "Eigentümer",
+ ownerDescription:
+ "Kann Rollen zuweisen und die Organisation löschen.",
+ rolesPlaceholder: "Neue Rolle auswählen ...",
+ },
+ status: {
+ createdTheOrganization: "Beigetreten",
+ emailInvitePending: "Ausstehend",
+ joinedViaEmailInvite: "Beigetreten",
+ joinedViaLink: "Beigetreten",
+ },
+ statusHeader: "Status",
+ },
+ },
+ },
+} satisfies typeof import("../en/organizations").default;
diff --git a/apps/react-router/saas-template/app/features/localization/locales/de/settings.ts b/apps/react-router/saas-template/app/features/localization/locales/de/settings.ts
new file mode 100644
index 0000000..35289af
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/de/settings.ts
@@ -0,0 +1,61 @@
+export default {
+ layout: {
+ backButtonLabel: "Zurück zur Startseite",
+ pageTitle: "Einstellungen",
+ },
+ userAccount: {
+ dangerZone: {
+ blockingOrganizations_one:
+ "Dein Konto ist derzeit Eigentümer dieser Organisation: <1>{{organizations}}1>.",
+ blockingOrganizations_other:
+ "Dein Konto ist derzeit Eigentümer dieser Organisationen: <1>{{organizations}}1>.",
+ blockingOrganizationsHelp:
+ "Du musst dich selbst entfernen, die Eigentümerschaft übertragen oder diese Organisation löschen, bevor du deinen Benutzer löschen kannst.",
+ cancel: "Abbrechen",
+ deleteButton: "Konto löschen",
+ deleteConfirm: "Dieses Konto löschen",
+ deleteDescription:
+ "Sobald du dein Konto löschst, gibt es kein Zurück mehr. Bitte sei dir sicher.",
+ deleteTitle: "Konto löschen",
+ deleting: "Konto wird gelöscht ...",
+ dialogDescription:
+ "Bist du sicher, dass du dein Konto löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
+ dialogTitle: "Konto löschen",
+ implicitlyDeletedOrganizations_one:
+ "Die folgende Organisation wird gelöscht: <1>{{organizations}}1>",
+ implicitlyDeletedOrganizations_other:
+ "Die folgenden Organisationen werden gelöscht: <1>{{organizations}}1>",
+ title: "Gefahrenzone",
+ },
+ description: "Verwalte deine Kontoeinstellungen.",
+ errors: {
+ avatarTooLarge: "Der Avatar muss kleiner als 1 MB sein.",
+ invalidFileType:
+ "Ungültiger Dateityp. Nur PNG-, JPG-, JPEG-, GIF- und WebP-Bilder sind erlaubt.",
+ nameMax: "Dein Name darf höchstens 128 Zeichen lang sein.",
+ nameMin: "Dein Name muss mindestens 2 Zeichen lang sein.",
+ },
+ form: {
+ avatarDescription:
+ "Dein Avatar wird in der gesamten Anwendung angezeigt.",
+ avatarFormats: "PNG, JPG, GIF oder WebP (max. 1 MB)",
+ avatarLabel: "Avatar",
+ avatarPreviewAlt: "Avatar-Vorschau",
+ emailDescription:
+ "Deine E-Mail-Adresse wird verwendet, um dich zu identifizieren und kann nicht geändert werden.",
+ emailLabel: "E-Mail",
+ emailPlaceholder: "Deine E-Mail-Adresse ...",
+ nameDescription:
+ "Dein Name wird in allen Organisationen in der gesamten Anwendung angezeigt.",
+ nameLabel: "Name",
+ namePlaceholder: "Dein Name ...",
+ save: "Änderungen speichern",
+ saving: "Änderungen werden gespeichert ...",
+ },
+ pageTitle: "Konto",
+ toast: {
+ userAccountDeleted: "Dein Konto wurde gelöscht",
+ userAccountUpdated: "Dein Konto wurde aktualisiert",
+ },
+ },
+} satisfies typeof import("../en/settings").default;
diff --git a/apps/react-router/saas-template/app/features/localization/locales/de/translation.ts b/apps/react-router/saas-template/app/features/localization/locales/de/translation.ts
new file mode 100644
index 0000000..6288653
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/de/translation.ts
@@ -0,0 +1,12 @@
+export default {
+ appName: "React Router SaaS Template",
+ breadcrumbNavigation: "Navigationspfad",
+ loading: "Laden",
+ notFound: {
+ description:
+ "Entschuldigung, wir konnten die Seite, die du suchst, nicht finden.",
+ homeLink: "Zurück zur Startseite",
+ status: "404",
+ title: "Seite nicht gefunden",
+ },
+} satisfies typeof import("../en/translation").default;
diff --git a/apps/react-router/saas-template/app/features/localization/locales/de/user-authentication.ts b/apps/react-router/saas-template/app/features/localization/locales/de/user-authentication.ts
new file mode 100644
index 0000000..489a9fa
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/de/user-authentication.ts
@@ -0,0 +1,92 @@
+export default {
+ layout: {
+ backgroundPathsTitle: "Animierte Hintergrundpfade",
+ home: "Startseite",
+ quote:
+ "Dieses Authentifizierungssystem hat verändert, wie wir Benutzer-Logins handhaben. Der Magic-Link-Flow ist nahtlos und unsere Benutzer lieben es.",
+ quoteAuthor: "Alex Chen, Lead Developer",
+ },
+ login: {
+ emailLabel: "E-Mail",
+ emailPlaceholder: "du@beispiel.de",
+ errors: {
+ invalidEmail: "Bitte gib eine gültige E-Mail-Adresse ein.",
+ },
+ form: {
+ joinOrganization: "Melde dich an, um {{organizationName}} beizutreten",
+ joinOrganizationDescription:
+ "{{creatorName}} hat dich eingeladen, {{organizationName}} beizutreten.",
+ loginFailed: "Anmeldung fehlgeschlagen. Bitte versuche es erneut.",
+ userDoesntExist:
+ "Benutzer mit der angegebenen E-Mail existiert nicht. Wolltest du stattdessen ein neues Konto erstellen?",
+ },
+ googleButton: "Mit Google fortfahren",
+ magicLink: {
+ alertDescription: "Denk daran, auch deinen Spam-Ordner zu überprüfen.",
+ cardDescription:
+ "Wir haben einen sicheren Anmeldelink an deine E-Mail-Adresse gesendet. Bitte überprüfe deinen Posteingang und klicke auf den Link, um auf dein Konto zuzugreifen.",
+ cardTitle: "Überprüfe deine E-Mails",
+ countdownMessage_one:
+ "Nachdem du auf den Link geklickt hast, <1>schließe bitte diesen Tab1>. Wenn du die E-Mail nicht innerhalb von {{count}} Sekunde erhalten hast, kannst du einen weiteren Link anfordern.",
+ countdownMessage_other:
+ "Nachdem du auf den Link geklickt hast, <1>schließe bitte diesen Tab1>. Wenn du die E-Mail nicht innerhalb von {{count}} Sekunden erhalten hast, kannst du einen weiteren Link anfordern.",
+ countdownMessage_zero:
+ "Nachdem du auf den Link geklickt hast, <1>schließe bitte diesen Tab1>. Wenn du die E-Mail nicht erhalten hast, kannst du jetzt einen neuen Anmeldelink anfordern.",
+ resendButton: "Neuen Anmeldelink anfordern",
+ resendButtonLoading: "Wird gesendet...",
+ resendSuccess:
+ "Ein neuer Anmeldelink wurde an deine E-Mail-Adresse gesendet. Bitte überprüfe deinen Posteingang.",
+ },
+ pageTitle: "Anmelden",
+ separator: "Oder",
+ signupCta: "Noch kein Konto? Registrieren ",
+ submitButton: "Mit E-Mail anmelden",
+ submitButtonSubmitting: "Wird angemeldet...",
+ subtitle: "Gib deine E-Mail unten ein, um dich bei deinem Konto anzumelden",
+ title: "Willkommen zurück",
+ },
+ register: {
+ emailLabel: "E-Mail",
+ emailPlaceholder: "du@beispiel.de",
+ errors: {
+ invalidEmail: "Bitte gib eine gültige E-Mail-Adresse ein.",
+ },
+ form: {
+ joinOrganization: "Registriere dich, um {{organizationName}} beizutreten",
+ joinOrganizationDescription:
+ "{{creatorName}} hat dich eingeladen, {{organizationName}} beizutreten.",
+ registrationFailed:
+ "Registrierung fehlgeschlagen. Bitte versuche es erneut.",
+ termsAndPrivacy:
+ "Durch das Erstellen eines Kontos stimmst du unseren <1>Nutzungsbedingungen1> und <2>Datenschutzrichtlinien2> zu.",
+ userAlreadyExists:
+ "Benutzer mit der angegebenen E-Mail existiert bereits. Wolltest du dich stattdessen anmelden?",
+ },
+ googleButton: "Mit Google fortfahren",
+ legal:
+ "Durch Klicken auf Fortfahren stimmst du unseren Nutzungsbedingungen und Datenschutzrichtlinien zu.",
+ loginCta: "Hast du bereits ein Konto? Anmelden ",
+ magicLink: {
+ alertDescription: "Denk daran, auch deinen Spam-Ordner zu überprüfen.",
+ cardDescription:
+ "Wir haben einen Verifizierungslink an deine E-Mail-Adresse gesendet. Bitte überprüfe deinen Posteingang und klicke auf den Link, um deine Registrierung abzuschließen.",
+ cardTitle: "Verifiziere deine E-Mail",
+ countdownMessage_one:
+ "Nachdem du auf den Link geklickt hast, <1>schließe bitte diesen Tab1>. Wenn du die E-Mail nicht innerhalb von {{count}} Sekunde erhalten hast, kannst du einen weiteren Verifizierungslink anfordern.",
+ countdownMessage_other:
+ "Nachdem du auf den Link geklickt hast, <1>schließe bitte diesen Tab1>. Wenn du die E-Mail nicht innerhalb von {{count}} Sekunden erhalten hast, kannst du einen weiteren Verifizierungslink anfordern.",
+ countdownMessage_zero:
+ "Nachdem du auf den Link geklickt hast, <1>schließe bitte diesen Tab1>. Wenn du die E-Mail nicht erhalten hast, kannst du jetzt einen neuen Verifizierungslink anfordern.",
+ resendButton: "Neuen Verifizierungslink anfordern",
+ resendButtonLoading: "Wird gesendet...",
+ resendSuccess:
+ "Ein neuer Verifizierungslink wurde an deine E-Mail-Adresse gesendet. Bitte überprüfe deinen Posteingang.",
+ },
+ pageTitle: "Registrieren",
+ separator: "Oder",
+ submitButton: "Konto erstellen",
+ submitButtonSubmitting: "Konto wird erstellt...",
+ subtitle: "Gib deine E-Mail unten ein, um dein Konto zu erstellen",
+ title: "Erstelle ein Konto",
+ },
+} satisfies typeof import("../en/user-authentication").default;
diff --git a/apps/react-router/saas-template/app/features/localization/locales/en/billing.ts b/apps/react-router/saas-template/app/features/localization/locales/en/billing.ts
new file mode 100644
index 0000000..37fca8b
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/en/billing.ts
@@ -0,0 +1,254 @@
+/** biome-ignore-all lint/suspicious/noTemplateCurlyInString: It's a currency */
+export default {
+ billingPage: {
+ breadcrumb: "Billing",
+ cancelAtPeriodEndBanner: {
+ button: "Resume subscription",
+ description: "Your subscription runs out on {{date}}.",
+ resumeSuccessTitle: "Subscription resumed",
+ resumingSubscription: "Resuming subscription ...",
+ title: "Your subscription is ending soon.",
+ },
+ cancelSubscriptionModal: {
+ cancellingSubscription: "Cancelling subscription ...",
+ changePlan: "Select a different plan",
+ confirm: "Cancel subscription",
+ description:
+ "Canceling your subscription means you will lose access to your benefits at the end of your billing cycle.",
+ features: [
+ "SSO",
+ "Unlimited members",
+ "Unlimited private projects",
+ "Priority support",
+ ],
+ title: "Are you sure you want to cancel your subscription?",
+ },
+ freeTrialBanner: {
+ button: "Add payment information",
+ description: "Your free trial will end on {{date}}.",
+ modal: {
+ description: "Pick a plan that fits your needs.",
+ title: "Choose your plan",
+ },
+ title: "Your organization is currently on a free trial.",
+ },
+ openingCustomerPortal: "Opening customer portal ...",
+ pageDescription: "Manage your billing information.",
+ pageTitle: "Billing",
+ paymentInformation: {
+ billingEmail: "Billing Email",
+ editButton: "Edit",
+ heading: "Payment Information",
+ },
+ pendingDowngradeBanner: {
+ button: "Keep current subscription",
+ description:
+ "Your subscription will downgrade to the {{planName}} ({{billingInterval}}) plan on {{date}}.",
+ intervals: {
+ annual: "annual",
+ monthly: "monthly",
+ },
+ loadingButton: "Updating subscription ...",
+ successTitle: "Current subscription kept",
+ title: "Downgrade scheduled",
+ },
+ planInformation: {
+ amountFormat: "${{amount}}",
+ currentPlan: "Current Plan",
+ heading: "Your Plan",
+ managePlan: "Manage plan",
+ manageUsers: "Manage users",
+ nextBillingDate: "Next Billing Date",
+ projectedTotal: "Projected Total",
+ rateFormatAnnual: "${{amount}} <1>per user billed annually1>",
+ rateFormatMonthly: "${{amount}} <1>per user billed monthly1>",
+ users: "Users",
+ usersFormat: "{{current}} / {{max}}",
+ viewInvoices: "View invoices",
+ },
+ pricingModal: {
+ addingPaymentInformation: "Adding payment information ...",
+ addPaymentInformation: "Add payment information",
+ cancelSubscriptionBanner: {
+ button: "Cancel subscription",
+ description:
+ "After cancelling your subscription, you will be able to use your account until the end of the current billing period.",
+ title: "Cancel subscription",
+ },
+ currentPlan: "Current Plan",
+ description: "Pick a plan that fits your needs.",
+ downgradeButton: "Downgrade",
+ downgrading: "Downgrading ...",
+ switchToAnnualButton: "Switch to annual",
+ switchToMonthlyButton: "Switch to monthly",
+ title: "Manage plan",
+ upgradeButton: "Upgrade",
+ upgrading: "Upgrading ...",
+ },
+ subscriptionCancelledBanner: {
+ button: "Reactivate subscription",
+ description: "Your subscription has been cancelled.",
+ modal: {
+ description: "Pick a plan that fits your needs.",
+ title: "Choose your plan to reactivate your subscription",
+ },
+ title: "Your subscription is inactive.",
+ },
+ updateBillingEmailModal: {
+ description: "Your invoices will be sent to this email address.",
+ emailInvalid: "A valid email consists of characters, '@' and '.'.",
+ emailLabel: "Email",
+ emailPlaceholder: "billing@company.com",
+ emailRequired: "Please enter a valid email (required).",
+ savingChanges: "Saving changes ...",
+ submitButton: "Save changes",
+ successTitle: "Billing email updated",
+ title: "Edit your billing email",
+ },
+ },
+ billingSidebarCard: {
+ activeTrial: {
+ button: "Add payment information",
+ description: "Trial ends on {{date}}.",
+ title: "Business Plan (Trial)",
+ },
+ billingModal: {
+ description: "Pick a plan that fits your needs.",
+ title: "Choose your plan",
+ },
+ subscriptionInactive: {
+ button: "Choose plan",
+ description: "Renew to keep using the app.",
+ modal: {
+ description: "Pick a plan that fits your needs.",
+ title: "Choose your plan to reactivate your subscription",
+ },
+ title: "Subscription inactive",
+ },
+ trialEnded: {
+ button: "Resume subscription",
+ description: "Trial ended on {{date}}.",
+ title: "Business Plan (Trial)",
+ },
+ },
+ billingSuccessPage: {
+ goToDashboard: "Go to your dashboard",
+ pageTitle: "Successfully subscribed!",
+ paymentSuccessful: "Payment successful",
+ productReady:
+ "Your SaaS product is ready and eager to help you maximize the usage of your time and serve your customers. Say goodbye to tedious set up and hello to just building.",
+ thankYou:
+ "Thank you for trusting React Router SaaS Template. Your successful journey towards building and maintaining a SaaS product starts now!",
+ },
+ contactSales: {
+ company: "Company",
+ companyNameLabel: "Company",
+ companyNamePlaceholder: "Company name",
+ companyNameRequired: "Please enter your company name (required).",
+ companyNameTooLong: "Your company name must not exceed 255 characters.",
+ companyPlaceholder: "Company name",
+ contactSalesDescription:
+ "We'll discuss your requirements, demo the product, and set up the right plan and pricing for you.",
+ contactSalesTitle: "Talk to our sales team about your needs.",
+ enterpriseSales: "Enterprise sales",
+ firstNameLabel: "First name",
+ firstNamePlaceholder: "First name",
+ firstNameRequired: "Please enter your first name (required).",
+ firstNameTooLong: "Your first name must not exceed 255 characters.",
+ lastNameLabel: "Last name",
+ lastNamePlaceholder: "Last name",
+ lastNameRequired: "Please enter your last name (required).",
+ lastNameTooLong: "Your last name must not exceed 255 characters.",
+ messageLabel: "Message",
+ messagePlaceholder: "Describe your project, needs and timeline.",
+ messageRequired: "Please enter a message describing your needs (required).",
+ messageTooLong: "Your message must not exceed 5000 characters.",
+ pageTitle: "Contact Sales",
+ phoneNumberLabel: "Phone number",
+ phoneNumberPlaceholder: "Where do you want us to call you?",
+ phoneNumberRequired: "Please enter your phone number (required).",
+ submitButton: "Contact sales",
+ submitButtonLoading: "Contacting sales ...",
+ submitDisclaimer:
+ "By submitting this form, I agree to be contacted by the sales team.",
+ success: "Success!",
+ thankYou: "Thank you for contacting us. We will get back to you shortly.",
+ workEmailInvalid: "Please enter a valid work email, including '@' and '.'.",
+ workEmailLabel: "Work email",
+ workEmailPlaceholder: "name@company.com",
+ workEmailRequired: "Please enter your work email (required).",
+ workEmailTooLong: "Your work email must not exceed 255 characters.",
+ },
+ noCurrentPlanModal: {
+ annual: "Annual",
+ disabledPlansAlert: {
+ descriptionPlural:
+ "You currently have {{currentSeats}} users, and the {{plan1Title}} plan only supports {{plan1Capacity}} user while the {{plan2Title}} plan only supports {{plan2Capacity}} users. Please choose a plan that supports at least {{currentSeats}} seats.",
+ descriptionSingular:
+ "You currently have {{currentSeats}} users, and the {{planTitle}} plan only supports {{planCapacity}} user. Please choose a plan that supports at least {{currentSeats}} seats.",
+ title: "Why are some plans disabled?",
+ },
+ monthly: "Monthly",
+ tierCardBusy: "Subscribing ...",
+ tierCardCta: "Subscribe Now",
+ },
+ pricing: {
+ annual: "Annual",
+ custom: "Custom",
+ free: "Free",
+ monthly: "Monthly",
+ mostPopular: "Most Popular",
+ plans: {
+ enterprise: {
+ cta: "Contact Sales",
+ description: "For large organizations who need custom solutions.",
+ features: [
+ "Custom Integrations",
+ "Unlimited members",
+ "Dedicated support",
+ ],
+ featuresTitle: "All Business features, plus:",
+ title: "Enterprise",
+ },
+ high: {
+ cta: "Start a 14-day free trial",
+ description: "For professionals and businesses who want to grow.",
+ features: ["SSO", "Up to 25 members", "Priority support"],
+ featuresTitle: "Everything in Startup, plus:",
+ title: "Business",
+ },
+ low: {
+ cta: "Get Started",
+ description:
+ "For hobbyists and beginners who are learning and exploring.",
+ features: [
+ "Unlimited public projects",
+ "Community support",
+ "1 member",
+ ],
+ featuresTitle: "Features:",
+ title: "Hobby",
+ },
+ mid: {
+ cta: "Start a 14-day free trial",
+ description:
+ "Perfect for startups with small teams that need to scale.",
+ features: [
+ "Unlimited private projects",
+ "Remove branding",
+ "Up to 5 members",
+ ],
+ featuresTitle: "Everything in Hobby, plus:",
+ title: "Startup",
+ },
+ },
+ price: "{{price}} <1>/user per month1>",
+ saveAnnually: "Save up to 20% on the annual plan.",
+ },
+ pricingPage: {
+ pageDescription:
+ "Obviously this template is free. But this is what your pricing could look like.",
+ pageTitle: "Pricing",
+ pricingHeading: "Choose your plan",
+ },
+};
diff --git a/apps/react-router/saas-template/app/features/localization/locales/en/color-scheme.ts b/apps/react-router/saas-template/app/features/localization/locales/en/color-scheme.ts
new file mode 100644
index 0000000..7e3bd07
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/en/color-scheme.ts
@@ -0,0 +1,7 @@
+export default {
+ dropdownMenuButtonLabel: "Open theme menu",
+ dropdownMenuItemDark: "Dark",
+ dropdownMenuItemLight: "Light",
+ dropdownMenuItemSystem: "System",
+ dropdownMenuLabel: "Appearance",
+};
diff --git a/apps/react-router/saas-template/app/features/localization/locales/en/drag-and-drop.ts b/apps/react-router/saas-template/app/features/localization/locales/en/drag-and-drop.ts
new file mode 100644
index 0000000..732c1ce
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/en/drag-and-drop.ts
@@ -0,0 +1,4 @@
+export default {
+ extensions: "{{extensions}} up to {{maxFileSize}}",
+ heading: "Drag and drop or <1>select files1> to upload",
+};
diff --git a/apps/react-router/saas-template/app/features/localization/locales/en/index.ts b/apps/react-router/saas-template/app/features/localization/locales/en/index.ts
new file mode 100644
index 0000000..ddd1159
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/en/index.ts
@@ -0,0 +1,25 @@
+import type { ResourceLanguage } from "i18next";
+
+import billing from "./billing";
+import colorScheme from "./color-scheme";
+import dragAndDrop from "./drag-and-drop";
+import landing from "./landing";
+import notifications from "./notifications";
+import onboarding from "./onboarding";
+import organizations from "./organizations";
+import settings from "./settings";
+import translation from "./translation";
+import userAuthentication from "./user-authentication";
+
+export default {
+ billing,
+ colorScheme,
+ dragAndDrop,
+ landing,
+ notifications,
+ onboarding,
+ organizations,
+ settings,
+ translation,
+ userAuthentication,
+} satisfies ResourceLanguage;
diff --git a/apps/react-router/saas-template/app/features/localization/locales/en/landing.ts b/apps/react-router/saas-template/app/features/localization/locales/en/landing.ts
new file mode 100644
index 0000000..2b725ce
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/en/landing.ts
@@ -0,0 +1,188 @@
+export default {
+ cta: {
+ buttons: {
+ primary: "Get started",
+ secondary: "Documentation",
+ },
+ description:
+ "Set up your project today and start building tomorrow. Save months on setup so you can focus on features.",
+ title: "Launch your SaaS asap.",
+ },
+ description: {
+ eyebrow: "Why use this template?",
+ features: [
+ {
+ description:
+ "TypeScript, ESLint & Prettier, Commitlint and GitHub Actions are all wired up, so your team writes clean, consistent code from day one.",
+ title: "Zero-config tooling.",
+ },
+ {
+ description:
+ "Jumping into a template with tens of thousands of lines of code is scary. Complete unit, integration, component and E2E tests (Vitest, React Testing Library & Playwright) let you refactor without fear.",
+ title: "Built with TDD.",
+ },
+ {
+ description:
+ "See real-world patterns for image uploads (client vs. server), MSW-wrapped mocks on both client and server, factory functions and test helpers for easy testing, seamless dark-mode without flicker, Stripe payments integration, authentication, and more.",
+ title: "Learn by example.",
+ },
+ {
+ description:
+ "Everything's modular - strip out what you don't need, tweak folder structure, and capture new requirements with tests as you grow.",
+ title: "Fully customizable.",
+ },
+ ],
+ image: {
+ dark: "Product screenshot (dark)",
+ light: "Product screenshot (light)",
+ },
+ subtitle:
+ "When you're launching a SaaS, your biggest advantage is speed. Use this production-ready foundation so you can skip months of setup and dive straight into shipping features.",
+ title: "Focus on your PMF",
+ },
+ faq: {
+ items: [
+ {
+ answer:
+ "Yes! This is an open-source project and is free to use under the MIT license. However, some of the integrated services (for example Supabase, Stripe, hosting providers, etc.) may incur their own usage fees, which you are responsible for covering.",
+ question: "Is this free?",
+ },
+ {
+ answer:
+ 'No. This is an independent, community-maintained, open-source template. It is not sponsored by, affiliated with, or endorsed by Shopify Inc. "React Router" and its logos are trademarks of Shopify Inc., and this project makes no claim of official partnership or endorsement.',
+ question:
+ "Is this template officially supported by or endorsed by Shopify Inc.?",
+ },
+ {
+ answer:
+ "Glad you asked! We're always looking for help with the project. If you're interested in contributing, please check out our <1>contributing guide1> for more information.",
+ links: {
+ contributing:
+ "https://github.com/janhesters/react-router-saas-template/blob/main/CONTRIBUTING.md",
+ },
+ question: "How do I contribute?",
+ },
+ {
+ answer:
+ "You can ask in GitHub discussions. If you're interested in professional help building your app from senior React developers, reach out to us at <1>ReactSquad1>.",
+ links: {
+ reactsquad: "https://reactsquad.io",
+ },
+ question: "I'm stuck! Where can I get help?",
+ },
+ {
+ answer:
+ "Thank you so much! You can follow Jan Hesters on <1>X1>, <2>LinkedIn2>, or <3>YouTube3>, and drop a thank you. And if you know anyone who needs senior React developers, please recommend <4>ReactSquad4>. Thank you!",
+ links: {
+ linkedin: "https://www.linkedin.com/in/jan-hesters/",
+ reactsquad: "https://reactsquad.io",
+ x: "https://x.com/janhesters",
+ youtube: "https://www.youtube.com/@janhesters",
+ },
+ question: "This is awesome! How can I support you?",
+ },
+ ],
+ title: "Frequently asked questions",
+ },
+ features: {
+ cards: [
+ {
+ description:
+ "Every screen works on desktop, tablet and mobile. So you can serve all customers.",
+ eyebrow: "TailwindCSS & Shadcn",
+ image: {
+ dark: "Mobile screenshot (dark)",
+ light: "Mobile screenshot (light)",
+ },
+ title: "Responsiveness & Accessibility",
+ },
+ {
+ description:
+ "Most SaaS apps charge some form of recurring subscription. This template comes with three tiers preconfigured. But even if your needs are different, this will give you a head start.",
+ eyebrow: "Stripe",
+ image: {
+ dark: "Billing (dark)",
+ light: "Billing (light)",
+ },
+ title: "Billing",
+ },
+ {
+ description:
+ "With Supabase Auth (email magic links & Google OAuth), a managed Postgres database, and Supabase Storage, this template takes care of your backend. Storage even offers two upload flows: direct client uploads for large files and server-mediated uploads for small assets like profile avatars.",
+ eyebrow: "Supabase",
+ image: {
+ dark: "Authentication (dark)",
+ light: "Authentication (light)",
+ },
+ title: "Authentication & Database",
+ },
+ {
+ description:
+ "This template includes a notifications system supporting text, mentions, and links, complete with read/unread tracking.",
+ image: {
+ dark: "Notifications (dark)",
+ light: "Notifications (light)",
+ },
+ title: "Notifications",
+ },
+ {
+ description:
+ "Built-in cookie-based dark mode prevents flicker on load and respects light, dark, or system settings out of the box.",
+ title: "Dark Mode",
+ },
+ {
+ description:
+ "Add members via shareable invite links or email invites where you can assign roles - Owner, Admin, or Member - to control access and permissions.",
+ eyebrow: "Multi-tenancy",
+ title: "Member Management",
+ },
+ {
+ description:
+ "Manage translations, switch languages on the fly, and handle locale-specific formatting (dates, numbers, currencies) without any extra setup.",
+ eyebrow: "React i18next",
+ title: "Internationalization",
+ },
+ {
+ description:
+ "Includes user account settings, sending emails (with Resend), an onboarding flow, and a host of other utilities to help you hit the ground running.",
+ eyebrow: "Miscellaneous",
+ title: "And much more ...",
+ },
+ ],
+ eyebrow: "Features",
+ title: "Everything your SaaS needs",
+ },
+ footer: {
+ madeWithLove: "Made with ❤️ by",
+ reactsquad: "ReactSquad",
+ social: {
+ github: "Github",
+ linkedin: "LinkedIn",
+ twitter: "X formerly known as Twitter",
+ },
+ },
+ header: {
+ login: "Login",
+ navLinks: {
+ pricing: "Pricing",
+ },
+ register: "Register",
+ },
+ hero: {
+ badge: "<1>NOT1> an official template",
+ cta: {
+ primary: "Get Started",
+ secondary: "Documentation",
+ },
+ description:
+ "Save your team months when building B2B & B2C SaaS applications with this <1>free1> React Router community template.",
+ image: {
+ dark: "App screenshot (dark)",
+ light: "App screenshot (light)",
+ },
+ title: "SaaS Template",
+ },
+ logos: {
+ title: "The Stack Behind the Template",
+ },
+};
diff --git a/apps/react-router/saas-template/app/features/localization/locales/en/notifications.ts b/apps/react-router/saas-template/app/features/localization/locales/en/notifications.ts
new file mode 100644
index 0000000..0271d54
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/en/notifications.ts
@@ -0,0 +1,20 @@
+export default {
+ notificationMenu: {
+ markAsRead: "Mark as read",
+ triggerButton: "Open notification menu",
+ },
+ notificationsButton: {
+ all: "All",
+ markAllAsRead: "Mark all as read",
+ notifications: "Notifications",
+ openNotifications: "Open notifications",
+ openUnreadNotifications: "Open unread notifications",
+ unread: "Unread",
+ },
+ notificationsPanel: {
+ markAsRead: "Mark as read",
+ noNotificationsDescription: "Your notifications will show up here.",
+ noNotificationsTitle: "No notifications",
+ openMenu: "Open notification menu",
+ },
+};
diff --git a/apps/react-router/saas-template/app/features/localization/locales/en/onboarding.ts b/apps/react-router/saas-template/app/features/localization/locales/en/onboarding.ts
new file mode 100644
index 0000000..51e4991
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/en/onboarding.ts
@@ -0,0 +1,104 @@
+export default {
+ layout: {
+ quote:
+ "This platform has streamlined our entire workflow. The onboarding process was intuitive and got our team up and running in minutes.",
+ quoteAuthor: "Sarah Mitchell, Product Manager",
+ },
+ organization: {
+ companySize: {
+ "1-10": "1-10 employees",
+ "11-50": "11-50 employees",
+ "51-200": "51-200 employees",
+ "201-500": "201-500 employees",
+ "501-1000": "501-1000 employees",
+ "1001+": "1001+ employees",
+ },
+ companySizeDescription:
+ "How many people work at your organization? (optional)",
+ companySizeLabel: "Company size",
+ companySizePlaceholder: "Select company size (optional)",
+ companyType: {
+ agency: "Agency",
+ enterprise: "Enterprise",
+ government: "Government",
+ midMarket: "Mid-market",
+ nonprofit: "Non-profit",
+ startup: "Startup",
+ },
+ companyTypesDescription: "Select all that apply.",
+ companyTypesLabel: "What type of company do you work for?",
+ companyWebsiteDescription: "Your company's website URL (optional).",
+ companyWebsiteLabel: "Company website",
+ companyWebsitePlaceholder: "https://example.com",
+ earlyAccessDescription:
+ "Get early access to new features and help shape the future of our platform.",
+ earlyAccessLabel: "Early Access Program",
+ earlyAccessTitle: "Join our early access program",
+ errors: {
+ companySizeRequired: "Please select your company size.",
+ createOrganizationFailedDescription:
+ "Please try again. If the problem persists, please contact support.",
+ createOrganizationFailedTitle: "Failed to create organization",
+ invalidFileType: "Please upload a valid image file.",
+ logoTooLarge: "Logo must be less than 1MB.",
+ nameMax: "Your organization name must be at most 72 characters long.",
+ nameMin: "Your organization name must be at least 3 characters long.",
+ },
+ heading: "Create your organization",
+ logoDescription: "Upload a logo to represent your organization.",
+ logoFormats: "PNG, JPG, GIF up to 1MB",
+ logoLabel: "Logo",
+ logoPreviewAlt: "Organization logo preview",
+ nameDescription: "Please enter the name of your organization.",
+ nameLabel: "Organization name",
+ namePlaceholder: "Your organization's name ...",
+ recruitingPainPointDescription:
+ "Help us understand your biggest challenges (optional).",
+ recruitingPainPointLabel: "What's your biggest recruiting pain point?",
+ recruitingPainPointPlaceholder:
+ "Tell us about your challenges with hiring, onboarding, or team management...",
+ referralSource: {
+ blogArticle: "Blog article",
+ colleagueReferral: "Colleague referral",
+ industryEvent: "Industry event",
+ onlineAd: "Online ad",
+ other: "Other",
+ partnerReferral: "Partner referral",
+ podcast: "Podcast",
+ productHunt: "Product Hunt",
+ searchEngine: "Search engine",
+ socialMedia: "Social media",
+ wordOfMouth: "Word of mouth",
+ },
+ referralSourcesDescription: "Select all that apply (optional).",
+ referralSourcesLabel: "How did you hear about us?",
+ save: "Continue",
+ saving: "Creating ...",
+ subtitle:
+ "You can invite other users to join your organization later through the organization settings.",
+ title: "Organization",
+ },
+ userAccount: {
+ errors: {
+ invalidFileType: "Please upload a valid image file.",
+ nameMax: "Your name must be at most 128 characters long.",
+ nameMin: "Your name must be at least 2 characters long.",
+ photoTooLarge: "Profile photo must be less than 1MB.",
+ },
+ heading: "Create your account",
+ nameDescription:
+ "Please enter your full name for public display within your organization.",
+ nameLabel: "Name",
+ namePlaceholder: "Your full name ...",
+ profilePhotoDescription:
+ "Upload a profile photo to personalize your account.",
+ profilePhotoFormats: "PNG, JPG, GIF up to 1MB",
+ profilePhotoLabel: "Profile Photo",
+ profilePhotoPreviewAlt: "Profile photo preview",
+ save: "Continue",
+ saving: "Saving ...",
+ subtitle:
+ "Welcome to the React Router SaaS template! Please create your user account to get started.",
+ title: "User account",
+ },
+};
diff --git a/apps/react-router/saas-template/app/features/localization/locales/en/organizations.ts b/apps/react-router/saas-template/app/features/localization/locales/en/organizations.ts
new file mode 100644
index 0000000..5d93e87
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/en/organizations.ts
@@ -0,0 +1,304 @@
+export default {
+ acceptEmailInvite: {
+ acceptInvite: "Accept invite",
+ acceptInviteInstructions:
+ "Click the button below to sign up. By using this email invite you will automatically join the correct organization.",
+ acceptingInvite: "Accepting invite ...",
+ alreadyMemberToastDescription:
+ "You are already a member of {{organizationName}}",
+ alreadyMemberToastTitle: "Already a member",
+ inviteEmailInvalidToastDescription:
+ "The email invite is invalid or has expired",
+ inviteEmailInvalidToastTitle: "Failed to accept invite",
+ inviteEmailValidToastDescription:
+ "Please register or log in to accept the invitation.",
+ inviteEmailValidToastTitle: "Success",
+ inviteYouToJoin: "{{inviterName}} invites you to join {{organizationName}}",
+ joinSuccessToastDescription: "You are now a member of {{organizationName}}",
+ joinSuccessToastTitle: "Successfully joined organization",
+ organizationFullToastDescription:
+ "The organization has reached its member limit",
+ organizationFullToastTitle: "Organization is full",
+ pageTitle: "Invitation",
+ welcomeToAppName: "Welcome to {{appName}}",
+ },
+ acceptInviteLink: {
+ acceptInvite: "Accept invite",
+ acceptInviteInstructions:
+ "Click the button below to sign up. By using this link you will automatically join the correct organization.",
+ acceptingInvite: "Accepting invite ...",
+ alreadyMemberToastDescription:
+ "You are already a member of {{organizationName}}",
+ alreadyMemberToastTitle: "Already a member",
+ inviteLinkInvalidToastDescription:
+ "The invite link is invalid or has expired",
+ inviteLinkInvalidToastTitle: "Failed to accept invite",
+ inviteLinkValidToastDescription:
+ "Please register or log in to accept the invitation",
+ inviteLinkValidToastTitle: "Success",
+ inviteYouToJoin: "{{inviterName}} invites you to join {{organizationName}}",
+ joinSuccessToastDescription: "You are now a member of {{organizationName}}",
+ joinSuccessToastTitle: "Successfully joined organization",
+ organizationFullToastDescription:
+ "The organization has reached its member limit",
+ organizationFullToastTitle: "Organization is full",
+ pageTitle: "Invitation",
+ welcomeToAppName: "Welcome to {{appName}}",
+ },
+ analytics: {
+ breadcrumb: "Analytics",
+ pageTitle: "Analytics",
+ },
+ dashboard: {
+ breadcrumb: "Dashboard",
+ pageTitle: "Dashboard",
+ },
+ getHelp: {
+ breadcrumb: "Get Help",
+ pageTitle: "Get Help",
+ },
+ layout: {
+ appSidebar: {
+ nav: {
+ app: {
+ analytics: "Analytics",
+ dashboard: "Dashboard",
+ projects: {
+ active: "Active",
+ all: "All",
+ title: "Projects",
+ },
+ title: "App",
+ },
+ settings: {
+ getHelp: "Get Help",
+ organizationSettings: "Organization Settings",
+ title: "Settings",
+ },
+ sidebar: "Sidebar",
+ },
+ },
+ navUser: {
+ account: "Account",
+ logOut: "Log out",
+ userMenuButtonLabel: "Open user menu",
+ },
+ organizationSwitcher: {
+ newOrganization: "New organization",
+ organizations: "Organizations",
+ },
+ },
+ new: {
+ backButtonLabel: "Back",
+ form: {
+ cardDescription:
+ "Tell us about your organization. You'll still remain a member of your current organization, with the ability to switch between them anytime.",
+ cardTitle: "Create a new organization",
+ logoInvalid: "Logo must be valid.",
+ logoLabel: "Logo",
+ logoMustBeUrl: "Logo must be a valid URL.",
+ nameLabel: "Organization name",
+ nameMaxLength: "Organization name must be less than 255 characters long.",
+ nameMinLength: "Organization name must be at least 3 characters long.",
+ namePlaceholder: "Organization name ...",
+ nameRequired: "Organization name is required.",
+ save: "Save",
+ saving: "Saving ...",
+ submitButton: "Create organization",
+ termsAndPrivacy:
+ "By creating an organization, you agree to our <1>Terms of Service1> and <2>Privacy Policy2>.",
+ },
+ pageTitle: "New organization",
+ },
+ organizationsList: {
+ cardDescription:
+ "This is a list of all the organizations you're a member of. Pick one to enter and visit its dashboard.",
+ cardTitle: "Your organizations",
+ pageTitle: "Organization List",
+ roles: {
+ admin: "Admin",
+ member: "Member",
+ owner: "Owner",
+ },
+ title: "Organization List",
+ },
+ projects: {
+ breadcrumb: "All Projects",
+ pageTitle: "All Projects",
+ },
+ projectsActive: {
+ breadcrumb: "Active Projects",
+ pageTitle: "Active Projects",
+ },
+ settings: {
+ breadcrumb: "Settings",
+ general: {
+ breadcrumb: "General",
+ dangerZone: {
+ cancelButton: "Cancel",
+ confirmationLabel:
+ 'To confirm, type "{{organizationName}}" in the box below',
+ confirmationPlaceholder: "Enter organization name...",
+ deleteButton: "Delete this organization",
+ deleteButtonSubmitting: "Deleting organization...",
+ deleteDescription:
+ "Once deleted, it will be gone forever. Please be certain.",
+ deleteTitle: "Delete this organization",
+ dialogDescription:
+ "Are you sure you want to delete this organization? This action cannot be undone.",
+ dialogTitle: "Delete Organization",
+ errors: {
+ confirmationMismatch:
+ "The confirmation text doesn't match the organization name.",
+ confirmationRequired:
+ "Please enter the organization name to confirm.",
+ },
+ title: "Danger Zone",
+ triggerButton: "Delete organization",
+ },
+ description: "General settings for this organization.",
+ errors: {
+ invalidFileType:
+ "Invalid file type. Only PNG, JPG, JPEG, GIF, and WebP images are allowed.",
+ logoTooLarge: "Logo must be less than 1MB.",
+ nameMax: "Organization name must be at most 255 characters long.",
+ nameMin: "Organization name must be at least 3 characters long.",
+ },
+ form: {
+ logoDescription:
+ "Your organization's logo will be shown across the application.",
+ logoFormats: "PNG, JPG, GIF, or WebP (max. 1MB)",
+ logoLabel: "Organization logo",
+ logoPreviewAlt: "Organization logo preview",
+ nameDescription:
+ "Your organization's public display name. Warning: Changing the name will break all existing links to your organization.",
+ nameLabel: "Organization name",
+ namePlaceholder: "Organization name ...",
+ nameWarningContent:
+ "When you change your organization's name, the URL slug will change. This means any bookmarked links or shared URLs will no longer work. Make sure to update any references to the old URL.",
+ save: "Save changes",
+ saving: "Saving changes ...",
+ },
+ organizationInfo: {
+ logoAlt: "Organization logo",
+ logoDescription: "The logo that represents your organization.",
+ logoTitle: "Organization Logo",
+ nameDescription:
+ "The name of your organization as it appears to others.",
+ nameTitle: "Organization Name",
+ },
+ pageTitle: "General",
+ toast: {
+ organizationDeleted: "Organization has been deleted",
+ organizationProfileUpdated: "Organization has been updated",
+ },
+ },
+ layout: {
+ billing: "Billing",
+ general: "General",
+ settingsNav: "Settings navigation",
+ teamMembers: "Team Members",
+ },
+ meta: {
+ title: "Settings",
+ },
+ teamMembers: {
+ breadcrumb: "Team Members",
+ description: "Manage your team members and their permissions.",
+ descriptionMember: "View who is a member of your organization.",
+ inviteByEmail: {
+ cardDescription:
+ "Enter your colleagues' email addresses, and we'll send them a personalized invitation to join your organization. You can also choose the role they'll join with.",
+ cardTitle: "Invite by Email",
+ form: {
+ email: "Email",
+ emailAlreadyMember: "{{email}} is already a member",
+ emailInvalid: "A valid email consists of characters, '@' and '.'.",
+ emailPlaceholder: "Your colleague's email ...",
+ emailRequired: "Please enter a valid email (required).",
+ inviting: "Sending email invitation ...",
+ organizationFull:
+ "You have reached the maximum number of seats for your subscription.",
+ role: "Role",
+ roleAdmin: "Admin",
+ roleMember: "Member",
+ roleOwner: "Owner",
+ rolePlaceholder: "Select a role ...",
+ submitButton: "Send email invitation",
+ },
+ inviteEmail: {
+ buttonText: "Join {{organizationName}}",
+ callToAction: "Join now to collaborate with your colleagues!",
+ description:
+ "{{inviterName}} has invited you to join {{organizationName}} in {{appName}}. This invite expires in 48 hours.",
+ subject: "{{inviteName}} invited you to {{appName}}",
+ title: "You've Been Invited to {{appName}}!",
+ },
+ organizationFullToastDescription:
+ "You've used up all your available seats.",
+ organizationFullToastTitle: "Organization is full",
+ successToastTitle: "Email invitation sent",
+ },
+ inviteLink: {
+ cardDescription:
+ 'After generating the link, it will be valid for 48 hours. Copy and share it with anyone you\'d like to join with the role of "Member".',
+ cardTitle: "Share an Invite Link",
+ copied: "Copied!",
+ copyInviteLink: "Copy invite link to clipboard",
+ createNewInviteLink: "Create new invite link",
+ creating: "Creating ...",
+ deactivateLink: "Deactivate link",
+ deactivating: "Deactivating ...",
+ goToLink: "Go to the invite link's page",
+ inviteLinkCopied: "Invite link copied to clipboard",
+ linkValidUntil: "Your link is valid until {{date}}.",
+ newLinkDeactivatesOld:
+ "Generating a new link automatically deactivates the old one.",
+ regenerateLink: "Regenerate link",
+ regenerating: "Regenerating ...",
+ },
+ organizationIsFullAlert: {
+ button: "Go to billing",
+ description:
+ "Switch to a higher-tier plan or contact sales to invite new members.",
+ title: "No seats remaining",
+ },
+ pageTitle: "Team Members",
+ table: {
+ avatarHeader: "Avatar",
+ emailHeader: "Email",
+ nameHeader: "Name",
+ noResults: "No members found.",
+ pagination: {
+ goToFirst: "Go to first page",
+ goToLast: "Go to last page",
+ goToNext: "Go to next page",
+ goToPrevious: "Go to previous page",
+ pageInfo: "Page {{current}} of {{total}}",
+ rowsPerPage: "Rows per page",
+ },
+ roleHeader: "Role",
+ roleSwitcher: {
+ admin: "Admin",
+ adminDescription: "Can edit the organization and manage billing.",
+ commandLabel: "Select new role",
+ deactivated: "Deactivated",
+ deactivatedDescription: "Access revoked to everything.",
+ member: "Member",
+ memberDescription: "Access to standard features.",
+ noRolesFound: "No roles found.",
+ owner: "Owner",
+ ownerDescription: "Can assign roles and delete the organization.",
+ rolesPlaceholder: "Select new role ...",
+ },
+ status: {
+ createdTheOrganization: "Joined",
+ emailInvitePending: "Pending",
+ joinedViaEmailInvite: "Joined",
+ joinedViaLink: "Joined",
+ },
+ statusHeader: "Status",
+ },
+ },
+ },
+};
diff --git a/apps/react-router/saas-template/app/features/localization/locales/en/settings.ts b/apps/react-router/saas-template/app/features/localization/locales/en/settings.ts
new file mode 100644
index 0000000..4b1bf18
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/en/settings.ts
@@ -0,0 +1,60 @@
+export default {
+ layout: {
+ backButtonLabel: "Back to home",
+ pageTitle: "Settings",
+ },
+ userAccount: {
+ dangerZone: {
+ blockingOrganizations_one:
+ "Your account is currently an owner in this organization: <1>{{organizations}}1>.",
+ blockingOrganizations_other:
+ "Your account is currently an owner in these organizations: <1>{{organizations}}1>.",
+ blockingOrganizationsHelp:
+ "You must remove yourself, transfer ownership, or delete this organization before you can delete your user.",
+ cancel: "Cancel",
+ deleteButton: "Delete Account",
+ deleteConfirm: "Delete this account",
+ deleteDescription:
+ "Once you delete your account, there is no going back. Please be certain.",
+ deleteTitle: "Delete Account",
+ deleting: "Deleting account ...",
+ dialogDescription:
+ "Are you sure you want to delete your account? This action cannot be undone.",
+ dialogTitle: "Delete Account",
+ implicitlyDeletedOrganizations_one:
+ "The following organization will be deleted: <1>{{organizations}}1>",
+ implicitlyDeletedOrganizations_other:
+ "The following organizations will be deleted: <1>{{organizations}}1>",
+ title: "Danger Zone",
+ },
+ description: "Manage your account settings.",
+ errors: {
+ avatarTooLarge: "Avatar must be less than 1MB.",
+ invalidFileType:
+ "Invalid file type. Only PNG, JPG, JPEG, GIF, and WebP images are allowed.",
+ nameMax: "Your name must be at most 128 characters long.",
+ nameMin: "Your name must be at least 2 characters long.",
+ },
+ form: {
+ avatarDescription: "Your avatar will be shown across the application.",
+ avatarFormats: "PNG, JPG, GIF, or WebP (max. 1MB)",
+ avatarLabel: "Avatar",
+ avatarPreviewAlt: "Avatar preview",
+ emailDescription:
+ "Your email address is used to identify you and cannot be changed.",
+ emailLabel: "Email",
+ emailPlaceholder: "Your email address ...",
+ nameDescription:
+ "Your name will be shown in all organizations across the application.",
+ nameLabel: "Name",
+ namePlaceholder: "Your name ...",
+ save: "Save changes",
+ saving: "Saving changes ...",
+ },
+ pageTitle: "Account",
+ toast: {
+ userAccountDeleted: "Your account has been deleted",
+ userAccountUpdated: "Your account has been updated",
+ },
+ },
+};
diff --git a/apps/react-router/saas-template/app/features/localization/locales/en/translation.ts b/apps/react-router/saas-template/app/features/localization/locales/en/translation.ts
new file mode 100644
index 0000000..1b5e62f
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/en/translation.ts
@@ -0,0 +1,11 @@
+export default {
+ appName: "React Router SaaS Template",
+ breadcrumbNavigation: "breadcrumb",
+ loading: "Loading",
+ notFound: {
+ description: "Sorry, we couldn't find the page you're looking for.",
+ homeLink: "Return Home",
+ status: "404",
+ title: "Page Not Found",
+ },
+};
diff --git a/apps/react-router/saas-template/app/features/localization/locales/en/user-authentication.ts b/apps/react-router/saas-template/app/features/localization/locales/en/user-authentication.ts
new file mode 100644
index 0000000..7669901
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/en/user-authentication.ts
@@ -0,0 +1,91 @@
+export default {
+ layout: {
+ backgroundPathsTitle: "Animated background paths",
+ home: "Home",
+ quote:
+ "This authentication system has transformed how we handle user logins. The magic link flow is seamless and our users love it.",
+ quoteAuthor: "Alex Chen, Lead Developer",
+ },
+ login: {
+ emailLabel: "Email",
+ emailPlaceholder: "you@example.com",
+ errors: {
+ invalidEmail: "Please enter a valid email address.",
+ },
+ form: {
+ joinOrganization: "Log in to join {{organizationName}}",
+ joinOrganizationDescription:
+ "{{creatorName}} has invited you to join {{organizationName}}.",
+ loginFailed: "Login failed. Please try again.",
+ userDoesntExist:
+ "User with given email doesn't exist. Did you mean to create a new account instead?",
+ },
+ googleButton: "Continue with Google",
+ magicLink: {
+ alertDescription: "Remember to check your spam folder.",
+ cardDescription:
+ "We've sent a secure login link to your email address. Please check your inbox and click the link to access your account.",
+ cardTitle: "Check your email",
+ countdownMessage_one:
+ "Once you click the link, <1>please close this tab1>. If you haven't received the email within {{count}} second, you may request another link.",
+ countdownMessage_other:
+ "Once you click the link, <1>please close this tab1>. If you haven't received the email within {{count}} seconds, you may request another link.",
+ countdownMessage_zero:
+ "Once you click the link, <1>please close this tab1>. If you haven't received the email, you may request another login link now.",
+ resendButton: "Request new login link",
+ resendButtonLoading: "Sending...",
+ resendSuccess:
+ "A new login link has been sent to your email address. Please check your inbox.",
+ },
+ pageTitle: "Login",
+ separator: "Or",
+ signupCta: "Don't have an account? Sign up ",
+ submitButton: "Sign in with Email",
+ submitButtonSubmitting: "Signing in...",
+ subtitle: "Enter your email below to sign in to your account",
+ title: "Welcome back",
+ },
+ register: {
+ emailLabel: "Email",
+ emailPlaceholder: "you@example.com",
+ errors: {
+ invalidEmail: "Please enter a valid email address.",
+ },
+ form: {
+ joinOrganization: "Register to join {{organizationName}}",
+ joinOrganizationDescription:
+ "{{creatorName}} has invited you to join {{organizationName}}.",
+ registrationFailed: "Registration failed. Please try again.",
+ termsAndPrivacy:
+ "By creating an account, you agree to our <1>Terms of Service1> and <2>Privacy Policy2>.",
+ userAlreadyExists:
+ "User with given email already exists. Did you mean to log in instead?",
+ },
+ googleButton: "Continue with Google",
+ legal:
+ "By clicking continue, you agree to our Terms of Service and Privacy Policy .",
+ loginCta: "Already have an account? Sign in ",
+ magicLink: {
+ alertDescription: "Remember to check your spam folder.",
+ cardDescription:
+ "We've sent a verification link to your email address. Please check your inbox and click the link to complete your registration.",
+ cardTitle: "Verify your email",
+ countdownMessage_one:
+ "Once you click the link, <1>please close this tab1>. If you haven't received the email within {{count}} second, you may request another verification link.",
+ countdownMessage_other:
+ "Once you click the link, <1>please close this tab1>. If you haven't received the email within {{count}} seconds, you may request another verification link.",
+ countdownMessage_zero:
+ "Once you click the link, <1>please close this tab1>. If you haven't received the email, you may request another verification link now.",
+ resendButton: "Request new verification link",
+ resendButtonLoading: "Sending...",
+ resendSuccess:
+ "A new verification link has been sent to your email address. Please check your inbox.",
+ },
+ pageTitle: "Register",
+ separator: "Or",
+ submitButton: "Create Account",
+ submitButtonSubmitting: "Creating account...",
+ subtitle: "Enter your email below to create your account",
+ title: "Create an account",
+ },
+};
diff --git a/apps/react-router/saas-template/app/features/localization/locales/index.ts b/apps/react-router/saas-template/app/features/localization/locales/index.ts
new file mode 100644
index 0000000..c9b5c3c
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/localization/locales/index.ts
@@ -0,0 +1,6 @@
+import type { Resource } from "i18next";
+
+import de from "./de";
+import en from "./en";
+
+export default { de, en } satisfies Resource;
diff --git a/apps/react-router/saas-template/app/features/notifications/notification-components.test.tsx b/apps/react-router/saas-template/app/features/notifications/notification-components.test.tsx
new file mode 100644
index 0000000..5425b97
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/notification-components.test.tsx
@@ -0,0 +1,65 @@
+import { faker } from "@faker-js/faker";
+import { createId } from "@paralleldrive/cuid2";
+import { describe, expect, test } from "vitest";
+
+import type { LinkNotificationProps } from "./notification-components";
+import { LinkNotification } from "./notification-components";
+import { LINK_NOTIFICATION_TYPE } from "./notification-constants";
+import { createRoutesStub, render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createLinkNotificationProps: Factory = ({
+ recipientId = createId(),
+ text = faker.lorem.sentence(),
+ href = faker.internet.url(),
+ isRead = false,
+} = {}) => ({
+ href,
+ isRead,
+ recipientId,
+ text,
+ type: LINK_NOTIFICATION_TYPE,
+});
+
+describe("LinkNotification", () => {
+ test("given: unread notification, should: show unread indicator and menu", () => {
+ const notification = createLinkNotificationProps({ isRead: false });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path: "/" },
+ ]);
+
+ render( );
+
+ // The dot should be visible
+ expect(
+ screen.getByRole("button", { name: /open notification menu/i }),
+ ).toBeInTheDocument();
+ });
+
+ test("given: read notification, should: not show unread indicator or menu", () => {
+ const notification = createLinkNotificationProps({ isRead: true });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path: "/" },
+ ]);
+
+ render( );
+
+ // Neither dot nor menu should be visible
+ expect(
+ screen.queryByRole("button", { name: /open notification menu/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ test("given: user clicks notification, should: navigate to href", () => {
+ const notification = createLinkNotificationProps();
+ const RouterStub = createRoutesStub([
+ { Component: () => , path: "/" },
+ ]);
+
+ render( );
+
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("href", notification.href);
+ expect(link).toHaveTextContent(notification.text);
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/notifications/notification-components.tsx b/apps/react-router/saas-template/app/features/notifications/notification-components.tsx
new file mode 100644
index 0000000..ac9d1df
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/notification-components.tsx
@@ -0,0 +1,150 @@
+import { IconDotsVertical } from "@tabler/icons-react";
+import type { ComponentProps } from "react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link, useFetcher } from "react-router";
+
+import { MARK_ONE_NOTIFICATION_AS_READ_INTENT } from "./notification-constants";
+import type { LinkNotificationData } from "./notifications-schemas";
+import { Button } from "~/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "~/components/ui/dropdown-menu";
+import type { NotificationRecipient } from "~/generated/browser";
+import { cn } from "~/lib/utils";
+import { toFormData } from "~/utils/to-form-data";
+
+/**
+ * Base notification stuff
+ */
+
+type NotificationsDotProps = ComponentProps<"div"> & {
+ blinking: boolean;
+};
+
+export function NotificationsDot({
+ blinking,
+ className,
+ ...props
+}: NotificationsDotProps) {
+ return (
+
+ {blinking && (
+
+ )}
+
+
+ );
+}
+
+type NotificationMenuProps = {
+ recipientId: NotificationRecipient["id"];
+};
+
+export function NotificationMenu({ recipientId }: NotificationMenuProps) {
+ const { t } = useTranslation("notifications", {
+ keyPrefix: "notificationMenu",
+ });
+ const [isOpen, setIsOpen] = useState(false);
+ const notificationMenuFetcher = useFetcher();
+
+ return (
+
+ {
+ event.stopPropagation();
+ event.preventDefault();
+ }}
+ render={
+
+ }
+ >
+
+
+
+
+ {
+ event.stopPropagation();
+ void notificationMenuFetcher.submit(
+ toFormData({
+ intent: MARK_ONE_NOTIFICATION_AS_READ_INTENT,
+ recipientId,
+ }),
+ { method: "post" },
+ );
+ }}
+ >
+ {t("markAsRead")}
+
+
+
+ );
+}
+
+type BaseNotificationProps = {
+ recipientId: NotificationRecipient["id"];
+ isRead: boolean;
+};
+
+/**
+ * Link notification
+ */
+
+export type LinkNotificationProps = BaseNotificationProps &
+ LinkNotificationData;
+
+export function LinkNotification({
+ href,
+ isRead,
+ recipientId,
+ text,
+}: LinkNotificationProps) {
+ return (
+ }
+ size="sm"
+ variant="ghost"
+ >
+ {text}
+
+ {isRead ? (
+
+ {/* Fake offset to prevent layout shift when the notification is read */}
+
+ ) : (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/notifications/notification-constants.ts b/apps/react-router/saas-template/app/features/notifications/notification-constants.ts
new file mode 100644
index 0000000..ae738fc
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/notification-constants.ts
@@ -0,0 +1,7 @@
+/* Notification types */
+export const LINK_NOTIFICATION_TYPE = "linkNotification";
+
+/* Notification intents */
+export const MARK_ALL_NOTIFICATIONS_AS_READ_INTENT = "markAllAsRead";
+export const MARK_ONE_NOTIFICATION_AS_READ_INTENT = "markOneAsRead";
+export const NOTIFICATION_PANEL_OPENED_INTENT = "notificationPanelOpened";
diff --git a/apps/react-router/saas-template/app/features/notifications/notifications-button.test.tsx b/apps/react-router/saas-template/app/features/notifications/notifications-button.test.tsx
new file mode 100644
index 0000000..4fad407
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/notifications-button.test.tsx
@@ -0,0 +1,173 @@
+import { faker } from "@faker-js/faker";
+import { createId } from "@paralleldrive/cuid2";
+import { describe, expect, test } from "vitest";
+
+import type { LinkNotificationProps } from "./notification-components";
+import { LINK_NOTIFICATION_TYPE } from "./notification-constants";
+import type { NotificationsButtonProps } from "./notifications-button";
+import { NotificationsButton } from "./notifications-button";
+import {
+ createRoutesStub,
+ render,
+ screen,
+ userEvent,
+} from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createLinkNotification: Factory = ({
+ href = faker.internet.url(),
+ isRead = false,
+ recipientId = createId(),
+ text = faker.lorem.sentence(),
+} = {}) => ({
+ href,
+ isRead,
+ recipientId,
+ text,
+ type: LINK_NOTIFICATION_TYPE,
+});
+
+const createProps: Factory = ({
+ allNotifications = [],
+ showBadge = false,
+ unreadNotifications = [],
+} = {}) => ({
+ allNotifications,
+ showBadge,
+ unreadNotifications,
+});
+
+describe("NotificationsButton component", () => {
+ test("given: no unread notifications, should: render button with default aria label", () => {
+ const props = createProps();
+ const RouterStub = createRoutesStub([
+ { Component: () => , path: "/" },
+ ]);
+
+ render( );
+
+ const button = screen.getByRole("button", { name: /open notifications/i });
+ expect(button).toBeInTheDocument();
+ });
+
+ test("given: should show badge (= unread notifications), should: render button with unread notifications aria label", () => {
+ const props = createProps({ showBadge: true });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path: "/" },
+ ]);
+
+ render( );
+
+ const button = screen.getByRole("button", {
+ name: /open unread notifications/i,
+ });
+ expect(button).toBeInTheDocument();
+ });
+
+ test("given: button clicked, should: open notifications panel with unread tab selected by default", async () => {
+ const user = userEvent.setup();
+ const props = createProps({
+ allNotifications: [createLinkNotification({ text: "All notification" })],
+ unreadNotifications: [
+ createLinkNotification({ text: "Unread notification" }),
+ ],
+ });
+ const RouterStub = createRoutesStub([
+ {
+ action: () => ({}),
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ const button = screen.getByRole("button", { name: /open notifications/i });
+ await user.click(button);
+
+ // Check that panel is open with tabs
+ expect(screen.getByRole("tablist")).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: /unread/i })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(screen.getByRole("tab", { name: /all/i })).toHaveAttribute(
+ "aria-selected",
+ "false",
+ );
+ });
+
+ test("given: notifications panel open, should: allow switching between tabs", async () => {
+ const user = userEvent.setup();
+ const allNotification = createLinkNotification({
+ text: "All notification",
+ });
+ const unreadNotification = createLinkNotification({
+ text: "Unread notification",
+ });
+ const props = createProps({
+ allNotifications: [allNotification],
+ unreadNotifications: [unreadNotification],
+ });
+ const RouterStub = createRoutesStub([
+ {
+ action: () => ({}),
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ // Open panel
+ await user.click(
+ screen.getByRole("button", { name: /open notifications/i }),
+ );
+
+ // Verify unread tab is selected by default and shows unread notification
+ expect(screen.getByRole("tab", { name: /unread/i })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(screen.getByText(unreadNotification.text)).toBeInTheDocument();
+
+ // Switch to all tab
+ await user.click(screen.getByRole("tab", { name: /all/i }));
+ expect(screen.getByRole("tab", { name: /all/i })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(screen.getByText(allNotification.text)).toBeInTheDocument();
+ });
+
+ test("given: notifications panel open, should: show mark all as read button", async () => {
+ const user = userEvent.setup();
+ const props = createProps({
+ allNotifications: [createLinkNotification({ text: "All notification" })],
+ showBadge: true,
+ unreadNotifications: [
+ createLinkNotification({ text: "Unread notification" }),
+ ],
+ });
+ const RouterStub = createRoutesStub([
+ {
+ action: () => ({}),
+ Component: () => ,
+ path: "/",
+ },
+ ]);
+
+ render( );
+
+ // Open panel
+ await user.click(
+ screen.getByRole("button", { name: /open unread notifications/i }),
+ );
+
+ // Check mark all as read button exists
+ const markAllButton = screen.getByRole("button", {
+ name: /mark all as read/i,
+ });
+ expect(markAllButton).toBeInTheDocument();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/notifications/notifications-button.tsx b/apps/react-router/saas-template/app/features/notifications/notifications-button.tsx
new file mode 100644
index 0000000..3353c2d
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/notifications-button.tsx
@@ -0,0 +1,187 @@
+import { IconBell, IconChecks } from "@tabler/icons-react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useFetcher } from "react-router";
+import { useHydrated } from "remix-utils/use-hydrated";
+
+import { NotificationsDot } from "./notification-components";
+import {
+ MARK_ALL_NOTIFICATIONS_AS_READ_INTENT,
+ NOTIFICATION_PANEL_OPENED_INTENT,
+} from "./notification-constants";
+import type { NotificationsPanelContentProps } from "./notifications-panel-content";
+import { NotificationsPanelContent } from "./notifications-panel-content";
+import { usePendingNotifications } from "./use-pending-notifications";
+import { Button } from "~/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "~/components/ui/popover";
+import { Separator } from "~/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "~/components/ui/tooltip";
+import { toFormData } from "~/utils/to-form-data";
+
+export type NotificationsButtonProps = {
+ allNotifications: NotificationsPanelContentProps["notifications"];
+ showBadge: boolean;
+ unreadNotifications: NotificationsPanelContentProps["notifications"];
+};
+
+export function NotificationsButton({
+ allNotifications,
+ showBadge,
+ unreadNotifications,
+}: NotificationsButtonProps) {
+ const { t } = useTranslation("notifications", {
+ keyPrefix: "notificationsButton",
+ });
+
+ /* Notification panel opened state */
+ const [popoverOpen, setPopoverOpen] = useState(false);
+ const notificationPanelOpenedFetcher = useFetcher();
+ const notificationPanelOpened =
+ notificationPanelOpenedFetcher.formData?.get("intent") ===
+ NOTIFICATION_PANEL_OPENED_INTENT;
+
+ const handlePopoverOpenChange = (open: boolean) => {
+ setPopoverOpen(open);
+
+ if (open) {
+ void notificationPanelOpenedFetcher.submit(
+ toFormData({ intent: NOTIFICATION_PANEL_OPENED_INTENT }),
+ { method: "post" },
+ );
+ }
+ };
+
+ /* Mark all as read */
+ const markAllAsReadFetcher = useFetcher();
+ const isMarkingAllAsRead =
+ markAllAsReadFetcher.formData?.get("intent") ===
+ MARK_ALL_NOTIFICATIONS_AS_READ_INTENT;
+ // Optimistically hide badge when the panel is opened.
+ const optimisticShowBadge = showBadge && !notificationPanelOpened;
+
+ /* Mark one as read */
+ const pendingNotifications = usePendingNotifications();
+ const hydrated = useHydrated();
+
+ return (
+
+
+ }
+ >
+
+ {optimisticShowBadge && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+ {t("markAllAsRead")}
+
+
+
+
+
+ {t("unread")}
+
+ {t("all")}
+
+
+
+
+
+
+
+
+ !pendingNotifications.some(
+ (pending) =>
+ pending.recipientId === notification.recipientId,
+ ),
+ )
+ }
+ />
+
+
+
+ ({
+ ...notification,
+ isRead: true,
+ }))
+ : // Optimistically mark specific notifications as read when marking one as read.
+ allNotifications.map((notification) => ({
+ ...notification,
+ isRead: pendingNotifications.some(
+ (pending) =>
+ pending.recipientId === notification.recipientId,
+ ),
+ }))
+ }
+ />
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/notifications/notifications-factories.server.ts b/apps/react-router/saas-template/app/features/notifications/notifications-factories.server.ts
new file mode 100644
index 0000000..d5531ff
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/notifications-factories.server.ts
@@ -0,0 +1,133 @@
+import { faker } from "@faker-js/faker";
+import { createId } from "@paralleldrive/cuid2";
+
+import { LINK_NOTIFICATION_TYPE } from "./notification-constants";
+import type { NotificationQueryResult } from "./notifications-model.server";
+import type { NotificationType } from "./notifications-panel-content";
+import type { LinkNotificationData } from "./notifications-schemas";
+import type {
+ Notification,
+ NotificationPanel,
+ NotificationRecipient,
+ Prisma,
+} from "~/generated/client";
+import type { Factory } from "~/utils/types";
+
+/**
+ * Creates link notification data with populated values.
+ *
+ * @param params - Parameters to create link notification data with.
+ * @param params.text - The text content of the notification. Defaults to a random sentence.
+ * @param params.href - The URL the notification links to. Defaults to a random URL.
+ * @returns A populated link notification data object with given params.
+ */
+export const createPopulatedLinkNotificationData: Factory<
+ LinkNotificationData
+> = ({
+ text = faker.lorem.sentences(2),
+ href = faker.internet.url(),
+} = {}) => ({
+ href,
+ text,
+ type: LINK_NOTIFICATION_TYPE,
+});
+
+/**
+ * Creates a notification type with populated values.
+ *
+ * @param params - Parameters to create notification type with.
+ * @param params.id - The ID of the notification. Defaults to a random CUID.
+ * @param params.isRead - Whether the notification has been read. Defaults to false.
+ * @param params.type - The type of notification. Defaults to a random supported type.
+ * @param params.rest - Additional parameters passed to the specific notification type creator.
+ * @returns A populated notification type with given params.
+ */
+export const createPopulatedNotificationType: Factory = ({
+ recipientId = createId(),
+ isRead = false,
+ type = faker.helpers.arrayElement([LINK_NOTIFICATION_TYPE]),
+ ...rest
+} = {}) => {
+ switch (type) {
+ case LINK_NOTIFICATION_TYPE: {
+ return {
+ isRead,
+ recipientId,
+ ...createPopulatedLinkNotificationData(rest),
+ };
+ }
+ default: {
+ return faker.helpers.arrayElement([
+ { isRead, recipientId, ...createPopulatedLinkNotificationData(rest) },
+ ]);
+ }
+ }
+};
+
+/**
+ * Creates a notification with populated values.
+ *
+ * @param notificationParams - Notification params to create notification with.
+ * @returns A populated notification with given params.
+ */
+export const createPopulatedNotification: Factory<
+ Omit & {
+ content: Prisma.InputJsonValue;
+ }
+> = ({
+ id = createId(),
+ updatedAt = faker.date.recent({ days: 1 }),
+ createdAt = faker.date.recent({ days: 1, refDate: updatedAt }),
+ organizationId = createId(),
+ content = createPopulatedNotificationType(),
+} = {}) => ({ content, createdAt, id, organizationId, updatedAt });
+
+/**
+ * Creates a notification recipient with populated values.
+ *
+ * @param recipientParams - NotificationRecipient params to create recipient with.
+ * @returns A populated notification recipient with given params.
+ */
+export const createPopulatedNotificationRecipient: Factory<
+ NotificationRecipient
+> = ({
+ id = createId(),
+ updatedAt = faker.date.recent({ days: 1 }),
+ createdAt = faker.date.recent({ days: 1, refDate: updatedAt }),
+ notificationId = createId(),
+ userId = createId(),
+ readAt = null,
+} = {}) => ({ createdAt, id, notificationId, readAt, updatedAt, userId });
+
+/**
+ * Creates a notification panel with populated values.
+ *
+ * @param panelParams - NotificationPanel params to create panel with.
+ * @returns A populated notification panel with given params.
+ */
+export const createPopulatedNotificationPanel: Factory = ({
+ id = createId(),
+ updatedAt = faker.date.recent({ days: 1 }),
+ createdAt = faker.date.recent({ days: 1, refDate: updatedAt }),
+ userId = createId(),
+ organizationId = createId(),
+ lastOpenedAt = faker.date.recent({ days: 1, refDate: updatedAt }),
+} = {}) => ({ createdAt, id, lastOpenedAt, organizationId, updatedAt, userId });
+
+/**
+ * Creates a notification query result with populated values. This represents the flattened
+ * structure returned by database queries joining notifications with their recipients.
+ *
+ * @param queryResultParams - NotificationQueryResult params to create the result with.
+ * @returns A populated notification query result with given params.
+ */
+export const createPopulatedNotificationQueryResult: Factory<
+ NotificationQueryResult
+> = ({
+ recipientId = createPopulatedNotificationRecipient().id,
+ readAt = null,
+ notificationId = createPopulatedNotification().id,
+ content = createPopulatedNotification()
+ .content as unknown as Notification["content"],
+ createdAt = createPopulatedNotification().createdAt,
+} = {}) => ({ content, createdAt, notificationId, readAt, recipientId });
diff --git a/apps/react-router/saas-template/app/features/notifications/notifications-helpers.server.test.ts b/apps/react-router/saas-template/app/features/notifications/notifications-helpers.server.test.ts
new file mode 100644
index 0000000..2f5ba48
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/notifications-helpers.server.test.ts
@@ -0,0 +1,368 @@
+import { describe, expect, test } from "vitest";
+
+import { LINK_NOTIFICATION_TYPE } from "./notification-constants";
+import { createPopulatedNotificationQueryResult } from "./notifications-factories.server";
+import { mapInitialNotificationsDataToNotificationButtonProps } from "./notifications-helpers.server";
+import type { NotificationQueryResult } from "./notifications-model.server";
+import type { LinkNotificationData } from "./notifications-schemas";
+
+function sortByCreatedAt(
+ notifications: NotificationQueryResult[],
+): NotificationQueryResult[] {
+ return notifications.toSorted(
+ (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
+ );
+}
+
+describe("mapInitialNotificationsDataToNotificationButtonProps()", () => {
+ test("given: no notifications and no panel data, should: return empty notifications and no badge", () => {
+ const initialData = {
+ allNotifications: [],
+ lastOpenedAt: null,
+ unreadNotifications: [],
+ };
+
+ const actual =
+ mapInitialNotificationsDataToNotificationButtonProps(initialData);
+ const expected = {
+ notificationButtonProps: {
+ allNotifications: [],
+ showBadge: false,
+ unreadNotifications: [],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: notifications exist and panel was opened after latest notification, should: return notifications with no badge", () => {
+ // Fixed notification content for stable assertions
+ const content1: LinkNotificationData = {
+ href: "/first",
+ text: "First",
+ type: LINK_NOTIFICATION_TYPE,
+ };
+ const content2: LinkNotificationData = {
+ href: "/second",
+ text: "Second",
+ type: LINK_NOTIFICATION_TYPE,
+ };
+
+ // Two notifications, one newer than the other
+ const n1 = createPopulatedNotificationQueryResult({
+ content: content1,
+ createdAt: new Date("2025-04-20T10:00:00Z"),
+ notificationId: "n1",
+ readAt: null,
+ recipientId: "r1",
+ });
+ const n2 = createPopulatedNotificationQueryResult({
+ content: content2,
+ createdAt: new Date("2025-04-19T10:00:00Z"),
+ notificationId: "n2",
+ readAt: null,
+ recipientId: "r2",
+ });
+ const allNotifications = sortByCreatedAt([n1, n2]);
+
+ const initialData = {
+ allNotifications,
+ lastOpenedAt: new Date("2025-04-21T00:00:00Z"), // after the latest notification
+ unreadNotifications: allNotifications,
+ };
+
+ const actual =
+ mapInitialNotificationsDataToNotificationButtonProps(initialData);
+ const expected = {
+ notificationButtonProps: {
+ allNotifications: [
+ { isRead: false, recipientId: "r1", ...content1 },
+ { isRead: false, recipientId: "r2", ...content2 },
+ ],
+ showBadge: false,
+ unreadNotifications: [
+ { isRead: false, recipientId: "r1", ...content1 },
+ { isRead: false, recipientId: "r2", ...content2 },
+ ],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: notifications exist and panel was opened before latest notification, should: return notifications with badge", () => {
+ // Fixed notification content for stable assertions
+ const content1: LinkNotificationData = {
+ href: "/first",
+ text: "First",
+ type: LINK_NOTIFICATION_TYPE,
+ };
+ const content2: LinkNotificationData = {
+ href: "/second",
+ text: "Second",
+ type: LINK_NOTIFICATION_TYPE,
+ };
+
+ // Two notifications, one newer than the other
+ const n1 = createPopulatedNotificationQueryResult({
+ content: content1,
+ createdAt: new Date("2025-04-20T10:00:00Z"),
+ notificationId: "n1",
+ readAt: null,
+ recipientId: "r1",
+ });
+ const n2 = createPopulatedNotificationQueryResult({
+ content: content2,
+ createdAt: new Date("2025-04-19T10:00:00Z"),
+ notificationId: "n2",
+ readAt: null,
+ recipientId: "r2",
+ });
+ const allNotifications = sortByCreatedAt([n1, n2]);
+
+ const initialData = {
+ allNotifications,
+ lastOpenedAt: new Date("2025-04-18T00:00:00Z"), // before the latest notification
+ unreadNotifications: allNotifications,
+ };
+
+ const actual =
+ mapInitialNotificationsDataToNotificationButtonProps(initialData);
+ const expected = {
+ notificationButtonProps: {
+ allNotifications: [
+ { isRead: false, recipientId: "r1", ...content1 },
+ { isRead: false, recipientId: "r2", ...content2 },
+ ],
+ showBadge: true,
+ unreadNotifications: [
+ { isRead: false, recipientId: "r1", ...content1 },
+ { isRead: false, recipientId: "r2", ...content2 },
+ ],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: notifications with one read and one unread, should: map isRead flags correctly", () => {
+ const content1: LinkNotificationData = {
+ href: "/read",
+ text: "Read",
+ type: LINK_NOTIFICATION_TYPE,
+ };
+ const content2: LinkNotificationData = {
+ href: "/unread",
+ text: "Unread",
+ type: LINK_NOTIFICATION_TYPE,
+ };
+
+ const n1 = createPopulatedNotificationQueryResult({
+ content: content1,
+ createdAt: new Date("2025-04-20T10:00:00Z"),
+ notificationId: "n1",
+ readAt: new Date("2025-04-20T12:00:00Z"),
+ recipientId: "r1",
+ });
+ const n2 = createPopulatedNotificationQueryResult({
+ content: content2,
+ createdAt: new Date("2025-04-19T10:00:00Z"),
+ notificationId: "n2",
+ readAt: null,
+ recipientId: "r2",
+ });
+ const allNotifications = sortByCreatedAt([n1, n2]);
+
+ const initialData = {
+ allNotifications,
+ lastOpenedAt: null,
+ unreadNotifications: [n2],
+ };
+
+ const actual =
+ mapInitialNotificationsDataToNotificationButtonProps(initialData);
+ const expected = {
+ notificationButtonProps: {
+ allNotifications: [
+ { isRead: true, recipientId: "r1", ...content1 },
+ { isRead: false, recipientId: "r2", ...content2 },
+ ],
+ showBadge: true,
+ unreadNotifications: [
+ { isRead: false, recipientId: "r2", ...content2 },
+ ],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: panel was opened at exactly the same time as latest notification, should: return notifications with no badge", () => {
+ const content = {
+ href: "/exact",
+ text: "Exact",
+ type: LINK_NOTIFICATION_TYPE,
+ };
+
+ const createdAt = new Date("2025-04-20T10:00:00Z");
+
+ const n = createPopulatedNotificationQueryResult({
+ content,
+ createdAt,
+ notificationId: "n1",
+ readAt: null,
+ recipientId: "r1",
+ });
+
+ const initialData = {
+ allNotifications: [n],
+ lastOpenedAt: createdAt,
+ unreadNotifications: [n],
+ };
+
+ const actual =
+ mapInitialNotificationsDataToNotificationButtonProps(initialData);
+
+ const expected = {
+ notificationButtonProps: {
+ allNotifications: [{ isRead: false, recipientId: "r1", ...content }],
+ showBadge: false,
+ unreadNotifications: [{ isRead: false, recipientId: "r1", ...content }],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: no unread notifications but a newer notification exists, should: return notifications with badge", () => {
+ const content = {
+ href: "/read",
+ text: "Read but new",
+ type: LINK_NOTIFICATION_TYPE,
+ };
+
+ const createdAt = new Date("2025-04-20T10:00:00Z");
+
+ const n = createPopulatedNotificationQueryResult({
+ content,
+ createdAt,
+ notificationId: "n1",
+ readAt: new Date("2025-04-20T11:00:00Z"),
+ recipientId: "r1",
+ });
+
+ const initialData = {
+ allNotifications: [n],
+ lastOpenedAt: new Date("2025-04-19T10:00:00Z"),
+ unreadNotifications: [],
+ };
+
+ const actual =
+ mapInitialNotificationsDataToNotificationButtonProps(initialData);
+
+ const expected = {
+ notificationButtonProps: {
+ allNotifications: [{ isRead: true, recipientId: "r1", ...content }],
+ showBadge: true,
+ unreadNotifications: [],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: future dated notification, should: return notifications with badge", () => {
+ const content = {
+ href: "/future",
+ text: "Future",
+ type: LINK_NOTIFICATION_TYPE,
+ };
+
+ const futureDate = new Date("3025-01-01T00:00:00Z");
+
+ const n = createPopulatedNotificationQueryResult({
+ content,
+ createdAt: futureDate,
+ notificationId: "n1",
+ readAt: null,
+ recipientId: "r1",
+ });
+
+ const initialData = {
+ allNotifications: [n],
+ lastOpenedAt: new Date("2025-04-27T00:00:00Z"),
+ unreadNotifications: [n],
+ };
+
+ const actual =
+ mapInitialNotificationsDataToNotificationButtonProps(initialData);
+
+ const expected = {
+ notificationButtonProps: {
+ allNotifications: [{ isRead: false, recipientId: "r1", ...content }],
+ showBadge: true,
+ unreadNotifications: [{ isRead: false, recipientId: "r1", ...content }],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: no notifications but lastOpenedAt exists, should: return empty notifications with no badge", () => {
+ const initialData = {
+ allNotifications: [],
+ lastOpenedAt: new Date("2025-04-27T00:00:00Z"),
+ unreadNotifications: [],
+ };
+
+ const actual =
+ mapInitialNotificationsDataToNotificationButtonProps(initialData);
+
+ const expected = {
+ notificationButtonProps: {
+ allNotifications: [],
+ showBadge: false,
+ unreadNotifications: [],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: lastOpenedAt is null and notifications exist, should: return notifications with badge", () => {
+ const content = {
+ href: "/new",
+ text: "New notification",
+ type: LINK_NOTIFICATION_TYPE,
+ };
+
+ const createdAt = new Date("2025-04-20T10:00:00Z");
+
+ const n = createPopulatedNotificationQueryResult({
+ content,
+ createdAt,
+ notificationId: "n1",
+ readAt: null,
+ recipientId: "r1",
+ });
+
+ const initialData = {
+ allNotifications: [n],
+ lastOpenedAt: null,
+ unreadNotifications: [n],
+ };
+
+ const actual =
+ mapInitialNotificationsDataToNotificationButtonProps(initialData);
+
+ const expected = {
+ notificationButtonProps: {
+ allNotifications: [{ isRead: false, recipientId: "r1", ...content }],
+ showBadge: true,
+ unreadNotifications: [{ isRead: false, recipientId: "r1", ...content }],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/notifications/notifications-helpers.server.ts b/apps/react-router/saas-template/app/features/notifications/notifications-helpers.server.ts
new file mode 100644
index 0000000..e022c3b
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/notifications-helpers.server.ts
@@ -0,0 +1,47 @@
+import { z } from "zod";
+
+import type { NotificationsButtonProps } from "./notifications-button";
+import type {
+ InitialNotificationsData,
+ NotificationQueryResult,
+} from "./notifications-model.server";
+import type { NotificationType } from "./notifications-panel-content";
+import { linkNotificationDataSchema } from "./notifications-schemas";
+
+const allNotificationsSchema = z.discriminatedUnion("type", [
+ linkNotificationDataSchema,
+]);
+
+function parseNotification(
+ notification: NotificationQueryResult,
+): NotificationType {
+ const parsed = allNotificationsSchema.parse(notification.content);
+ return {
+ isRead: notification.readAt !== null,
+ recipientId: notification.recipientId,
+ ...parsed,
+ };
+}
+
+export function mapInitialNotificationsDataToNotificationButtonProps({
+ allNotifications,
+ lastOpenedAt,
+ unreadNotifications,
+}: Omit): {
+ notificationButtonProps: NotificationsButtonProps;
+} {
+ const latestNotificationDate = allNotifications?.[0]?.createdAt;
+ const showBadge = Boolean(
+ latestNotificationDate &&
+ (!lastOpenedAt ||
+ new Date(latestNotificationDate) > new Date(lastOpenedAt)),
+ );
+
+ return {
+ notificationButtonProps: {
+ allNotifications: allNotifications.map(parseNotification),
+ showBadge,
+ unreadNotifications: unreadNotifications.map(parseNotification),
+ },
+ };
+}
diff --git a/apps/react-router/saas-template/app/features/notifications/notifications-model.server.ts b/apps/react-router/saas-template/app/features/notifications/notifications-model.server.ts
new file mode 100644
index 0000000..bc7f9bc
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/notifications-model.server.ts
@@ -0,0 +1,632 @@
+import type {
+ Notification,
+ NotificationRecipient,
+ Organization,
+ UserAccount,
+} from "~/generated/client";
+import { Prisma } from "~/generated/client";
+import { prisma } from "~/utils/database.server";
+
+// Define the structure of the notification data we expect from the raw query
+export type NotificationQueryResult = {
+ recipientId: NotificationRecipient["id"];
+ readAt: NotificationRecipient["readAt"];
+ notificationId: Notification["id"];
+ content: Notification["content"];
+ createdAt: Notification["createdAt"];
+};
+
+/* CREATE */
+
+export async function saveNotificationWithRecipientForUserAndOrganizationInDatabaseById({
+ notification,
+ recipient,
+}: {
+ notification: Prisma.NotificationUncheckedCreateInput;
+ recipient: Omit<
+ Prisma.NotificationRecipientUncheckedCreateInput,
+ "notificationId"
+ >;
+}) {
+ return prisma.notification.create({
+ data: { ...notification, recipients: { create: recipient } },
+ include: { recipients: true },
+ });
+}
+
+/**
+ * Creates a new notification with the specified content for a single user
+ * within a given organization. Also creates the associated NotificationRecipient record.
+ * This operation is atomic.
+ *
+ * @param userId - The ID of the user who will receive the notification.
+ * @param organizationId - The ID of the organization context for the notification.
+ * @param content - The JSON content of the notification.
+ * @returns A promise resolving to the NotificationQueryResult for the newly created
+ * notification and its recipient, or null if creation failed unexpectedly.
+ */
+export async function createNotificationForUserInDatabaseById({
+ userId,
+ organizationId,
+ content,
+}: {
+ userId: UserAccount["id"];
+ organizationId: Organization["id"];
+ content: Prisma.InputJsonValue; // Use Prisma's specific JSON type for input
+}): Promise {
+ const newNotification = await prisma.notification.create({
+ data: {
+ content: content,
+ organizationId: organizationId,
+ // Create the recipient record simultaneously
+ recipients: { create: { userId: userId } },
+ },
+ // Include the newly created recipient details
+ include: {
+ recipients: {
+ select: { id: true, readAt: true },
+ where: { userId: userId }, // Ensure we only get the one we just created
+ },
+ },
+ });
+
+ // Should always have one recipient from the create operation
+ if (!newNotification.recipients?.length) {
+ // This case is unlikely but indicates something went wrong
+ // console.error(
+ // `Failed to create recipient for notification ${newNotification.id}`,
+ // );
+ // Optionally delete the orphaned notification here if needed
+ await prisma.notification.delete({ where: { id: newNotification.id } });
+ return null;
+ }
+
+ // biome-ignore lint/style/noNonNullAssertion: The check above ensures that there is a recipient
+ const recipient = newNotification.recipients[0]!;
+
+ // Map to the desired NotificationQueryResult structure
+ return {
+ content: newNotification.content,
+ createdAt: newNotification.createdAt,
+ notificationId: newNotification.id,
+ readAt: recipient.readAt, // Will be null initially
+ recipientId: recipient.id,
+ };
+}
+
+/**
+ * Creates a new notification with the specified content for multiple users
+ * within a given organization. Also creates the associated NotificationRecipient records.
+ * This operation is atomic.
+ *
+ * @param userIds - An array of user IDs who will receive the notification.
+ * @param organizationId - The ID of the organization context for the notification.
+ * @param content - The JSON content of the notification.
+ * @returns A promise resolving to an object containing the created Notification
+ * and the count of recipient records created, or null if creation failed.
+ */
+export async function createNotificationForUsersInDatabaseById({
+ userIds,
+ organizationId,
+ content,
+}: {
+ userIds: UserAccount["id"][];
+ organizationId: Organization["id"];
+ content: Prisma.InputJsonValue; // Use Prisma's specific JSON type for input
+}): Promise<{ notification: Notification | null; recipientCount: number }> {
+ // Avoid unnecessary database call if no users are provided
+ if (userIds.length === 0) {
+ // console.warn('Attempted to create notification with zero recipients.');
+ return {
+ notification: null, // Indicate no notification was created
+ recipientCount: 0,
+ };
+ }
+
+ // Map user IDs to the structure required by createMany
+ const recipientData = userIds.map((id) => ({ userId: id }));
+
+ const newNotification = await prisma.notification.create({
+ data: {
+ content: content,
+ organizationId: organizationId,
+ // Create multiple recipient records simultaneously
+ recipients: {
+ createMany: {
+ data: recipientData,
+ // skipDuplicates: true, // Optional: useful if a userId might appear twice, though ideally the input array is unique
+ },
+ },
+ },
+ });
+
+ // createMany for nested writes doesn't return the created records directly,
+ // but it does guarantee atomicity. We return the count based on the input array length.
+ // If skipDuplicates were true and duplicates existed, this count might be higher
+ // than the actual records created, but it reflects the intended number.
+ return {
+ notification: newNotification,
+ recipientCount: userIds.length, // Reflects the number of users intended
+ };
+}
+
+/* READ */
+
+/**
+ * Retrieves a notification recipient record for a specific user and organization.
+ *
+ * This function ensures that the recipient record belongs to the specified user
+ * and that the associated notification belongs to the specified organization.
+ *
+ * @param userId - The ID of the user who received the notification
+ * @param organizationId - The ID of the organization the notification belongs to
+ * @param recipientId - The ID of the notification recipient record to retrieve
+ * @returns A promise resolving to the notification recipient record if found and
+ * matches the user/org criteria, or null if not found
+ */
+export async function retrieveNotificationRecipientForUserAndOrganizationFromDatabaseById({
+ userId,
+ organizationId,
+ recipientId,
+}: {
+ userId: UserAccount["id"];
+ organizationId: Organization["id"];
+ recipientId: NotificationRecipient["id"];
+}) {
+ return await prisma.notificationRecipient.findUnique({
+ where: { id: recipientId, notification: { organizationId }, userId },
+ });
+}
+
+/**
+ * Retrieves notification recipient records for a specific user and organization.
+ *
+ * This function ensures that the recipient records belong to the specified user
+ * and that the associated notifications belong to the specified organization.
+ *
+ * @param userId - The ID of the user who received the notifications
+ * @param organizationId - The ID of the organization the notifications belong to
+ * @returns A promise resolving to an array of notification recipient records that
+ * match the user/org criteria
+ */
+export async function retrieveNotificationRecipientsForUserAndOrganizationFromDatabase({
+ userId,
+ organizationId,
+}: {
+ userId: UserAccount["id"];
+ organizationId: Organization["id"];
+}) {
+ return await prisma.notificationRecipient.findMany({
+ where: { notification: { organizationId }, userId },
+ });
+}
+
+/**
+ * Retrieves a notification panel record for a specific user and organization.
+ *
+ * This function ensures that the panel record belongs to the specified user
+ * and that the associated organization matches the specified organization.
+ *
+ * @param userId - The ID of the user who owns the notification panel
+ * @param organizationId - The ID of the organization the notification panel belongs to
+ * @returns A promise resolving to the notification panel record if found and
+ * matches the user/org criteria, or null if not found
+ */
+export async function retrieveNotificationPanelForUserAndOrganizationFromDatabaseById({
+ userId,
+ organizationId,
+}: {
+ userId: UserAccount["id"];
+ organizationId: Organization["id"];
+}) {
+ return await prisma.notificationPanel.findUnique({
+ where: { userId_organizationId: { organizationId, userId } },
+ });
+}
+
+// Define the overall structure returned by the raw query
+export type InitialNotificationsData = {
+ allNotifications: NotificationQueryResult[];
+ hasMoreAll: boolean;
+ hasMoreUnread: boolean;
+ lastOpenedAt: Date | null;
+ unreadNotifications: NotificationQueryResult[];
+};
+
+/**
+ * Retrieves initial notifications data for a user within an organization.
+ *
+ * This function performs a complex SQL query to fetch:
+ * - The last time the user opened the notifications panel
+ * - A limited set of unread notifications
+ * - A limited set of all notifications (both read and unread)
+ *
+ * The results are ordered by creation date (newest first) with a secondary sort
+ * on recipient ID for stable ordering.
+ *
+ * @param userId - The ID of the user to fetch notifications for
+ * @param organizationId - The ID of the organization context
+ * @param limit - Maximum number of notifications to return per category
+ *
+ * @returns A promise that resolves to:
+ * - The notifications data object if successful
+ * - A default object with empty arrays if no data is found
+ */
+export async function retrieveInitialNotificationsDataForUserAndOrganizationFromDatabaseById({
+ userId,
+ organizationId,
+ allNotificationsLimit = 50, // Default limit per category
+ unreadNotificationsLimit = 20, // Default limit per category
+}: {
+ userId: UserAccount["id"];
+ organizationId: Organization["id"];
+ allNotificationsLimit?: number;
+ unreadNotificationsLimit?: number;
+}): Promise {
+ // Ensure limit is a positive integer
+ const safeAllNotificationsLimit = Math.max(
+ 1,
+ Math.floor(allNotificationsLimit),
+ );
+ const safeUnreadNotificationsLimit = Math.max(
+ 1,
+ Math.floor(unreadNotificationsLimit),
+ );
+
+ // Prisma's $queryRaw requires Prisma.sql template tag for parameters
+ const results = await prisma.$queryRaw<[InitialNotificationsData]>`
+ WITH PanelInfo AS (
+ -- Select the lastOpenedAt for the specific user and organization
+ SELECT "lastOpenedAt"
+ FROM "NotificationPanel"
+ WHERE "userId" = ${userId}
+ AND "organizationId" = ${organizationId}
+ LIMIT 1 -- Should be unique anyway due to @@unique constraint
+ ),
+ UnreadNotifications AS (
+ -- Select the latest unread notifications for the user in the org
+ SELECT
+ nr.id as "recipientId",
+ nr."readAt",
+ n.id as "notificationId",
+ n.content,
+ n."createdAt"
+ FROM "NotificationRecipient" nr
+ JOIN "Notification" n ON nr."notificationId" = n.id
+ WHERE nr."userId" = ${userId}
+ AND n."organizationId" = ${organizationId}
+ AND nr."readAt" IS NULL
+ ORDER BY n."createdAt" DESC, nr.id DESC -- Primary sort by notification creation, secondary by recipient ID for stable order
+ LIMIT ${safeUnreadNotificationsLimit + 1}
+ ),
+ AllNotifications AS (
+ -- Select the latest notifications (read or unread) for the user in the org
+ SELECT
+ nr.id as "recipientId",
+ nr."readAt",
+ n.id as "notificationId",
+ n.content,
+ n."createdAt"
+ FROM "NotificationRecipient" nr
+ JOIN "Notification" n ON nr."notificationId" = n.id
+ WHERE nr."userId" = ${userId}
+ AND n."organizationId" = ${organizationId}
+ ORDER BY n."createdAt" DESC, nr.id DESC -- Primary sort by notification creation, secondary by recipient ID for stable order
+ LIMIT ${safeAllNotificationsLimit + 1}
+ )
+ -- Combine the results into a single JSON object
+ SELECT
+ (SELECT "lastOpenedAt" FROM PanelInfo) as "lastOpenedAt",
+ -- Aggregate results into JSON arrays, COALESCE ensures we get [] instead of NULL if no rows match
+ COALESCE((SELECT json_agg(u.* ORDER BY u."createdAt" DESC, u."recipientId" DESC) FROM UnreadNotifications u), '[]'::json) as "unreadNotifications",
+ COALESCE((SELECT json_agg(a.* ORDER BY a."createdAt" DESC, a."recipientId" DESC) FROM AllNotifications a), '[]'::json) as "allNotifications",
+ (SELECT COUNT(*) > ${safeUnreadNotificationsLimit} FROM UnreadNotifications) AS "hasMoreUnread",
+ (SELECT COUNT(*) > ${safeAllNotificationsLimit} FROM AllNotifications) AS "hasMore";
+ `;
+
+ // $queryRaw returns an array, even if only one row is expected.
+ if (results && results.length > 0) {
+ // We need to explicitly cast the JSON results back if needed,
+ // but Prisma attempts to map types based on the query result structure.
+ // The defined `InitialNotificationsData` type helps TypeScript understand the shape.
+ // Ensure the `NotificationQueryResult` matches the columns selected in the CTEs.
+ return results[0];
+ }
+
+ // Should not happen if the organization exists, but good practice to handle.
+ return {
+ allNotifications: [],
+ hasMoreAll: false,
+ hasMoreUnread: false,
+ lastOpenedAt: null,
+ unreadNotifications: [],
+ };
+}
+
+// Shared type for paginated results
+type PaginatedNotificationsResult = {
+ notifications: NotificationQueryResult[];
+ hasMore: boolean;
+};
+
+/**
+ * Retrieves the next page of all notifications (read and unread) for a user
+ * within an organization, using cursor-based pagination based on the
+ * NotificationRecipient ID.
+ *
+ * @param userId - The ID of the user to fetch notifications for.
+ * @param organizationId - The ID of the organization context.
+ * @param limit - Maximum number of notifications to return in this batch.
+ * @param cursor - The ID of the last NotificationRecipient from the previous page.
+ * Used to fetch items *after* this cursor.
+ * @returns A promise resolving to an object containing:
+ * - `notifications`: The next batch of NotificationQueryResult items.
+ * - `hasMore`: A boolean indicating if more notifications exist beyond this batch.
+ */
+export async function retrieveMoreAllNotificationsForUserAndOrganizationFromDatabaseById({
+ userId,
+ organizationId,
+ limit = 10,
+ cursor,
+}: {
+ userId: UserAccount["id"];
+ organizationId: Organization["id"];
+ limit?: number;
+ cursor: NotificationRecipient["id"]; // Cursor is the ID of the last item fetched
+}): Promise {
+ const safeLimit = Math.max(1, Math.floor(limit));
+
+ const results = await prisma.notificationRecipient.findMany({
+ cursor: { id: cursor },
+ // Order must be consistent with the initial fetch and across pagination calls
+ orderBy: [{ notification: { createdAt: "desc" } }, { id: "desc" }],
+ select: {
+ id: true, // = recipientId
+ notification: {
+ select: {
+ content: true,
+ createdAt: true,
+ id: true, // = notificationId
+ // No need to select organizationId here unless specifically needed in the result
+ },
+ },
+ readAt: true,
+ },
+ skip: 1,
+ take: safeLimit + 1,
+ // Filter through the related Notification
+ where: { notification: { organizationId }, userId },
+ });
+
+ const hasMore = results.length > safeLimit;
+ const notificationsToReturn = results.slice(0, safeLimit);
+
+ const notifications: NotificationQueryResult[] = notificationsToReturn.map(
+ (recipients) => ({
+ content: recipients.notification.content,
+ createdAt: recipients.notification.createdAt,
+ notificationId: recipients.notification.id,
+ readAt: recipients.readAt,
+ recipientId: recipients.id,
+ }),
+ );
+
+ return { hasMore, notifications };
+}
+
+/**
+ * Retrieves the next page of *unread* notifications for a user within an
+ * organization, using cursor-based pagination based on the NotificationRecipient ID.
+ *
+ * @param userId - The ID of the user to fetch notifications for.
+ * @param organizationId - The ID of the organization context.
+ * @param limit - Maximum number of notifications to return in this batch.
+ * @param cursor - The ID of the last *unread* NotificationRecipient from the
+ * previous page. Used to fetch items *after* this cursor.
+ * @returns A promise resolving to an object containing:
+ * - `notifications`: The next batch of unread NotificationQueryResult items.
+ * - `hasMore`: A boolean indicating if more unread notifications exist beyond this batch.
+ */
+export async function retrieveMoreUnreadNotificationsForUserAndOrganizationFromDatabaseById({
+ userId,
+ organizationId,
+ limit = 10,
+ cursor,
+}: {
+ userId: UserAccount["id"];
+ organizationId: Organization["id"];
+ limit?: number;
+ cursor: NotificationRecipient["id"]; // Cursor is the ID of the last item fetched
+}): Promise {
+ const safeLimit = Math.max(1, Math.floor(limit));
+
+ const results = await prisma.notificationRecipient.findMany({
+ cursor: {
+ id: cursor,
+ },
+ // Order must be consistent with the initial fetch and across pagination calls
+ orderBy: [{ notification: { createdAt: "desc" } }, { id: "desc" }],
+ select: {
+ id: true, // = recipientId
+ notification: {
+ select: {
+ content: true,
+ createdAt: true,
+ id: true, // = notificationId
+ // No need to select organizationId here unless specifically needed in the result
+ },
+ },
+ readAt: true, // Will be null, but select for consistent structure
+ },
+ skip: 1,
+ take: safeLimit + 1,
+ where: {
+ // Corrected: Filter through the related Notification
+ notification: { organizationId },
+ readAt: null, // Filter for unread
+ userId,
+ },
+ });
+
+ const hasMore = results.length > safeLimit;
+ const notificationsToReturn = results.slice(0, safeLimit);
+
+ const notifications: NotificationQueryResult[] = notificationsToReturn.map(
+ (recipient) => ({
+ content: recipient.notification.content,
+ createdAt: recipient.notification.createdAt,
+ notificationId: recipient.notification.id,
+ readAt: recipient.readAt, // Should be null here
+ recipientId: recipient.id,
+ }),
+ );
+
+ return { hasMore, notifications };
+}
+
+/* UPDATE */
+
+/**
+ * Updates the lastOpenedAt timestamp of a notification panel for a specific user
+ * within an organization to the current time.
+ *
+ * @param userId - The ID of the user whose panel should be updated.
+ * @param organizationId - The ID of the organization context.
+ * @returns A promise resolving to the updated NotificationPanel if successful,
+ * or null if the panel wasn't found.
+ */
+export async function updateNotificationPanelLastOpenedAtForUserAndOrganizationInDatabaseById({
+ userId,
+ organizationId,
+}: {
+ userId: UserAccount["id"];
+ organizationId: Organization["id"];
+}) {
+ try {
+ return await prisma.notificationPanel.update({
+ data: { lastOpenedAt: new Date() },
+ where: { userId_organizationId: { organizationId, userId } },
+ });
+ } catch (error) {
+ if (
+ error instanceof Prisma.PrismaClientKnownRequestError &&
+ error.code === "P2025"
+ ) {
+ // This shouldn't happen because the action should check that the user
+ // is a member and if they're a member, they also have a notification
+ // panel for the organization.
+ return null;
+ }
+
+ throw error;
+ }
+}
+
+/**
+ * Marks a specific notification recipient record as read for a given user
+ * within an organization by setting its `readAt` timestamp.
+ *
+ * Ensures that the user is updating their own notification recipient record
+ * and that the record belongs to the specified organization.
+ *
+ * @param userId - The ID of the user performing the action.
+ * @param organizationId - The ID of the organization context.
+ * @param recipientId - The ID of the NotificationRecipient record to mark as read.
+ * @returns A promise resolving to the updated NotificationQueryResult if successful,
+ * or null if the recipient wasn't found or didn't belong to the user/org.
+ */
+export async function markNotificationAsReadForUserAndOrganizationInDatabaseById({
+ userId,
+ organizationId,
+ recipientId,
+}: {
+ userId: UserAccount["id"];
+ organizationId: Organization["id"];
+ recipientId: NotificationRecipient["id"];
+}): Promise {
+ try {
+ const updatedRecipient = await prisma.notificationRecipient.update({
+ data: {
+ // Set readAt to the current time
+ readAt: new Date(),
+ },
+ // Select the fields needed to return NotificationQueryResult
+ select: {
+ id: true,
+ notification: { select: { content: true, createdAt: true, id: true } },
+ readAt: true,
+ },
+ where: {
+ // Ensure the recipient exists and belongs to the correct user
+ id: recipientId,
+ // Ensure the related notification belongs to the correct organization
+ notification: { organizationId: organizationId },
+ userId: userId,
+ },
+ });
+
+ // Map the Prisma result to the desired NotificationQueryResult structure
+ return {
+ content: updatedRecipient.notification.content,
+ createdAt: updatedRecipient.notification.createdAt,
+ notificationId: updatedRecipient.notification.id,
+ readAt: updatedRecipient.readAt,
+ recipientId: updatedRecipient.id,
+ };
+ } catch (error) {
+ // Prisma throws an error (P2025) if the record to update is not found
+ // based on the where clause. We can catch this to return null gracefully.
+ // You might want more specific error handling depending on requirements.
+ if (
+ error instanceof Prisma.PrismaClientKnownRequestError &&
+ error.code === "P2025"
+ ) {
+ // console.warn(
+ // `NotificationRecipient ${recipientId} not found or access denied for user ${userId} in org ${organizationId}.`,
+ // );
+ return null;
+ }
+ // Re-throw other unexpected errors
+ // console.error(
+ // `Failed to mark notification recipient ${recipientId} as read:`,
+ // error,
+ // );
+ throw error;
+ }
+}
+
+/**
+ * Marks all unread notification recipient records as read for a specific user
+ * within a given organization by setting their `readAt` timestamp.
+ *
+ * @param userId - The ID of the user whose notifications should be marked as
+ * read.
+ * @param organizationId - The ID of the organization context.
+ * @returns A promise resolving to an object containing the count of records
+ * updated.
+ */
+export async function markAllUnreadNotificationsAsReadForUserAndOrganizationInDatabaseById({
+ userId,
+ organizationId,
+}: {
+ userId: UserAccount["id"];
+ organizationId: Organization["id"];
+}): Promise<{ count: number }> {
+ const result = await prisma.notificationRecipient.updateMany({
+ data: {
+ // Set readAt to the current time
+ readAt: new Date(),
+ },
+ where: {
+ // Ensure the related notification belongs to the correct organization
+ notification: { organizationId: organizationId },
+ // Target only unread records
+ readAt: null,
+ // Target records for the specific user
+ userId: userId,
+ },
+ });
+
+ // updateMany returns an object { count: number }
+ return result;
+}
diff --git a/apps/react-router/saas-template/app/features/notifications/notifications-panel-content.test.tsx b/apps/react-router/saas-template/app/features/notifications/notifications-panel-content.test.tsx
new file mode 100644
index 0000000..943efdb
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/notifications-panel-content.test.tsx
@@ -0,0 +1,71 @@
+import { faker } from "@faker-js/faker";
+import { createId } from "@paralleldrive/cuid2";
+import { describe, expect, test } from "vitest";
+
+import type { LinkNotificationProps } from "./notification-components";
+import { LINK_NOTIFICATION_TYPE } from "./notification-constants";
+import type { NotificationsPanelContentProps } from "./notifications-panel-content";
+import { NotificationsPanelContent } from "./notifications-panel-content";
+import { createRoutesStub, render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createLinkNotificationProps: Factory = ({
+ href = faker.internet.url(),
+ isRead = false,
+ recipientId = createId(),
+ text = faker.lorem.sentence(),
+} = {}) => ({
+ href,
+ isRead,
+ recipientId,
+ text,
+ type: LINK_NOTIFICATION_TYPE,
+});
+
+const createPanelProps: Factory = ({
+ notifications = [createLinkNotificationProps()],
+} = {}) => ({ notifications });
+
+describe("NotificationsPanelContent", () => {
+ test("given: no notifications, should: show empty state", () => {
+ const props = createPanelProps({ notifications: [] });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path: "/" },
+ ]);
+
+ render( );
+
+ expect(screen.getByText(/no notifications/i)).toBeInTheDocument();
+ });
+
+ test("given: a link notification, should: render it correctly", () => {
+ const notification = createLinkNotificationProps();
+ const props = createPanelProps({ notifications: [notification] });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path: "/" },
+ ]);
+
+ render( );
+
+ expect(screen.getByText(notification.text)).toBeInTheDocument();
+ expect(screen.getByRole("link")).toHaveAttribute("href", notification.href);
+ });
+
+ test("given: multiple notifications, should: render all of them", () => {
+ const notifications = [
+ createLinkNotificationProps(),
+ createLinkNotificationProps(),
+ createLinkNotificationProps(),
+ ];
+ const props = createPanelProps({ notifications });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path: "/" },
+ ]);
+
+ render( );
+
+ for (const notification of notifications) {
+ expect(screen.getByText(notification.text)).toBeInTheDocument();
+ }
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/notifications/notifications-panel-content.tsx b/apps/react-router/saas-template/app/features/notifications/notifications-panel-content.tsx
new file mode 100644
index 0000000..0bdfe3c
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/notifications-panel-content.tsx
@@ -0,0 +1,59 @@
+import { useTranslation } from "react-i18next";
+
+import type { LinkNotificationProps } from "./notification-components";
+import { LinkNotification } from "./notification-components";
+import { LINK_NOTIFICATION_TYPE } from "./notification-constants";
+
+export type NotificationType = LinkNotificationProps;
+
+type NotificationContentProps = {
+ notification: NotificationType;
+};
+
+function NotificationContent({ notification }: NotificationContentProps) {
+ switch (notification.type) {
+ case LINK_NOTIFICATION_TYPE: {
+ return ;
+ }
+ default: {
+ return;
+ }
+ }
+}
+
+export type NotificationsPanelContentProps = {
+ notifications: NotificationType[];
+};
+
+export function NotificationsPanelContent({
+ notifications,
+}: NotificationsPanelContentProps) {
+ const { t } = useTranslation("notifications", {
+ keyPrefix: "notificationsPanel",
+ });
+
+ if (notifications.length === 0) {
+ return (
+
+
+ {t("noNotificationsTitle")}
+
+
+
+ {t("noNotificationsDescription")}
+
+
+ );
+ }
+
+ return (
+
+ {notifications.map((notification) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/notifications/notifications-schemas.ts b/apps/react-router/saas-template/app/features/notifications/notifications-schemas.ts
new file mode 100644
index 0000000..5de07d5
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/notifications-schemas.ts
@@ -0,0 +1,35 @@
+import { z } from "zod";
+
+import {
+ LINK_NOTIFICATION_TYPE,
+ MARK_ALL_NOTIFICATIONS_AS_READ_INTENT,
+ MARK_ONE_NOTIFICATION_AS_READ_INTENT,
+ NOTIFICATION_PANEL_OPENED_INTENT,
+} from "./notification-constants";
+
+z.config({ jitless: true });
+
+/* Notification types */
+
+export const linkNotificationDataSchema = z.object({
+ href: z.string(),
+ text: z.string(),
+ type: z.literal(LINK_NOTIFICATION_TYPE),
+});
+
+export type LinkNotificationData = z.infer;
+
+/* Notification request schemas */
+
+export const markOneAsReadSchema = z.object({
+ intent: z.literal(MARK_ONE_NOTIFICATION_AS_READ_INTENT),
+ recipientId: z.string(),
+});
+
+export const markAllAsReadSchema = z.object({
+ intent: z.literal(MARK_ALL_NOTIFICATIONS_AS_READ_INTENT),
+});
+
+export const notificationPanelOpenedSchema = z.object({
+ intent: z.literal(NOTIFICATION_PANEL_OPENED_INTENT),
+});
diff --git a/apps/react-router/saas-template/app/features/notifications/use-pending-notifications.ts b/apps/react-router/saas-template/app/features/notifications/use-pending-notifications.ts
new file mode 100644
index 0000000..0df8e3a
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/notifications/use-pending-notifications.ts
@@ -0,0 +1,28 @@
+import { useFetchers } from "react-router";
+
+import { MARK_ONE_NOTIFICATION_AS_READ_INTENT } from "./notification-constants";
+import type { NotificationRecipient } from "~/generated/browser";
+
+type PendingNotification = {
+ intent: typeof MARK_ONE_NOTIFICATION_AS_READ_INTENT;
+ recipientId: NotificationRecipient["id"];
+};
+type MarkOneAsReadFetcher = ReturnType[number] &
+ PendingNotification;
+
+export function usePendingNotifications(): PendingNotification[] {
+ return useFetchers()
+ .filter((fetcher): fetcher is MarkOneAsReadFetcher => {
+ return (
+ fetcher.formData?.get("intent") === MARK_ONE_NOTIFICATION_AS_READ_INTENT
+ );
+ })
+ .map((fetcher) => ({
+ intent: fetcher.formData?.get(
+ "intent",
+ ) as typeof MARK_ONE_NOTIFICATION_AS_READ_INTENT,
+ recipientId: fetcher.formData?.get(
+ "recipientId",
+ ) as NotificationRecipient["id"],
+ }));
+}
diff --git a/apps/react-router/saas-template/app/features/onboarding/onboarding-helpers.server.test.ts b/apps/react-router/saas-template/app/features/onboarding/onboarding-helpers.server.test.ts
new file mode 100644
index 0000000..b36dea3
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/onboarding/onboarding-helpers.server.test.ts
@@ -0,0 +1,292 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: Test code */
+import { faker } from "@faker-js/faker";
+import { describe, expect, test } from "vitest";
+
+import { createPopulatedOrganization } from "../organizations/organizations-factories.server";
+import {
+ getUserIsOnboarded,
+ redirectUserToOnboardingStep,
+ throwIfUserIsOnboarded,
+ throwIfUserNeedsOnboarding,
+} from "./onboarding-helpers.server";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { createOnboardingUser } from "~/test/test-utils";
+
+describe("getUserIsOnboarded()", () => {
+ test("given a user with no memberships and no name: returns false", () => {
+ const user = createOnboardingUser({ memberships: [], name: "" });
+
+ const actual = getUserIsOnboarded(user);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given a user with memberships but no name: returns false", () => {
+ const user = createOnboardingUser({ name: "" });
+
+ const actual = getUserIsOnboarded(user);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given a user with a name but no memberships: returns false", () => {
+ const user = createOnboardingUser({ memberships: [] });
+
+ const actual = getUserIsOnboarded(user);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given a user with both a name and memberships: returns true", () => {
+ const user = createOnboardingUser();
+
+ const actual = getUserIsOnboarded(user);
+ const expected = true;
+
+ expect(actual).toEqual(expected);
+ });
+});
+
+describe("throwIfUserIsOnboarded()", () => {
+ test("given: a user with no name and no memberships, should: return the user", () => {
+ const user = createOnboardingUser({ memberships: [], name: "" });
+ const headers = new Headers({ "X-Test-Header": "test-value" });
+
+ const actual = throwIfUserIsOnboarded(user, headers);
+ const expected = user;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an onboarded user with exactly one organization, should: redirect to the organization page", () => {
+ expect.assertions(3);
+
+ const user = createOnboardingUser({
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: createPopulatedOrganization(),
+ role: OrganizationMembershipRole.member,
+ },
+ ],
+ name: "Test User",
+ });
+ const headers = new Headers({ "X-Test-Header": "test-value" });
+
+ try {
+ throwIfUserIsOnboarded(user, headers);
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual(
+ `/organizations/${user.memberships[0]!.organization.slug}`,
+ );
+ expect([...error.headers.entries()]).toEqual([
+ [
+ "location",
+ `/organizations/${user.memberships[0]!.organization.slug}`,
+ ],
+ ...headers.entries(),
+ ]);
+ }
+ }
+ });
+
+ test("given: an onboarded user with multiple organizations, should: redirect to the organizations page", () => {
+ expect.assertions(3);
+
+ const user = createOnboardingUser({
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: createPopulatedOrganization(),
+ role: OrganizationMembershipRole.member,
+ },
+ {
+ deactivatedAt: null,
+ organization: createPopulatedOrganization(),
+ role: OrganizationMembershipRole.member,
+ },
+ ],
+ name: "Test User",
+ });
+ const headers = new Headers({ "X-Test-Header": "test-value" });
+
+ try {
+ throwIfUserIsOnboarded(user, headers);
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual("/organizations");
+ expect([...error.headers.entries()]).toEqual([
+ ["location", "/organizations"],
+ ...headers.entries(),
+ ]);
+ }
+ }
+ });
+
+ test("given: a user with memberships but no name, should: return the user", () => {
+ const user = createOnboardingUser({ name: "" });
+ const headers = new Headers({ "X-Test-Header": "test-value" });
+
+ const actual = throwIfUserIsOnboarded(user, headers);
+ const expected = user;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a user with a name but no memberships, should: return the user", () => {
+ const user = createOnboardingUser({
+ memberships: [],
+ name: "Test User",
+ });
+ const headers = new Headers({ "X-Test-Header": "test-value" });
+
+ const actual = throwIfUserIsOnboarded(user, headers);
+ const expected = user;
+
+ expect(actual).toEqual(expected);
+ });
+});
+
+describe("redirectUserToOnboardingStep()", () => {
+ describe("user account onboarding page", () => {
+ test("given: a request to the user account onboarding page and a user has neither a name, nor organizations yet, should: return the user", () => {
+ const url = "http://localhost:3000/onboarding/user-account";
+ const method = faker.internet.httpMethod();
+ const request = new Request(url, { method });
+ const user = createOnboardingUser({ memberships: [], name: "" });
+ const headers = new Headers({ "X-Test-Header": "test-value" });
+
+ const actual = redirectUserToOnboardingStep(request, user, headers);
+ const expected = { headers, user };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test.each([
+ faker.internet.url(),
+ "http://localhost:3000/onboarding/organization",
+ ])("given: any other request (to %s) and the user has no name, and is NOT a member of any organizations yet, should: redirect the user to the organization onboarding page", (url) => {
+ expect.assertions(3);
+
+ const user = createOnboardingUser({ memberships: [], name: "" });
+ const method = faker.internet.httpMethod();
+ const request = new Request(url, { method });
+ const headers = new Headers({ "X-Test-Header": "test-value" });
+
+ try {
+ redirectUserToOnboardingStep(request, user, headers);
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual(
+ "/onboarding/user-account",
+ );
+ expect([...error.headers.entries()]).toEqual([
+ ["location", "/onboarding/user-account"],
+ ...headers.entries(),
+ ]);
+ }
+ }
+ });
+ });
+
+ describe("organization onboarding page", () => {
+ test("given: a request to the organization onboarding page and a user that is NOT a member of any organizations yet, should: return the user", () => {
+ const user = createOnboardingUser({ memberships: [] });
+ const url = "http://localhost:3000/onboarding/organization";
+ const method = faker.internet.httpMethod();
+ const request = new Request(url, { method });
+ const headers = new Headers({ "X-Test-Header": "test-value" });
+
+ const actual = redirectUserToOnboardingStep(request, user, headers);
+ const expected = { headers, user };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test.each([
+ faker.internet.url(),
+ "http://localhost:3000/onboarding/future-step",
+ ])("given: any other request (to %s) and a user that is NOT a member of any organizations yet, should: redirect the user to the organization onboarding page", (url) => {
+ expect.assertions(3);
+
+ const user = createOnboardingUser({ memberships: [] });
+ const method = faker.internet.httpMethod();
+ const request = new Request(url, { method });
+ const headers = new Headers({ "X-Test-Header": "test-value" });
+
+ try {
+ redirectUserToOnboardingStep(request, user, headers);
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual(
+ "/onboarding/organization",
+ );
+ expect([...error.headers.entries()]).toEqual([
+ ["location", "/onboarding/organization"],
+ ...headers.entries(),
+ ]);
+ }
+ }
+ });
+ });
+});
+
+describe("throwIfUserNeedsOnboarding()", () => {
+ test("given: a user with both a name and memberships, should: return the user", () => {
+ const user = createOnboardingUser();
+ const headers = new Headers();
+
+ const actual = throwIfUserNeedsOnboarding({ headers, user });
+ const expected = { headers, user };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a user with no memberships, should: redirect to the onboarding page", () => {
+ expect.assertions(3);
+
+ const user = createOnboardingUser({ memberships: [] });
+ const headers = new Headers({ "X-Test-Header": "test-value" });
+
+ try {
+ throwIfUserNeedsOnboarding({ headers, user });
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual("/onboarding");
+ expect([...error.headers.entries()]).toEqual([
+ ["location", "/onboarding"],
+ ...headers.entries(),
+ ]);
+ }
+ }
+ });
+
+ test("given: a user with no name, should: redirect to the onboarding page", () => {
+ expect.assertions(3);
+
+ const user = createOnboardingUser({ name: "" });
+ const headers = new Headers({ "X-Test-Header": "test-value" });
+
+ try {
+ throwIfUserNeedsOnboarding({ headers, user });
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual("/onboarding");
+ expect([...error.headers.entries()]).toEqual([
+ ["location", "/onboarding"],
+ ...headers.entries(),
+ ]);
+ }
+ }
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/onboarding/onboarding-helpers.server.ts b/apps/react-router/saas-template/app/features/onboarding/onboarding-helpers.server.ts
new file mode 100644
index 0000000..13c29bd
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/onboarding/onboarding-helpers.server.ts
@@ -0,0 +1,184 @@
+import type { RouterContextProvider } from "react-router";
+import { href, redirect } from "react-router";
+
+import { throwIfUserAccountIsMissing } from "../user-accounts/user-accounts-helpers.server";
+import { retrieveUserAccountWithMembershipsFromDatabaseBySupabaseUserId } from "../user-accounts/user-accounts-model.server";
+import { authContext } from "../user-authentication/user-authentication-middleware.server";
+import { asyncPipe } from "~/utils/async-pipe.server";
+
+/**
+ * Requires that a user account exists for the authenticated user.
+ * Retrieves the user's account with their memberships from the database.
+ *
+ * @param context - Router context provider containing authentication data.
+ * @param request - Request object used for error handling.
+ * @returns The user's account with their memberships and authentication headers.
+ * @throws Response with appropriate error status if the user account is missing.
+ */
+async function requireOnboardingUserExists({
+ context,
+ request,
+}: {
+ context: Readonly;
+ request: Request;
+}) {
+ const {
+ user: { id },
+ headers,
+ } = context.get(authContext);
+ const user =
+ await retrieveUserAccountWithMembershipsFromDatabaseBySupabaseUserId(id);
+ return { headers, user: await throwIfUserAccountIsMissing(request, user) };
+}
+
+/**
+ * The user for the onboarding helper functions.
+ */
+export type OnboardingUser = Awaited<
+ ReturnType
+>["user"];
+
+/**
+ * The organization with memberships and subscriptions for the onboarding helper
+ * functions.
+ */
+export type OrganizationWithMembershipsAndSubscriptions =
+ OnboardingUser["memberships"][number]["organization"];
+
+/**
+ * Checks if the user is onboarded, which means they have a name and are a
+ * member of at least one organization.
+ *
+ * @param user - The OnboardingUser object.
+ * @returns `true` if the user is onboarded; otherwise, `false`.
+ */
+export const getUserIsOnboarded = (user: OnboardingUser) =>
+ user.memberships.length > 0 && user.name.length > 0;
+
+/**
+ * Checks if the user is onboarded; if so, redirects the user to their first
+ * organization.
+ *
+ * @param user - The OnboardingUser object.
+ * @param headers - The Headers object containing the user's headers.
+ * @returns The user object if not onboarded.
+ * @throws Response with 302 status redirecting to the user's first organization
+ * if the user is onboarded.
+ */
+export const throwIfUserIsOnboarded = (
+ user: OnboardingUser,
+ headers: Headers,
+) => {
+ if (getUserIsOnboarded(user)) {
+ if (user.memberships.length === 1) {
+ // biome-ignore lint/style/noNonNullAssertion: The check above ensures that there is a membership
+ const slug = user.memberships[0]!.organization.slug;
+ throw redirect(
+ href("/organizations/:organizationSlug", {
+ organizationSlug: slug,
+ }),
+ { headers },
+ );
+ }
+
+ throw redirect(href("/organizations"), { headers });
+ }
+
+ return user;
+};
+
+/**
+ * Redirects the user to the appropriate onboarding step based on their state.
+ *
+ * @param request - The Request object containing the user's request.
+ * @param user - The user's account with their memberships.
+ * @param headers - The Headers object containing the user's headers.
+ * @returns A function that takes the user object and returns it if the user is
+ * on the correct onboarding step; otherwise, throws a 302 redirect to the
+ * appropriate step.
+ */
+export const redirectUserToOnboardingStep = (
+ request: Request,
+ user: OnboardingUser,
+ headers: Headers,
+) => {
+ const { pathname } = new URL(request.url);
+
+ if (user.name.length === 0 && pathname !== "/onboarding/user-account") {
+ throw redirect(href("/onboarding/user-account"), { headers });
+ }
+
+ if (
+ user.name.length > 0 &&
+ user.memberships.length === 0 &&
+ pathname !== "/onboarding/organization"
+ ) {
+ throw redirect(href("/onboarding/organization"), { headers });
+ }
+
+ return { headers, user };
+};
+
+/**
+ * Ensures the user needs onboarding and redirects to the appropriate onboarding
+ * step. If the user is onboarded, it navigates to their first organization.
+ *
+ * @param context - Router context provider containing authentication data.
+ * @param request - Request object containing the user's request.
+ * @returns The user object with headers if the user needs onboarding and is on the correct step.
+ * @throws Response with redirect to the user's first organization if already onboarded.
+ * @throws Response with redirect to the appropriate onboarding step if on the wrong step.
+ * @throws Response with appropriate error status if the user account is missing.
+ */
+export async function requireUserNeedsOnboarding({
+ context,
+ request,
+}: {
+ context: Readonly;
+ request: Request;
+}) {
+ const { user, headers } = await requireOnboardingUserExists({
+ context,
+ request,
+ });
+ return redirectUserToOnboardingStep(
+ request,
+ throwIfUserIsOnboarded(user, headers),
+ headers,
+ );
+}
+
+/**
+ * Checks if the user is onboarded; if not, redirects the user to the onboarding
+ * page.
+ *
+ * @param user - The OnboardingUser object.
+ * @returns The user object if onboarded.
+ */
+export const throwIfUserNeedsOnboarding = ({
+ user,
+ headers,
+}: {
+ user: OnboardingUser;
+ headers: Headers;
+}) => {
+ if (getUserIsOnboarded(user)) {
+ return { headers, user };
+ }
+
+ throw redirect(href("/onboarding"), { headers });
+};
+
+/**
+ * Returns a user account with their active organization memberships for a given
+ * user id and request.
+ *
+ * @param request - A Request object.
+ * @returns A user's account with their organization memberships.
+ * @throws A redirect to the login page if the user does NOT exist.
+ * @throws A redirect to the onboarding page if the user needs onboarding.
+ */
+export const requireOnboardedUserAccountExists = asyncPipe(
+ requireOnboardingUserExists,
+ throwIfUserNeedsOnboarding,
+);
diff --git a/apps/react-router/saas-template/app/features/onboarding/organization/onboarding-organization-action.server.ts b/apps/react-router/saas-template/app/features/onboarding/organization/onboarding-organization-action.server.ts
new file mode 100644
index 0000000..0f50200
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/onboarding/organization/onboarding-organization-action.server.ts
@@ -0,0 +1,55 @@
+import { coerceFormValue } from "@conform-to/zod/v4/future";
+import { createId } from "@paralleldrive/cuid2";
+import { redirect } from "react-router";
+
+import { requireUserNeedsOnboarding } from "../onboarding-helpers.server";
+import { onboardingOrganizationSchema } from "./onboarding-organization-schemas";
+import type { Route } from ".react-router/types/app/routes/_authenticated-routes+/onboarding+/+types/organization";
+import { uploadOrganizationLogo } from "~/features/organizations/organizations-helpers.server";
+import { saveOrganizationWithOwnerToDatabase } from "~/features/organizations/organizations-model.server";
+import { authContext } from "~/features/user-authentication/user-authentication-middleware.server";
+import { slugify } from "~/utils/slugify.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+export async function onboardingOrganizationAction({
+ request,
+ context,
+}: Route.ActionArgs) {
+ const { user, headers } = await requireUserNeedsOnboarding({
+ context,
+ request,
+ });
+ const { supabase } = context.get(authContext);
+ const result = await validateFormData(
+ request,
+ coerceFormValue(onboardingOrganizationSchema),
+ {
+ maxFileSize: 1_000_000, // 1MB
+ },
+ );
+
+ if (!result.success) {
+ return result.response;
+ }
+
+ const organizationId = createId();
+ const imageUrl = result.data.logo
+ ? await uploadOrganizationLogo({
+ file: result.data.logo,
+ organizationId,
+ supabase,
+ })
+ : "";
+
+ const organization = await saveOrganizationWithOwnerToDatabase({
+ organization: {
+ id: organizationId,
+ imageUrl,
+ name: result.data.name,
+ slug: slugify(result.data.name),
+ },
+ userId: user.id,
+ });
+
+ return redirect(`/organizations/${organization.slug}`, { headers });
+}
diff --git a/apps/react-router/saas-template/app/features/onboarding/organization/onboarding-organization-consants.ts b/apps/react-router/saas-template/app/features/onboarding/organization/onboarding-organization-consants.ts
new file mode 100644
index 0000000..ba8bdbf
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/onboarding/organization/onboarding-organization-consants.ts
@@ -0,0 +1 @@
+export const ONBOARDING_ORGANIZATION_INTENT = "createOrganization";
diff --git a/apps/react-router/saas-template/app/features/onboarding/organization/onboarding-organization-schemas.ts b/apps/react-router/saas-template/app/features/onboarding/organization/onboarding-organization-schemas.ts
new file mode 100644
index 0000000..a5c9f2d
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/onboarding/organization/onboarding-organization-schemas.ts
@@ -0,0 +1,69 @@
+import { z } from "zod";
+
+import { ONBOARDING_ORGANIZATION_INTENT } from "./onboarding-organization-consants";
+
+const MIN_NAME_LENGTH = 3;
+const MAX_NAME_LENGTH = 72;
+const ONE_MB = 1_000_000;
+
+const REFERRAL_SOURCE_OPTIONS = [
+ "searchEngine",
+ "socialMedia",
+ "colleagueReferral",
+ "industryEvent",
+ "blogArticle",
+ "podcast",
+ "onlineAd",
+ "partnerReferral",
+ "productHunt",
+ "wordOfMouth",
+ "other",
+] as const;
+
+const COMPANY_TYPE_OPTIONS = [
+ "startup",
+ "midMarket",
+ "enterprise",
+ "agency",
+ "nonprofit",
+ "government",
+] as const;
+
+const COMPANY_SIZE_OPTIONS = [
+ "1-10",
+ "11-50",
+ "51-200",
+ "201-500",
+ "501-1000",
+ "1001+",
+] as const;
+
+z.config({ jitless: true });
+
+export const onboardingOrganizationSchema = z.object({
+ companySize: z.enum(COMPANY_SIZE_OPTIONS).optional(),
+ companyTypes: z.array(z.enum(COMPANY_TYPE_OPTIONS)).optional(),
+ earlyAccessOptIn: z.boolean().default(false),
+ intent: z.literal(ONBOARDING_ORGANIZATION_INTENT),
+ logo: z
+ .file()
+ .max(ONE_MB, { message: "onboarding:organization.errors.logoTooLarge" })
+ .mime(["image/png", "image/jpeg", "image/gif", "image/webp"], {
+ message: "onboarding:organization.errors.invalidFileType",
+ })
+ .optional(),
+ name: z
+ .string()
+ .trim()
+ .min(MIN_NAME_LENGTH, {
+ message: "onboarding:organization.errors.nameMin",
+ })
+ .max(MAX_NAME_LENGTH, {
+ message: "onboarding:organization.errors.nameMax",
+ }),
+ recruitingPainPoint: z.string().default(""),
+ referralSources: z.array(z.enum(REFERRAL_SOURCE_OPTIONS)).optional(),
+ website: z.url().optional().or(z.literal("")).default(""),
+});
+
+export { COMPANY_SIZE_OPTIONS, COMPANY_TYPE_OPTIONS, REFERRAL_SOURCE_OPTIONS };
diff --git a/apps/react-router/saas-template/app/features/onboarding/talent-map.tsx b/apps/react-router/saas-template/app/features/onboarding/talent-map.tsx
new file mode 100644
index 0000000..183cec6
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/onboarding/talent-map.tsx
@@ -0,0 +1,107 @@
+/** biome-ignore-all lint/style/noMagicNumbers: Marker sizes are based on city importance */
+import type { COBEOptions } from "cobe";
+
+import { Globe } from "~/components/ui/globe";
+import { LightRays } from "~/components/ui/light-rays";
+import { usePrefersReducedMotion } from "~/hooks/use-prefers-reduced-motion";
+
+const markers: COBEOptions["markers"] = [
+ // Original cities - Major global hubs
+ { location: [40.7128, -74.006], size: 0.1 }, // New York
+ { location: [34.0522, -118.2437], size: 0.09 }, // Los Angeles
+ { location: [51.5074, -0.1278], size: 0.1 }, // London
+ { location: [-33.8688, 151.2093], size: 0.08 }, // Sydney
+ { location: [48.8566, 2.3522], size: 0.09 }, // Paris
+ { location: [35.6762, 139.6503], size: 0.1 }, // Tokyo
+ { location: [55.7558, 37.6176], size: 0.08 }, // Moscow
+ { location: [39.9042, 116.4074], size: 0.1 }, // Beijing
+ { location: [28.6139, 77.209], size: 0.1 }, // New Delhi
+ { location: [-23.5505, -46.6333], size: 0.1 }, // São Paulo
+ { location: [1.3521, 103.8198], size: 0.08 }, // Singapore
+ { location: [25.2048, 55.2708], size: 0.07 }, // Dubai
+ { location: [52.52, 13.405], size: 0.07 }, // Berlin
+ { location: [19.4326, -99.1332], size: 0.1 }, // Mexico City
+ { location: [-26.2041, 28.0473], size: 0.07 }, // Johannesburg
+ // Asia-Pacific additions
+ { location: [31.1978, 121.336], size: 0.1 }, // Shanghai, China
+ { location: [22.305, 114.185], size: 0.08 }, // Hong Kong
+ { location: [37.5665, 126.978], size: 0.09 }, // Seoul, South Korea
+ { location: [19.076, 72.8777], size: 0.1 }, // Mumbai, India
+ { location: [12.97, 77.56], size: 0.08 }, // Bangalore, India
+ // Africa additions
+ { location: [30.0444, 31.2357], size: 0.09 }, // Cairo, Egypt
+ { location: [6.5244, 3.3792], size: 0.08 }, // Lagos, Nigeria
+ { location: [-1.2921, 36.8219], size: 0.06 }, // Nairobi, Kenya
+ { location: [-33.9249, 18.4241], size: 0.06 }, // Cape Town, South Africa
+ { location: [5.6037, -0.187], size: 0.05 }, // Accra, Ghana
+ // South America additions
+ { location: [-34.6037, -58.3816], size: 0.08 }, // Buenos Aires, Argentina
+ { location: [4.711, -74.0721], size: 0.07 }, // Bogotá, Colombia
+ { location: [-33.4489, -70.6693], size: 0.06 }, // Santiago, Chile
+ { location: [-12.0464, -77.0428], size: 0.06 }, // Lima, Peru
+ { location: [-22.9068, -43.1729], size: 0.07 }, // Rio de Janeiro, Brazil
+ { location: [10.4806, -66.9036], size: 0.05 }, // Caracas, Venezuela
+ { location: [-0.1807, -78.4678], size: 0.04 }, // Quito, Ecuador
+ { location: [-34.9011, -56.1645], size: 0.04 }, // Montevideo, Uruguay
+ { location: [-16.5, -68.15], size: 0.04 }, // La Paz, Bolivia
+ { location: [-25.2637, -57.5759], size: 0.03 }, // Asunción, Paraguay
+ // Middle East additions
+ { location: [32.0853, 34.7818], size: 0.06 }, // Tel Aviv, Israel
+ { location: [24.4667, 54.3667], size: 0.06 }, // Abu Dhabi, UAE
+ { location: [24.6408, 46.7727], size: 0.06 }, // Riyadh, Saudi Arabia
+ // North America additions
+ { location: [37.7749, -122.4194], size: 0.08 }, // San Francisco, USA
+ { location: [43.6511, -79.3832], size: 0.08 }, // Toronto, Canada
+ { location: [41.8781, -87.6298], size: 0.08 }, // Chicago, USA
+ { location: [49.2827, -123.1207], size: 0.06 }, // Vancouver, Canada
+ { location: [30.2672, -97.7431], size: 0.06 }, // Austin, USA
+ { location: [47.6062, -122.3321], size: 0.07 }, // Seattle, USA
+ { location: [42.3601, -71.0589], size: 0.07 }, // Boston, USA
+ { location: [25.7617, -80.1918], size: 0.06 }, // Miami, USA
+ { location: [38.9072, -77.0369], size: 0.07 }, // Washington D.C., USA
+ { location: [45.5017, -73.5673], size: 0.06 }, // Montreal, Canada
+ { location: [39.7392, -104.9903], size: 0.06 }, // Denver, USA
+ { location: [32.7157, -117.1611], size: 0.06 }, // San Diego, USA
+ { location: [33.4484, -112.074], size: 0.06 }, // Phoenix, USA
+ { location: [39.9526, -75.1652], size: 0.07 }, // Philadelphia, USA
+ { location: [33.749, -84.388], size: 0.07 }, // Atlanta, USA
+ { location: [32.7767, -96.797], size: 0.07 }, // Dallas, USA
+ { location: [29.7604, -95.3698], size: 0.07 }, // Houston, USA
+ // Europe additions
+ { location: [52.3676, 4.9041], size: 0.06 }, // Amsterdam, Netherlands
+ { location: [59.3293, 18.0686], size: 0.05 }, // Stockholm, Sweden
+ { location: [41.4, 2.15], size: 0.07 }, // Barcelona, Spain
+ { location: [40.4168, -3.7038], size: 0.07 }, // Madrid, Spain
+ { location: [41.9028, 12.4964], size: 0.06 }, // Rome, Italy
+ { location: [47.3769, 8.5417], size: 0.05 }, // Zurich, Switzerland
+ { location: [55.6761, 12.5683], size: 0.05 }, // Copenhagen, Denmark
+ { location: [48.2082, 16.3738], size: 0.06 }, // Vienna, Austria
+ { location: [53.3498, -6.2603], size: 0.05 }, // Dublin, Ireland
+ { location: [50.8503, 4.3517], size: 0.05 }, // Brussels, Belgium
+ { location: [38.7169, -9.1399], size: 0.05 }, // Lisbon, Portugal
+ { location: [59.9139, 10.7522], size: 0.04 }, // Oslo, Norway
+ { location: [37.9838, 23.7275], size: 0.06 }, // Athens, Greece
+ { location: [60.1695, 24.9354], size: 0.04 }, // Helsinki, Finland
+ { location: [52.2297, 21.0122], size: 0.06 }, // Warsaw, Poland
+ { location: [50.0755, 14.4378], size: 0.05 }, // Prague, Czech Republic
+ { location: [47.4979, 19.0402], size: 0.05 }, // Budapest, Hungary
+ { location: [41.0082, 28.9784], size: 0.08 }, // Istanbul, Turkey
+];
+
+export function TalentMap() {
+ const prefersReducedMotion = usePrefersReducedMotion();
+
+ return (
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/onboarding/user-account/onboarding-user-account-action.server.ts b/apps/react-router/saas-template/app/features/onboarding/user-account/onboarding-user-account-action.server.ts
new file mode 100644
index 0000000..e1dfdd6
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/onboarding/user-account/onboarding-user-account-action.server.ts
@@ -0,0 +1,93 @@
+import { coerceFormValue } from "@conform-to/zod/v4/future";
+import { href, redirect } from "react-router";
+
+import { requireUserNeedsOnboarding } from "../onboarding-helpers.server";
+import { onboardingUserAccountSchema } from "./onboarding-user-account-schemas";
+import type { Route } from ".react-router/types/app/routes/_authenticated-routes+/onboarding+/+types/user-account";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { destroyEmailInviteInfoSession } from "~/features/organizations/accept-email-invite/accept-email-invite-session.server";
+import { destroyInviteLinkInfoSession } from "~/features/organizations/accept-invite-link/accept-invite-link-session.server";
+import { updateEmailInviteLinkInDatabaseById } from "~/features/organizations/organizations-email-invite-link-model.server";
+import { getInviteInfoForAuthRoutes } from "~/features/organizations/organizations-helpers.server";
+import { uploadUserAvatar } from "~/features/user-accounts/settings/account/account-settings-helpers.server";
+import { updateUserAccountInDatabaseById } from "~/features/user-accounts/user-accounts-model.server";
+import { authContext } from "~/features/user-authentication/user-authentication-middleware.server";
+import { combineHeaders } from "~/utils/combine-headers.server";
+import { redirectWithToast } from "~/utils/toast.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+export async function onboardingUserAccountAction({
+ request,
+ context,
+}: Route.ActionArgs) {
+ const { headers, user } = await requireUserNeedsOnboarding({
+ context,
+ request,
+ });
+ const { supabase } = context.get(authContext);
+ const result = await validateFormData(
+ request,
+ coerceFormValue(onboardingUserAccountSchema),
+ {
+ maxFileSize: 1_000_000, // 1MB
+ },
+ );
+
+ if (!result.success) {
+ return result.response;
+ }
+
+ const imageUrl = result.data.image
+ ? await uploadUserAvatar({
+ file: result.data.image,
+ supabase,
+ userId: user.id,
+ })
+ : "";
+
+ await updateUserAccountInDatabaseById({
+ id: user.id,
+ user: { imageUrl, name: result.data.name },
+ });
+
+ const { inviteLinkInfo, headers: inviteLinkHeaders } =
+ await getInviteInfoForAuthRoutes(request);
+
+ if (user.memberships.length > 0 && inviteLinkInfo) {
+ const i18n = getInstance(context);
+
+ if (inviteLinkInfo.type === "emailInvite") {
+ await updateEmailInviteLinkInDatabaseById({
+ emailInviteLink: { deactivatedAt: new Date() },
+ id: inviteLinkInfo.inviteLinkId,
+ });
+ }
+
+ return redirectWithToast(
+ href("/organizations/:organizationSlug/dashboard", {
+ organizationSlug: inviteLinkInfo.organizationSlug,
+ }),
+ {
+ description: i18n.t(
+ "organizations:acceptInviteLink.joinSuccessToastDescription",
+ {
+ organizationName: inviteLinkInfo.organizationName,
+ },
+ ),
+ title: i18n.t("organizations:acceptInviteLink.joinSuccessToastTitle"),
+ type: "success",
+ },
+ {
+ headers: combineHeaders(
+ headers,
+ await destroyEmailInviteInfoSession(request),
+ await destroyInviteLinkInfoSession(request),
+ ),
+ },
+ );
+ }
+
+ return redirect(href("/onboarding/organization"), {
+ headers: combineHeaders(headers, inviteLinkHeaders),
+ });
+}
diff --git a/apps/react-router/saas-template/app/features/onboarding/user-account/onboarding-user-account-constants.ts b/apps/react-router/saas-template/app/features/onboarding/user-account/onboarding-user-account-constants.ts
new file mode 100644
index 0000000..e4799fb
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/onboarding/user-account/onboarding-user-account-constants.ts
@@ -0,0 +1 @@
+export const ONBOARDING_USER_ACCOUNT_INTENT = "createUserAccount";
diff --git a/apps/react-router/saas-template/app/features/onboarding/user-account/onboarding-user-account-schemas.ts b/apps/react-router/saas-template/app/features/onboarding/user-account/onboarding-user-account-schemas.ts
new file mode 100644
index 0000000..d8ad1df
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/onboarding/user-account/onboarding-user-account-schemas.ts
@@ -0,0 +1,29 @@
+import { z } from "zod";
+
+import { ONBOARDING_USER_ACCOUNT_INTENT } from "./onboarding-user-account-constants";
+
+const MIN_NAME_LENGTH = 2;
+const MAX_NAME_LENGTH = 128;
+const ONE_MB = 1_000_000;
+
+z.config({ jitless: true });
+
+export const onboardingUserAccountSchema = z.object({
+ image: z
+ .file()
+ .max(ONE_MB, { message: "onboarding:userAccount.errors.photoTooLarge" })
+ .mime(["image/png", "image/jpeg", "image/gif", "image/webp"], {
+ message: "onboarding:userAccount.errors.invalidFileType",
+ })
+ .optional(),
+ intent: z.literal(ONBOARDING_USER_ACCOUNT_INTENT),
+ name: z
+ .string()
+ .trim()
+ .min(MIN_NAME_LENGTH, {
+ message: "onboarding:userAccount.errors.nameMin",
+ })
+ .max(MAX_NAME_LENGTH, {
+ message: "onboarding:userAccount.errors.nameMax",
+ }),
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-action.server.ts b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-action.server.ts
new file mode 100644
index 0000000..0f9ac4a
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-action.server.ts
@@ -0,0 +1,185 @@
+import { href } from "react-router";
+import { z } from "zod";
+
+import {
+ retrieveActiveEmailInviteLinkFromDatabaseByToken,
+ updateEmailInviteLinkInDatabaseById,
+} from "../organizations-email-invite-link-model.server";
+import { acceptEmailInvite } from "../organizations-helpers.server";
+import { ACCEPT_EMAIL_INVITE_INTENT } from "./accept-email-invite-constants";
+import { getEmailInviteToken } from "./accept-email-invite-helpers.server";
+import { createEmailInviteInfoHeaders } from "./accept-email-invite-session.server";
+import type { Route } from ".react-router/types/app/routes/organizations_+/+types/email-invite";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { requireSupabaseUserExists } from "~/features/user-accounts/user-accounts-helpers.server";
+import { createSupabaseServerClient } from "~/features/user-authentication/supabase.server";
+import { combineHeaders } from "~/utils/combine-headers.server";
+import { getErrorMessage } from "~/utils/get-error-message";
+import { getIsDataWithResponseInit } from "~/utils/get-is-data-with-response-init.server";
+import { badRequest } from "~/utils/http-responses.server";
+import { createToastHeaders, redirectWithToast } from "~/utils/toast.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+const acceptEmailInviteSchema = z.object({
+ intent: z.literal(ACCEPT_EMAIL_INVITE_INTENT),
+});
+
+export async function acceptEmailInviteAction({
+ request,
+ context,
+}: Route.ActionArgs) {
+ try {
+ const i18n = getInstance(context);
+ const result = await validateFormData(request, acceptEmailInviteSchema);
+ if (!result.success) return result.response;
+
+ const data = result.data;
+
+ switch (data.intent) {
+ case ACCEPT_EMAIL_INVITE_INTENT: {
+ const { supabase, headers } = createSupabaseServerClient({ request });
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ const token = getEmailInviteToken(request);
+
+ if (!token) {
+ const toastHeaders = await createToastHeaders({
+ description: i18n.t(
+ "organizations:acceptEmailInvite.inviteEmailInvalidToastDescription",
+ ),
+ title: i18n.t(
+ "organizations:acceptEmailInvite.inviteEmailInvalidToastTitle",
+ ),
+ type: "error",
+ });
+
+ return badRequest(
+ { error: "Invalid token" },
+ { headers: combineHeaders(headers, toastHeaders) },
+ );
+ }
+
+ const link =
+ await retrieveActiveEmailInviteLinkFromDatabaseByToken(token);
+
+ if (!link) {
+ const toastHeaders = await createToastHeaders({
+ description: i18n.t(
+ "organizations:acceptEmailInvite.inviteEmailInvalidToastDescription",
+ ),
+ title: i18n.t(
+ "organizations:acceptEmailInvite.inviteEmailInvalidToastTitle",
+ ),
+ type: "error",
+ });
+
+ return badRequest(
+ { error: "Invalid token" },
+ { headers: combineHeaders(headers, toastHeaders) },
+ );
+ }
+
+ if (user) {
+ const userAccount = await requireSupabaseUserExists(request, user.id);
+
+ try {
+ await acceptEmailInvite({
+ emailInviteId: link.id,
+ emailInviteToken: link.token,
+ i18n,
+ organizationId: link.organization.id,
+ request,
+ role: link.role,
+ userAccountId: userAccount.id,
+ });
+
+ return redirectWithToast(
+ href("/organizations/:organizationSlug/dashboard", {
+ organizationSlug: link.organization.slug,
+ }),
+ {
+ description: i18n.t(
+ "organizations:acceptEmailInvite.joinSuccessToastDescription",
+ {
+ organizationName: link.organization.name,
+ },
+ ),
+ title: i18n.t(
+ "organizations:acceptEmailInvite.joinSuccessToastTitle",
+ ),
+ type: "success",
+ },
+ { headers },
+ );
+ } catch (error) {
+ const message = getErrorMessage(error);
+
+ if (
+ message.includes(
+ "Unique constraint failed on the fields: (`memberId`,`organizationId`)",
+ ) ||
+ message.includes(
+ "Unique constraint failed on the fields: (`userId`,`organizationId`)",
+ ) ||
+ message.includes(
+ 'Unique constraint failed on the fields: (`"userId"',
+ )
+ ) {
+ await updateEmailInviteLinkInDatabaseById({
+ emailInviteLink: { deactivatedAt: new Date() },
+ id: link.id,
+ });
+ return await redirectWithToast(
+ href("/organizations/:organizationSlug/dashboard", {
+ organizationSlug: link.organization.slug,
+ }),
+ {
+ description: i18n.t(
+ "organizations:acceptEmailInvite.alreadyMemberToastDescription",
+ {
+ organizationName: link.organization.name,
+ },
+ ),
+ title: i18n.t(
+ "organizations:acceptEmailInvite.alreadyMemberToastTitle",
+ ),
+ type: "info",
+ },
+ { headers },
+ );
+ }
+
+ throw error;
+ }
+ }
+
+ const emailInviteInfo = await createEmailInviteInfoHeaders({
+ emailInviteToken: link.token,
+ expiresAt: link.expiresAt,
+ });
+
+ return redirectWithToast(
+ href("/register"),
+ {
+ description: i18n.t(
+ "organizations:acceptEmailInvite.inviteEmailValidToastDescription",
+ ),
+ title: i18n.t(
+ "organizations:acceptEmailInvite.inviteEmailValidToastTitle",
+ ),
+ type: "info",
+ },
+ { headers: combineHeaders(headers, emailInviteInfo) },
+ );
+ }
+ }
+ } catch (error) {
+ if (getIsDataWithResponseInit(error)) {
+ return error;
+ }
+
+ throw error;
+ }
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-constants.ts b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-constants.ts
new file mode 100644
index 0000000..94ed6a4
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-constants.ts
@@ -0,0 +1,2 @@
+export const ACCEPT_EMAIL_INVITE_INTENT = "acceptEmailInvite";
+export const EMAIL_INVITE_INFO_SESSION_NAME = "__email_invite_info";
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-helpers.server.test.ts b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-helpers.server.test.ts
new file mode 100644
index 0000000..58e72c4
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-helpers.server.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "vitest";
+
+import { createPopulatedOrganizationEmailInviteLink } from "../organizations-factories.server";
+import { getEmailInviteToken } from "./accept-email-invite-helpers.server";
+
+describe("getEmailInviteToken()", () => {
+ test("given: request with token query param, should: return the token", () => {
+ const token = createPopulatedOrganizationEmailInviteLink().token;
+ const request = new Request(`http://example.com/?token=${token}`);
+
+ const actual = getEmailInviteToken(request);
+
+ expect(actual).toEqual(token);
+ });
+
+ test("given: request without token query param, should: return empty string", () => {
+ const request = new Request("http://example.com");
+
+ const actual = getEmailInviteToken(request);
+ const expected = "";
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: request with multiple token query params, should: return first token", () => {
+ const token1 = createPopulatedOrganizationEmailInviteLink().token;
+ const token2 = createPopulatedOrganizationEmailInviteLink().token;
+ const request = new Request(
+ `http://example.com/?token=${token1}&token=${token2}`,
+ );
+
+ const actual = getEmailInviteToken(request);
+
+ expect(actual).toEqual(token1);
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-helpers.server.ts b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-helpers.server.ts
new file mode 100644
index 0000000..0a1ae82
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-helpers.server.ts
@@ -0,0 +1,107 @@
+import { retrieveActiveEmailInviteLinkFromDatabaseByToken } from "../organizations-email-invite-link-model.server";
+import {
+ destroyEmailInviteInfoSession,
+ getEmailInviteInfoFromSession,
+} from "./accept-email-invite-session.server";
+import { asyncPipe } from "~/utils/async-pipe.server";
+import { getSearchParameterFromRequest } from "~/utils/get-search-parameter-from-request.server";
+import { notFound } from "~/utils/http-responses.server";
+import { throwIfEntityIsMissing } from "~/utils/throw-if-entity-is-missing.server";
+
+/**
+ * Checks if the provided email invite has expired.
+ *
+ * @param invite - The email invite object retrieved from the database.
+ * @throws A '404 Not Found' HTTP response if the email invite has expired.
+ */
+export const throwIfEmailInviteIsExpired = (
+ invite: NonNullable<
+ Awaited>
+ >,
+) => {
+ if (!invite || !invite.expiresAt || new Date() > invite.expiresAt) {
+ throw notFound();
+ }
+
+ return invite;
+};
+
+/**
+ * Retrieves the email invite token from the request URL.
+ *
+ * @param request - The request to get the token from.
+ * @returns The token if found, otherwise undefined.
+ */
+export const getEmailInviteToken = getSearchParameterFromRequest("token");
+
+/**
+ * Validates and returns the organization email invite identified by the provided
+ * token.
+ *
+ * @param token - The unique token identifying the email invite.
+ * @returns A Promise that resolves with the email invite object if it exists and
+ * has not expired.
+ * @throws A '404 Not Found' error if the email invite does not exist in the
+ * database or is expired.
+ */
+export const requireEmailInviteByTokenExists = asyncPipe(
+ retrieveActiveEmailInviteLinkFromDatabaseByToken,
+ throwIfEntityIsMissing,
+ throwIfEmailInviteIsExpired,
+);
+
+/**
+ * Ensures that an email invite identified by the provided token exists in the
+ * database and returns the necessary data for the page.
+ *
+ * @param token - The unique token for the email invite.
+ * @returns An object containing inviter and organization data associated with
+ * the token.
+ * @throws A '404 not found' HTTP response if the email invite identified by the
+ * token doesn't exist or is expired.
+ */
+export async function requireEmailInviteDataByTokenExists(token: string) {
+ const emailInvite = await requireEmailInviteByTokenExists(token);
+ return {
+ inviterName: emailInvite.invitedBy?.name ?? "Deactivated User",
+ organizationName: emailInvite.organization.name,
+ };
+}
+
+/**
+ * Retrieves the email invite information from the session and validates it.
+ * If the email invite is expired or deactivated, it will be destroyed from the
+ * session and the headers will be returned.
+ *
+ * @param request - The request to get the email invite information from.
+ * @returns An object containing the headers and the email invite information.
+ */
+export async function getValidEmailInviteInfo(request: Request) {
+ const tokenInfo = await getEmailInviteInfoFromSession(request);
+
+ if (tokenInfo) {
+ const emailInvite = await retrieveActiveEmailInviteLinkFromDatabaseByToken(
+ tokenInfo.emailInviteToken,
+ );
+
+ if (emailInvite?.organization) {
+ return {
+ emailInviteInfo: {
+ emailInviteId: emailInvite.id,
+ emailInviteToken: emailInvite.token,
+ inviterName: emailInvite.invitedBy?.name ?? "Deactivated User",
+ organizationId: emailInvite.organization.id,
+ organizationName: emailInvite.organization.name,
+ organizationSlug: emailInvite.organization.slug,
+ role: emailInvite.role,
+ },
+ headers: new Headers(),
+ };
+ }
+
+ const headers = await destroyEmailInviteInfoSession(request);
+ return { emailInviteInfo: undefined, headers };
+ }
+
+ return { emailInviteInfo: undefined, headers: new Headers() };
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-page.test.tsx b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-page.test.tsx
new file mode 100644
index 0000000..995b1cb
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-page.test.tsx
@@ -0,0 +1,52 @@
+import { describe, expect, test } from "vitest";
+
+import { createPopulatedOrganization } from "../organizations-factories.server";
+import type { AcceptEmailInvitePageProps } from "./accept-email-invite-page";
+import { AcceptEmailInvitePage } from "./accept-email-invite-page";
+import { createPopulatedUserAccount } from "~/features/user-accounts/user-accounts-factories.server";
+import { createRoutesStub, render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createProps: Factory = ({
+ inviterName = createPopulatedUserAccount().name,
+ organizationName = createPopulatedOrganization().name,
+ ...props
+} = {}) => ({ inviterName, organizationName, ...props });
+
+describe("AcceptEmailInvitePage component", () => {
+ test("given: an organization name and an inviter name, should: render a greeting and a button to accept the invite", () => {
+ const props = createProps();
+ const path = `/organizations/invite-email`;
+ const RemixStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // It renders a greeting.
+ expect(
+ screen.getByText(/welcome to react router saas template/i),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ new RegExp(
+ `${props.inviterName} invites you to join ${props.organizationName}`,
+ "i",
+ ),
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /click the button below to sign up. by using this email invite you will automatically join the correct organization./i,
+ ),
+ ).toBeInTheDocument();
+
+ // It renders a button to accept the invite.
+ expect(
+ screen.getByRole("button", { name: /accept invite/i }),
+ ).toHaveAttribute("type", "submit");
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-page.tsx b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-page.tsx
new file mode 100644
index 0000000..2446b4d
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-page.tsx
@@ -0,0 +1,95 @@
+import { useTranslation } from "react-i18next";
+import { Form, useNavigation } from "react-router";
+
+import { ACCEPT_EMAIL_INVITE_INTENT } from "./accept-email-invite-constants";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import { Spinner } from "~/components/ui/spinner";
+import type { Organization, UserAccount } from "~/generated/browser";
+
+export type AcceptEmailInvitePageProps = {
+ inviterName: UserAccount["name"];
+ organizationName: Organization["name"];
+};
+
+export function AcceptEmailInvitePage({
+ inviterName,
+ organizationName,
+}: AcceptEmailInvitePageProps) {
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "acceptEmailInvite",
+ });
+ const { t: tCommon } = useTranslation("translation");
+ const navigation = useNavigation();
+ const isAcceptingInvite =
+ navigation.formData?.get("intent") === ACCEPT_EMAIL_INVITE_INTENT;
+
+ return (
+
+
+
+
+
+
+ {t("welcomeToAppName", { appName: tCommon("appName") })}
+
+
+
+
+
+ {t("inviteYouToJoin", { inviterName, organizationName })}
+
+
+
+ {t("acceptInviteInstructions")}
+
+
+
+
+ {isAcceptingInvite ? (
+ <>
+
+ {t("acceptingInvite")}
+ >
+ ) : (
+ t("acceptInvite")
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-session.server.spec.ts b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-session.server.spec.ts
new file mode 100644
index 0000000..1d58eed
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-session.server.spec.ts
@@ -0,0 +1,163 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: Test code */
+import { addSeconds, subSeconds } from "date-fns";
+import { afterAll, describe, expect, test, vi } from "vitest";
+
+import { createPopulatedOrganizationEmailInviteLink } from "../organizations-factories.server";
+import type { EmailInviteInfoSessionData } from "./accept-email-invite-session.server";
+import {
+ createEmailInviteInfoHeaders,
+ destroyEmailInviteInfoSession,
+ getEmailInviteInfoFromSession,
+} from "./accept-email-invite-session.server";
+
+// Helper function adapted from toast.server.spec.ts
+const mapHeaders = (headers: Headers): Headers | undefined => {
+ const cookie = headers.get("Set-Cookie");
+ return cookie ? new Headers({ Cookie: cookie }) : undefined;
+};
+
+// Test data factory
+const createTestEmailInviteInfo = (
+ overrides: Partial<
+ EmailInviteInfoSessionData & {
+ expiresInSeconds?: number;
+ isExpired?: boolean;
+ }
+ > = {},
+): EmailInviteInfoSessionData & { expiresAt: Date } => {
+ const expiresInSeconds = overrides.expiresInSeconds ?? 3600; // Default 1 hour
+ const expiresAt = overrides.isExpired
+ ? subSeconds(new Date(), 10) // 10 seconds in the past
+ : addSeconds(new Date(), expiresInSeconds);
+
+ return {
+ emailInviteToken:
+ overrides.emailInviteToken ??
+ createPopulatedOrganizationEmailInviteLink().token,
+ expiresAt: expiresAt,
+ };
+};
+
+describe("Email Invite Info Session", () => {
+ // Mock console.warn to prevent noise during expired invite tests
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
+ // Do nothing
+ });
+
+ // Restore mocks after all tests in this describe block
+ afterAll(() => {
+ warnSpy.mockRestore();
+ });
+
+ describe("createEmailInviteInfoHeaders() & getEmailInviteInfoFromSession()", () => {
+ test("given: valid email invite data, should: create headers with Set-Cookie containing the data", async () => {
+ const inviteInfo = createTestEmailInviteInfo({ expiresInSeconds: 3600 });
+
+ const headers = await createEmailInviteInfoHeaders(inviteInfo);
+ const cookieHeader = headers.get("Set-Cookie");
+
+ expect(cookieHeader).toBeTruthy();
+ expect(cookieHeader).toContain("__email_invite_info=");
+ expect(cookieHeader).toContain("HttpOnly");
+ expect(cookieHeader).toContain("Path=/");
+ expect(cookieHeader).toContain("SameSite=Lax");
+ // Approximate check for Max-Age
+ const maxAgeMatch = /Max-Age=(\d+);/.exec(cookieHeader!);
+ expect(maxAgeMatch).not.toBeNull();
+ const maxAge = Number.parseInt(maxAgeMatch![1]!, 10);
+ expect(maxAge).toBeGreaterThan(3590); // ~1 hour minus slight delay
+ expect(maxAge).toBeLessThanOrEqual(3600); // 1 hour
+
+ // Now test retrieval
+ const request = new Request("http://example.com", {
+ headers: mapHeaders(headers),
+ });
+ const actual = await getEmailInviteInfoFromSession(request);
+ const expected: EmailInviteInfoSessionData = {
+ emailInviteToken: inviteInfo.emailInviteToken,
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a request with no session cookie, should: return undefined", async () => {
+ const request = new Request("http://example.com");
+ const actual = await getEmailInviteInfoFromSession(request);
+ const expected: EmailInviteInfoSessionData | undefined = undefined;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: email invite data with expiration in the past, should: return destroying headers and read undefined", async () => {
+ const expiredInviteInfo = createTestEmailInviteInfo({ isExpired: true });
+
+ const headers = await createEmailInviteInfoHeaders(expiredInviteInfo);
+ const cookieHeader = headers.get("Set-Cookie");
+
+ // Expect a header that expires the cookie immediately
+ expect(cookieHeader).toBeTruthy();
+ expect(cookieHeader).toContain("__email_invite_info=");
+ expect(cookieHeader).toMatch(/Max-Age=0|Expires=.*1970/);
+ expect(warnSpy).toHaveBeenCalledExactlyOnceWith(
+ expect.stringContaining(
+ `Attempted to create email invite session cookie for already expired invite with token: ${expiredInviteInfo.emailInviteToken}`,
+ ),
+ );
+
+ // Attempting to read from this "destroyed" cookie should yield undefined
+ const request = new Request("http://example.com", {
+ headers: mapHeaders(headers),
+ });
+ const actual = await getEmailInviteInfoFromSession(request);
+ const expected: EmailInviteInfoSessionData | undefined = undefined;
+
+ expect(actual).toEqual(expected);
+ });
+ });
+
+ describe("destroyEmailInviteInfoSession()", () => {
+ test("given: a request with an existing session, should: return headers that expire the cookie", async () => {
+ // 1. Create a valid session cookie
+ const inviteInfo = createTestEmailInviteInfo();
+ const createHeaders = await createEmailInviteInfoHeaders(inviteInfo);
+ const requestWithCookie = new Request("http://example.com", {
+ headers: mapHeaders(createHeaders),
+ });
+
+ // 2. Call destroy session
+ const destroyHeaders =
+ await destroyEmailInviteInfoSession(requestWithCookie);
+ const destroyCookieHeader = destroyHeaders.get("Set-Cookie");
+
+ // 3. Assert the destroy header is correct
+ expect(destroyCookieHeader).toBeTruthy();
+ expect(destroyCookieHeader).toContain("__email_invite_info=;");
+ expect(destroyCookieHeader).toContain("Path=/");
+ expect(destroyCookieHeader).toMatch(/Max-Age=0|Expires=.*1970/); // Check for immediate expiry
+ expect(destroyCookieHeader).toContain("HttpOnly");
+ expect(destroyCookieHeader).toContain("SameSite=Lax");
+
+ // 4. Verify reading after destruction yields undefineds
+ const requestAfterDestroy = new Request("http://example.com", {
+ headers: mapHeaders(destroyHeaders), // Use the destroy header
+ });
+ const actual = await getEmailInviteInfoFromSession(requestAfterDestroy);
+ const expected: EmailInviteInfoSessionData | undefined = undefined;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a request with no session, should: still return expiring headers", async () => {
+ const requestWithoutCookie = new Request("http://example.com");
+
+ const destroyHeaders =
+ await destroyEmailInviteInfoSession(requestWithoutCookie);
+ const destroyCookieHeader = destroyHeaders.get("Set-Cookie");
+
+ // Assert the destroy header is correct, even if no cookie existed
+ expect(destroyCookieHeader).toBeTruthy();
+ expect(destroyCookieHeader).toContain("__email_invite_info=;");
+ expect(destroyCookieHeader).toMatch(/Max-Age=0|Expires=.*1970/);
+ });
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-session.server.ts b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-session.server.ts
new file mode 100644
index 0000000..daba855
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-email-invite/accept-email-invite-session.server.ts
@@ -0,0 +1,120 @@
+import { createCookieSessionStorage } from "react-router";
+import { z } from "zod";
+
+import { EMAIL_INVITE_INFO_SESSION_NAME } from "./accept-email-invite-constants";
+import type { OrganizationEmailInviteLink } from "~/generated/client";
+
+// Define keys for the session data
+const EMAIL_INVITE_TOKEN_KEY = "emailInviteToken"; // This is the token NOT the id
+
+const emailInviteSchema = z.object({
+ [EMAIL_INVITE_TOKEN_KEY]: z.string(),
+});
+
+export type EmailInviteInfoSessionData = z.infer;
+
+// Create the session storage instance
+// Note: We don't set a default maxAge here; it will be set dynamically
+// based on the email invite's expiration when committing the session.
+const { commitSession, getSession, destroySession } =
+ createCookieSessionStorage({
+ cookie: {
+ httpOnly: true,
+ name: EMAIL_INVITE_INFO_SESSION_NAME,
+ path: "/",
+ sameSite: "lax",
+ secrets: [process.env.COOKIE_SECRET],
+ secure: process.env.NODE_ENV === "production",
+ },
+ });
+
+export type CreateEmailInviteInfoCookieParams = EmailInviteInfoSessionData & {
+ expiresAt: OrganizationEmailInviteLink["expiresAt"];
+};
+
+/**
+ * Creates a cookie header for the email invite info session.
+ *
+ * @param emailInviteInfo - The email invite data to store in the session.
+ * @returns A `Headers` object with the 'Set-Cookie' header necessary
+ * to persist the email invite info session.
+ */
+export async function createEmailInviteInfoCookie(
+ emailInviteInfo: CreateEmailInviteInfoCookieParams,
+) {
+ const session = await getSession();
+ const now = Date.now();
+ const expiresAtTime = emailInviteInfo.expiresAt.getTime();
+
+ if (expiresAtTime <= now) {
+ // This shouldn't happen if using retrieveActiveEmailInviteFromDatabaseByToken,
+ // but it's good defensive programming.
+ console.warn(
+ `Attempted to create email invite session cookie for already expired invite with token: ${emailInviteInfo.emailInviteToken}`,
+ );
+ // Return a header that immediately expires the cookie if it existed
+ const cookieHeader = await destroySession(session);
+ return cookieHeader;
+ }
+
+ // Calculate remaining time in seconds
+ const maxAgeInSeconds = Math.floor((expiresAtTime - now) / 1000);
+ session.set(EMAIL_INVITE_TOKEN_KEY, emailInviteInfo.emailInviteToken);
+ return await commitSession(session, {
+ maxAge: maxAgeInSeconds,
+ });
+}
+
+/**
+ * Creates HTTP headers containing a 'Set-Cookie' directive to store
+ * email invite information in the user's session.
+ *
+ * @param emailInviteInfo - The email invite data to store in the session.
+ * @returns A `Headers` object with the 'Set-Cookie' header necessary
+ * to persist the email invite info session.
+ */
+export async function createEmailInviteInfoHeaders(
+ emailInviteInfo: CreateEmailInviteInfoCookieParams,
+) {
+ return new Headers({
+ "Set-Cookie": await createEmailInviteInfoCookie(emailInviteInfo),
+ });
+}
+
+/**
+ * Retrieves and validates email invite information from the session cookie
+ * present in the incoming request.
+ *
+ * @param request - The incoming `Request` object, potentially containing the
+ * email invite info session cookie.
+ * @returns A promise that resolves to the validated `EmailInviteInfoSessionData`
+ * if found and valid, otherwise resolves to `undefined`.
+ */
+export async function getEmailInviteInfoFromSession(
+ request: Request,
+): Promise {
+ const session = await getSession(request.headers.get("Cookie"));
+ const emailInviteToken = session.get(EMAIL_INVITE_TOKEN_KEY);
+
+ // Attempt to parse the retrieved data against the schema
+ const result = emailInviteSchema.safeParse({ emailInviteToken });
+
+ // Return the parsed data if successful, otherwise undefined
+ return result.success ? result.data : undefined;
+}
+
+/**
+ * Creates HTTP headers containing a 'Set-Cookie' directive to destroy
+ * the email invite information session cookie.
+ *
+ * @param request - The incoming `Request` object used to retrieve the current
+ * session cookie details for destruction.
+ * @returns A `Headers` object with the 'Set-Cookie' header necessary
+ * to remove the email invite info session cookie.
+ */
+export async function destroyEmailInviteInfoSession(
+ request: Request,
+): Promise {
+ const session = await getSession(request.headers.get("Cookie"));
+ return new Headers({ "Set-Cookie": await destroySession(session) });
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-action.server.ts b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-action.server.ts
new file mode 100644
index 0000000..5c6f963
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-action.server.ts
@@ -0,0 +1,157 @@
+import { href } from "react-router";
+import { z } from "zod";
+
+import { acceptInviteLink } from "../organizations-helpers.server";
+import { retrieveActiveInviteLinkFromDatabaseByToken } from "../organizations-invite-link-model.server";
+import { ACCEPT_INVITE_LINK_INTENT } from "./accept-invite-link-constants";
+import { getInviteLinkToken } from "./accept-invite-link-helpers.server";
+import { createInviteLinkInfoHeaders } from "./accept-invite-link-session.server";
+import type { Route } from ".react-router/types/app/routes/organizations_+/+types/invite-link";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { requireSupabaseUserExists } from "~/features/user-accounts/user-accounts-helpers.server";
+import { createSupabaseServerClient } from "~/features/user-authentication/supabase.server";
+import { combineHeaders } from "~/utils/combine-headers.server";
+import { getErrorMessage } from "~/utils/get-error-message";
+import { getIsDataWithResponseInit } from "~/utils/get-is-data-with-response-init.server";
+import { badRequest } from "~/utils/http-responses.server";
+import { createToastHeaders, redirectWithToast } from "~/utils/toast.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+const acceptInviteLinkSchema = z.object({
+ intent: z.literal(ACCEPT_INVITE_LINK_INTENT),
+});
+
+export async function acceptInviteLinkAction({
+ request,
+ context,
+}: Route.ActionArgs) {
+ try {
+ const i18n = getInstance(context);
+ const result = await validateFormData(request, acceptInviteLinkSchema);
+ if (!result.success) return result.response;
+
+ const data = result.data;
+
+ switch (data.intent) {
+ case ACCEPT_INVITE_LINK_INTENT: {
+ const { supabase, headers } = createSupabaseServerClient({ request });
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ const token = getInviteLinkToken(request);
+ const link = await retrieveActiveInviteLinkFromDatabaseByToken(token);
+
+ if (!link) {
+ const toastHeaders = await createToastHeaders({
+ description: i18n.t(
+ "organizations:acceptInviteLink.inviteLinkInvalidToastDescription",
+ ),
+ title: i18n.t(
+ "organizations:acceptInviteLink.inviteLinkInvalidToastTitle",
+ ),
+ type: "error",
+ });
+
+ return badRequest(
+ { error: "Invalid token" },
+ { headers: combineHeaders(headers, toastHeaders) },
+ );
+ }
+
+ if (user) {
+ const userAccount = await requireSupabaseUserExists(request, user.id);
+
+ try {
+ await acceptInviteLink({
+ i18n,
+ inviteLinkId: link.id,
+ inviteLinkToken: link.token,
+ organizationId: link.organization.id,
+ request,
+ userAccountId: userAccount.id,
+ });
+
+ return redirectWithToast(
+ href("/organizations/:organizationSlug/dashboard", {
+ organizationSlug: link.organization.slug,
+ }),
+ {
+ description: i18n.t(
+ "organizations:acceptInviteLink.joinSuccessToastDescription",
+ {
+ organizationName: link.organization.name,
+ },
+ ),
+ title: i18n.t(
+ "organizations:acceptInviteLink.joinSuccessToastTitle",
+ ),
+ type: "success",
+ },
+ { headers },
+ );
+ } catch (error) {
+ const message = getErrorMessage(error);
+
+ if (
+ message.includes(
+ "Unique constraint failed on the fields: (`memberId`,`organizationId`)",
+ ) ||
+ message.includes(
+ "Unique constraint failed on the fields: (`userId`,`organizationId`)",
+ ) ||
+ message.includes(
+ 'Unique constraint failed on the fields: (`"userId"',
+ )
+ ) {
+ return await redirectWithToast(
+ href("/organizations/:organizationSlug/dashboard", {
+ organizationSlug: link.organization.slug,
+ }),
+ {
+ description: i18n.t(
+ "organizations:acceptInviteLink.alreadyMemberToastDescription",
+ {
+ organizationName: link.organization.name,
+ },
+ ),
+ title: i18n.t(
+ "organizations:acceptInviteLink.alreadyMemberToastTitle",
+ ),
+ type: "info",
+ },
+ { headers },
+ );
+ }
+
+ throw error;
+ }
+ }
+
+ const inviteLinkInfo = await createInviteLinkInfoHeaders({
+ expiresAt: link.expiresAt,
+ inviteLinkToken: link.token,
+ });
+ return redirectWithToast(
+ href("/register"),
+ {
+ description: i18n.t(
+ "organizations:acceptInviteLink.inviteLinkValidToastDescription",
+ ),
+ title: i18n.t(
+ "organizations:acceptInviteLink.inviteLinkValidToastTitle",
+ ),
+ type: "info",
+ },
+ { headers: combineHeaders(headers, inviteLinkInfo) },
+ );
+ }
+ }
+ } catch (error) {
+ if (getIsDataWithResponseInit(error)) {
+ return error;
+ }
+
+ throw error;
+ }
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-constants.ts b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-constants.ts
new file mode 100644
index 0000000..86df412
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-constants.ts
@@ -0,0 +1,2 @@
+export const ACCEPT_INVITE_LINK_INTENT = "acceptInviteLink";
+export const INVITE_LINK_INFO_SESSION_NAME = "__invite_link_info";
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-helpers.server.test.ts b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-helpers.server.test.ts
new file mode 100644
index 0000000..7b456a8
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-helpers.server.test.ts
@@ -0,0 +1,93 @@
+import { faker } from "@faker-js/faker";
+import { describe, expect, test } from "vitest";
+
+import {
+ createPopulatedOrganization,
+ createPopulatedOrganizationInviteLink,
+} from "../organizations-factories.server";
+import {
+ getInviteLinkToken,
+ throwIfInviteLinkIsExpired,
+} from "./accept-invite-link-helpers.server";
+import { createPopulatedUserAccount } from "~/features/user-accounts/user-accounts-factories.server";
+import { getIsDataWithResponseInit } from "~/utils/get-is-data-with-response-init.server";
+import { notFound } from "~/utils/http-responses.server";
+
+describe("throwIfInviteLinkIsExpired()", () => {
+ test("given: a valid invite link, should: return the link", () => {
+ const link = {
+ creator: {
+ id: createPopulatedUserAccount().id,
+ name: createPopulatedUserAccount().name,
+ },
+ expiresAt: createPopulatedOrganizationInviteLink().expiresAt,
+ id: createPopulatedOrganizationInviteLink().id,
+ organization: {
+ id: createPopulatedOrganization().name,
+ name: createPopulatedOrganization().name,
+ },
+ };
+
+ const actual = throwIfInviteLinkIsExpired(link);
+ const expected = link;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an expired invite link, should: throw a 404 error", () => {
+ expect.assertions(1);
+
+ const link = {
+ creator: {
+ id: createPopulatedUserAccount().id,
+ name: createPopulatedUserAccount().name,
+ },
+ expiresAt: faker.date.recent(),
+ id: createPopulatedOrganizationInviteLink().id,
+ organization: {
+ id: createPopulatedOrganization().name,
+ name: createPopulatedOrganization().name,
+ },
+ };
+
+ try {
+ throwIfInviteLinkIsExpired(link);
+ } catch (error) {
+ if (getIsDataWithResponseInit(error)) {
+ expect(error).toEqual(notFound());
+ }
+ }
+ });
+});
+
+describe("getInviteLinkToken()", () => {
+ test("given a request with token query param: returns the token", () => {
+ const token = createPopulatedOrganizationInviteLink().token;
+ const request = new Request(`http://example.com/?token=${token}`);
+
+ const actual = getInviteLinkToken(request);
+
+ expect(actual).toEqual(token);
+ });
+
+ test("given a request without a token query param: returns an empty string", () => {
+ const request = new Request("http://example.com");
+
+ const actual = getInviteLinkToken(request);
+ const expected = "";
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given a request with multiple token query params: returns the first token", () => {
+ const token1 = createPopulatedOrganizationInviteLink().token;
+ const token2 = createPopulatedOrganizationInviteLink().token;
+ const request = new Request(
+ `http://example.com/?token=${token1}&token=${token2}`,
+ );
+
+ const actual = getInviteLinkToken(request);
+
+ expect(actual).toEqual(token1);
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-helpers.server.ts b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-helpers.server.ts
new file mode 100644
index 0000000..a85cbd4
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-helpers.server.ts
@@ -0,0 +1,116 @@
+import {
+ retrieveActiveOrganizationInviteLinkFromDatabaseByToken,
+ retrieveCreatorAndOrganizationForActiveLinkFromDatabaseByToken,
+} from "../organizations-invite-link-model.server";
+import {
+ destroyInviteLinkInfoSession,
+ getInviteLinkInfoFromSession,
+} from "./accept-invite-link-session.server";
+import { asyncPipe } from "~/utils/async-pipe.server";
+import { getSearchParameterFromRequest } from "~/utils/get-search-parameter-from-request.server";
+import { notFound } from "~/utils/http-responses.server";
+import { throwIfEntityIsMissing } from "~/utils/throw-if-entity-is-missing.server";
+
+/**
+ * Checks if the provided invite link has expired.
+ *
+ * @param link - The invite link object retrieved from the database.
+ * @throws A '403 Forbidden' HTTP response if the invite link has expired.
+ */
+export const throwIfInviteLinkIsExpired = (
+ link: NonNullable<
+ Awaited<
+ ReturnType<
+ typeof retrieveCreatorAndOrganizationForActiveLinkFromDatabaseByToken
+ >
+ >
+ >,
+) => {
+ if (new Date() > link.expiresAt) {
+ throw notFound();
+ }
+
+ return link;
+};
+
+/**
+ * Validates and returns the organization invite link identified by the provided
+ * token.
+ *
+ * @param token - The unique token identifying the invite link.
+ * @returns A Promise that resolves with the invite link object if it exists and
+ * has not expired.
+ * @throws A '404 Not Found' error if the invite link does not exist in the
+ * database or is expired.
+ */
+export const requireInviteLinkByTokenExists = asyncPipe(
+ retrieveCreatorAndOrganizationForActiveLinkFromDatabaseByToken,
+ throwIfEntityIsMissing,
+ throwIfInviteLinkIsExpired,
+);
+
+/**
+ * Ensures that an invite link identified by the provided token exists in the
+ * database.
+ *
+ * @param token - The unique token for the invite link.
+ * @returns An object containing creator and organization data associated with
+ * the token.
+ * @throws A '404 not found' HTTP response if the invite link identified by the
+ * token doesn't exist.
+ */
+export async function requireCreatorAndOrganizationByTokenExists(
+ token: string,
+) {
+ const inviteLink = await requireInviteLinkByTokenExists(token);
+ return {
+ inviterName: inviteLink.creator?.name ?? "Deactivated User",
+ organizationName: inviteLink.organization.name,
+ };
+}
+
+/**
+ * Extracts the token for an invite link from the search parameters.
+ *
+ * @param Request - The request to get the token from.
+ * @returns The token from the request params, or null.
+ */
+export const getInviteLinkToken = getSearchParameterFromRequest("token");
+
+/**
+ * Retrieves the invite link information from the session and validates it.
+ * If the invite link is expired or deactivated, it will be destroyed from the
+ * session and the headers will be returned.
+ *
+ * @param request - The request to get the invite link information from.
+ * @returns An object containing the headers and the invite link information.
+ */
+export async function getValidInviteLinkInfo(request: Request) {
+ const tokenInfo = await getInviteLinkInfoFromSession(request);
+
+ if (tokenInfo) {
+ const inviteLink =
+ await retrieveActiveOrganizationInviteLinkFromDatabaseByToken(
+ tokenInfo.inviteLinkToken,
+ );
+
+ if (inviteLink) {
+ return {
+ headers: new Headers(),
+ inviteLinkInfo: {
+ creatorName: inviteLink.creator?.name ?? "Deactivated User",
+ inviteLinkId: inviteLink.id,
+ inviteLinkToken: inviteLink.token,
+ organizationId: inviteLink.organization.id,
+ organizationName: inviteLink.organization.name,
+ organizationSlug: inviteLink.organization.slug,
+ },
+ };
+ }
+
+ const headers = await destroyInviteLinkInfoSession(request);
+ return { headers, inviteLinkInfo: undefined };
+ }
+
+ return { headers: new Headers(), inviteLinkInfo: undefined };
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-page.test.tsx b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-page.test.tsx
new file mode 100644
index 0000000..6f27a84
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-page.test.tsx
@@ -0,0 +1,52 @@
+import { describe, expect, test } from "vitest";
+
+import { createPopulatedOrganization } from "../organizations-factories.server";
+import type { AcceptInviteLinkPageProps } from "./accept-invite-link-page";
+import { AcceptInviteLinkPage } from "./accept-invite-link-page";
+import { createPopulatedUserAccount } from "~/features/user-accounts/user-accounts-factories.server";
+import { createRoutesStub, render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createProps: Factory = ({
+ inviterName = createPopulatedUserAccount().name,
+ organizationName = createPopulatedOrganization().name,
+ ...props
+} = {}) => ({ inviterName, organizationName, ...props });
+
+describe("AcceptInviteLinkPage component", () => {
+ test("given: an organization name and an inviter name, should: render a greeting and a button to accept the invite", () => {
+ const props = createProps();
+ const path = `/organizations/invite-link`;
+ const RemixStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // It renders a greeting.
+ expect(
+ screen.getByText(/welcome to react router saas template/i),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ new RegExp(
+ `${props.inviterName} invites you to join ${props.organizationName}`,
+ "i",
+ ),
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /click the button below to sign up. by using this link you will automatically join the correct organization./i,
+ ),
+ ).toBeInTheDocument();
+
+ // It renders a button to accept the invite.
+ expect(
+ screen.getByRole("button", { name: /accept invite/i }),
+ ).toHaveAttribute("type", "submit");
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-page.tsx b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-page.tsx
new file mode 100644
index 0000000..8ccdbf4
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-page.tsx
@@ -0,0 +1,95 @@
+import { useTranslation } from "react-i18next";
+import { Form, useNavigation } from "react-router";
+
+import { ACCEPT_INVITE_LINK_INTENT } from "./accept-invite-link-constants";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import { Spinner } from "~/components/ui/spinner";
+import type { Organization, UserAccount } from "~/generated/browser";
+
+export type AcceptInviteLinkPageProps = {
+ inviterName: UserAccount["name"];
+ organizationName: Organization["name"];
+};
+
+export function AcceptInviteLinkPage({
+ inviterName,
+ organizationName,
+}: AcceptInviteLinkPageProps) {
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "acceptInviteLink",
+ });
+ const { t: tCommon } = useTranslation("translation");
+ const navigation = useNavigation();
+ const isAcceptingInvite =
+ navigation.formData?.get("intent") === ACCEPT_INVITE_LINK_INTENT;
+
+ return (
+
+
+
+
+
+
+ {t("welcomeToAppName", { appName: tCommon("appName") })}
+
+
+
+
+
+ {t("inviteYouToJoin", { inviterName, organizationName })}
+
+
+
+ {t("acceptInviteInstructions")}
+
+
+
+
+ {isAcceptingInvite ? (
+ <>
+
+ {t("acceptingInvite")}
+ >
+ ) : (
+ t("acceptInvite")
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-session.server.spec.ts b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-session.server.spec.ts
new file mode 100644
index 0000000..cc88b38
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-session.server.spec.ts
@@ -0,0 +1,163 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: Test code */
+import { addSeconds, subSeconds } from "date-fns";
+import { afterAll, describe, expect, test, vi } from "vitest";
+
+import { createPopulatedOrganizationInviteLink } from "../organizations-factories.server";
+import type { InviteLinkInfoSessionData } from "./accept-invite-link-session.server";
+import {
+ createInviteLinkInfoHeaders,
+ destroyInviteLinkInfoSession,
+ getInviteLinkInfoFromSession,
+} from "./accept-invite-link-session.server";
+
+// Helper function adapted from toast.server.spec.ts
+const mapHeaders = (headers: Headers): Headers | undefined => {
+ const cookie = headers.get("Set-Cookie");
+ return cookie ? new Headers({ Cookie: cookie }) : undefined;
+};
+
+// Test data factory
+const createTestInviteLinkInfo = (
+ overrides: Partial<
+ InviteLinkInfoSessionData & {
+ expiresInSeconds?: number;
+ isExpired?: boolean;
+ }
+ > = {},
+): InviteLinkInfoSessionData & { expiresAt: Date } => {
+ const expiresInSeconds = overrides.expiresInSeconds ?? 3600; // Default 1 hour
+ const expiresAt = overrides.isExpired
+ ? subSeconds(new Date(), 10) // 10 seconds in the past
+ : addSeconds(new Date(), expiresInSeconds);
+
+ return {
+ expiresAt: expiresAt,
+ inviteLinkToken:
+ overrides.inviteLinkToken ??
+ createPopulatedOrganizationInviteLink().token,
+ };
+};
+
+describe("Invite Link Info Session", () => {
+ // Mock console.warn to prevent noise during expired link tests
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
+ // Do nothing
+ });
+
+ // Restore mocks after all tests in this describe block
+ afterAll(() => {
+ warnSpy.mockRestore();
+ });
+
+ describe("createInviteLinkInfoHeaders() & getInviteLinkInfoFromSession()", () => {
+ test("given: valid invite link data, should: create headers with Set-Cookie containing the data", async () => {
+ const inviteInfo = createTestInviteLinkInfo({ expiresInSeconds: 3600 });
+
+ const headers = await createInviteLinkInfoHeaders(inviteInfo);
+ const cookieHeader = headers.get("Set-Cookie");
+
+ expect(cookieHeader).toBeTruthy();
+ expect(cookieHeader).toContain("__invite_link_info=");
+ expect(cookieHeader).toContain("HttpOnly");
+ expect(cookieHeader).toContain("Path=/");
+ expect(cookieHeader).toContain("SameSite=Lax");
+ // Approximate check for Max-Age
+ const maxAgeMatch = /Max-Age=(\d+);/.exec(cookieHeader!);
+ expect(maxAgeMatch).not.toBeNull();
+ const maxAge = Number.parseInt(maxAgeMatch![1]!, 10);
+ expect(maxAge).toBeGreaterThan(3590); // ~1 hour minus slight delay
+ expect(maxAge).toBeLessThanOrEqual(3600); // 1 hour
+
+ // Now test retrieval
+ const request = new Request("http://example.com", {
+ headers: mapHeaders(headers),
+ });
+ const actual = await getInviteLinkInfoFromSession(request);
+ const expected: InviteLinkInfoSessionData = {
+ inviteLinkToken: inviteInfo.inviteLinkToken,
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a request with no session cookie, should: return undefined", async () => {
+ const request = new Request("http://example.com");
+ const actual = await getInviteLinkInfoFromSession(request);
+ const expected: InviteLinkInfoSessionData | undefined = undefined;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: invite link data with expiration in the past, should: return destroying headers and read undefined", async () => {
+ const expiredInviteInfo = createTestInviteLinkInfo({ isExpired: true });
+
+ const headers = await createInviteLinkInfoHeaders(expiredInviteInfo);
+ const cookieHeader = headers.get("Set-Cookie");
+
+ // Expect a header that expires the cookie immediately
+ expect(cookieHeader).toBeTruthy();
+ expect(cookieHeader).toContain("__invite_link_info=");
+ expect(cookieHeader).toMatch(/Max-Age=0|Expires=.*1970/);
+ expect(warnSpy).toHaveBeenCalledExactlyOnceWith(
+ expect.stringContaining(
+ `Attempted to create invite link session cookie for already expired link with token: ${expiredInviteInfo.inviteLinkToken}`,
+ ),
+ );
+
+ // Attempting to read from this "destroyed" cookie should yield undefined
+ const request = new Request("http://example.com", {
+ headers: mapHeaders(headers),
+ });
+ const actual = await getInviteLinkInfoFromSession(request);
+ const expected: InviteLinkInfoSessionData | undefined = undefined;
+
+ expect(actual).toEqual(expected);
+ });
+ });
+
+ describe("destroyInviteLinkInfoSession()", () => {
+ test("given: a request with an existing session, should: return headers that expire the cookie", async () => {
+ // 1. Create a valid session cookie
+ const inviteInfo = createTestInviteLinkInfo();
+ const createHeaders = await createInviteLinkInfoHeaders(inviteInfo);
+ const requestWithCookie = new Request("http://example.com", {
+ headers: mapHeaders(createHeaders),
+ });
+
+ // 2. Call destroy session
+ const destroyHeaders =
+ await destroyInviteLinkInfoSession(requestWithCookie);
+ const destroyCookieHeader = destroyHeaders.get("Set-Cookie");
+
+ // 3. Assert the destroy header is correct
+ expect(destroyCookieHeader).toBeTruthy();
+ expect(destroyCookieHeader).toContain("__invite_link_info=;");
+ expect(destroyCookieHeader).toContain("Path=/");
+ expect(destroyCookieHeader).toMatch(/Max-Age=0|Expires=.*1970/); // Check for immediate expiry
+ expect(destroyCookieHeader).toContain("HttpOnly");
+ expect(destroyCookieHeader).toContain("SameSite=Lax");
+
+ // 4. Verify reading after destruction yields undefineds
+ const requestAfterDestroy = new Request("http://example.com", {
+ headers: mapHeaders(destroyHeaders), // Use the destroy header
+ });
+ const actual = await getInviteLinkInfoFromSession(requestAfterDestroy);
+ const expected: InviteLinkInfoSessionData | undefined = undefined;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a request with no session, should: still return expiring headers", async () => {
+ const requestWithoutCookie = new Request("http://example.com");
+
+ const destroyHeaders =
+ await destroyInviteLinkInfoSession(requestWithoutCookie);
+ const destroyCookieHeader = destroyHeaders.get("Set-Cookie");
+
+ // Assert the destroy header is correct, even if no cookie existed
+ expect(destroyCookieHeader).toBeTruthy();
+ expect(destroyCookieHeader).toContain("__invite_link_info=;");
+ expect(destroyCookieHeader).toMatch(/Max-Age=0|Expires=.*1970/);
+ });
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-session.server.ts b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-session.server.ts
new file mode 100644
index 0000000..8c1273a
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/accept-invite-link-session.server.ts
@@ -0,0 +1,120 @@
+import { createCookieSessionStorage } from "react-router";
+import { z } from "zod";
+
+import { INVITE_LINK_INFO_SESSION_NAME } from "./accept-invite-link-constants";
+import type { OrganizationInviteLink } from "~/generated/client";
+
+// Define keys for the session data
+const INVITE_LINK_TOKEN_KEY = "inviteLinkToken"; // This is the token NOT the id
+
+const inviteLinkSchema = z.object({
+ [INVITE_LINK_TOKEN_KEY]: z.string(),
+});
+
+export type InviteLinkInfoSessionData = z.infer;
+
+// Create the session storage instance
+// Note: We don't set a default maxAge here; it will be set dynamically
+// based on the invite link's expiration when committing the session.
+const { commitSession, getSession, destroySession } =
+ createCookieSessionStorage({
+ cookie: {
+ httpOnly: true,
+ name: INVITE_LINK_INFO_SESSION_NAME,
+ path: "/",
+ sameSite: "lax",
+ secrets: [process.env.COOKIE_SECRET],
+ secure: process.env.NODE_ENV === "production",
+ },
+ });
+
+export type CreateInviteLinkInfoCookieParams = InviteLinkInfoSessionData & {
+ expiresAt: OrganizationInviteLink["expiresAt"];
+};
+
+/**
+ * Creates a cookie header for the invite link info session.
+ *
+ * @param inviteLinkInfo - The invite link data to store in the session.
+ * @returns A `Headers` object with the 'Set-Cookie' header necessary
+ * to persist the invite link info session.
+ */
+export async function createInviteLinkInfoCookie(
+ inviteLinkInfo: CreateInviteLinkInfoCookieParams,
+) {
+ const session = await getSession();
+ const now = Date.now();
+ const expiresAtTime = inviteLinkInfo.expiresAt.getTime();
+
+ if (expiresAtTime <= now) {
+ // This shouldn't happen if using retrieveActiveInviteLinkFromDatabaseByToken,
+ // but it's good defensive programming.
+ console.warn(
+ `Attempted to create invite link session cookie for already expired link with token: ${inviteLinkInfo.inviteLinkToken}`,
+ );
+ // Return a header that immediately expires the cookie if it existed
+ const cookieHeader = await destroySession(session);
+ return cookieHeader;
+ }
+
+ // Calculate remaining time in seconds
+ const maxAgeInSeconds = Math.floor((expiresAtTime - now) / 1000);
+ session.set(INVITE_LINK_TOKEN_KEY, inviteLinkInfo.inviteLinkToken);
+ return await commitSession(session, {
+ maxAge: maxAgeInSeconds,
+ });
+}
+
+/**
+ * Creates HTTP headers containing a 'Set-Cookie' directive to store
+ * invite link information in the user's session.
+ *
+ * @param inviteLinkInfo - The invite link data to store in the session.
+ * @returns A `Headers` object with the 'Set-Cookie' header necessary
+ * to persist the invite link info session.
+ */
+export async function createInviteLinkInfoHeaders(
+ inviteLinkInfo: CreateInviteLinkInfoCookieParams,
+) {
+ return new Headers({
+ "Set-Cookie": await createInviteLinkInfoCookie(inviteLinkInfo),
+ });
+}
+
+/**
+ * Retrieves and validates invite link information from the session cookie
+ * present in the incoming request.
+ *
+ * @param request - The incoming `Request` object, potentially containing the
+ * invite link info session cookie.
+ * @returns A promise that resolves to the validated `InviteLinkInfoSessionData`
+ * if found and valid, otherwise resolves to `undefined`.
+ */
+export async function getInviteLinkInfoFromSession(
+ request: Request,
+): Promise {
+ const session = await getSession(request.headers.get("Cookie"));
+ const inviteLinkToken = session.get(INVITE_LINK_TOKEN_KEY);
+
+ // Attempt to parse the retrieved data against the schema
+ const result = inviteLinkSchema.safeParse({ inviteLinkToken });
+
+ // Return the parsed data if successful, otherwise undefined
+ return result.success ? result.data : undefined;
+}
+
+/**
+ * Creates HTTP headers containing a 'Set-Cookie' directive to destroy
+ * the invite link information session cookie.
+ *
+ * @param request - The incoming `Request` object used to retrieve the current
+ * session cookie details for destruction.
+ * @returns A `Headers` object with the 'Set-Cookie' header necessary
+ * to remove the invite link info session cookie.
+ */
+export async function destroyInviteLinkInfoSession(
+ request: Request,
+): Promise {
+ const session = await getSession(request.headers.get("Cookie"));
+ return new Headers({ "Set-Cookie": await destroySession(session) });
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/accept-invite-link/invite-link-use-model.server.ts b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/invite-link-use-model.server.ts
new file mode 100644
index 0000000..1ff85f2
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/accept-invite-link/invite-link-use-model.server.ts
@@ -0,0 +1,66 @@
+import type {
+ InviteLinkUse,
+ OrganizationInviteLink,
+ UserAccount,
+} from "~/generated/client";
+import { prisma } from "~/utils/database.server";
+
+export type PartialinviteLinkUseParameters = Pick<
+ Parameters[0]["data"],
+ "id"
+>;
+
+/* CREATE */
+
+/**
+ * Saves a new Invite Link Uses to the database.
+ *
+ * @param Invite Link Uses - Parameters of the Invite Link Uses that should be created.
+ * @returns The newly created Invite Link Uses.
+ */
+export function saveInviteLinkUseToDatabase(
+ inviteLinkUse: PartialinviteLinkUseParameters & {
+ inviteLinkId: OrganizationInviteLink["id"];
+ userId: UserAccount["id"];
+ },
+) {
+ return prisma.inviteLinkUse.create({ data: inviteLinkUse });
+}
+
+/* READ */
+
+/**
+ * Retrieves a Invite Link Uses record from the database based on its id.
+ *
+ * @param id - The id of the Invite Link Uses to get.
+ * @returns The Invite Link Uses with a given id or null if it wasn't found.
+ */
+export function retrieveInviteLinkUseFromDatabaseById(id: InviteLinkUse["id"]) {
+ return prisma.inviteLinkUse.findUnique({ where: { id } });
+}
+
+export function retrieveInviteLinkUseFromDatabaseByUserIdAndLinkId({
+ inviteLinkId,
+ userId,
+}: {
+ inviteLinkId: OrganizationInviteLink["id"];
+ userId: UserAccount["id"];
+}) {
+ return prisma.inviteLinkUse.findUnique({
+ where: { inviteLinkId_userId: { inviteLinkId, userId } },
+ });
+}
+
+/* DELETE */
+
+/**
+ * Removes a Invite Link Uses from the database.
+ *
+ * @param id - The id of the Invite Link Uses you want to delete.
+ * @returns The Invite Link Uses that was deleted.
+ */
+export async function deleteInviteLinkUseFromDatabaseById(
+ id: InviteLinkUse["id"],
+) {
+ return prisma.inviteLinkUse.delete({ where: { id } });
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-action.server.ts b/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-action.server.ts
new file mode 100644
index 0000000..bab4ca4
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-action.server.ts
@@ -0,0 +1,55 @@
+import { coerceFormValue } from "@conform-to/zod/v4/future";
+import { createId } from "@paralleldrive/cuid2";
+import { redirect } from "react-router";
+
+import { createOrganizationFormSchema } from "./create-organization-schemas";
+import type { Route } from ".react-router/types/app/routes/_authenticated-routes+/organizations_+/+types/new";
+import { uploadOrganizationLogo } from "~/features/organizations/organizations-helpers.server";
+import { saveOrganizationWithOwnerToDatabase } from "~/features/organizations/organizations-model.server";
+import { requireAuthenticatedUserExists } from "~/features/user-accounts/user-accounts-helpers.server";
+import { authContext } from "~/features/user-authentication/user-authentication-middleware.server";
+import { slugify } from "~/utils/slugify.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+export async function createOrganizationAction({
+ context,
+ request,
+}: Route.ActionArgs) {
+ const { user, headers } = await requireAuthenticatedUserExists({
+ context,
+ request,
+ });
+ const { supabase } = context.get(authContext);
+ const result = await validateFormData(
+ request,
+ coerceFormValue(createOrganizationFormSchema),
+ {
+ maxFileSize: 1_000_000, // 1MB
+ },
+ );
+
+ if (!result.success) {
+ return result.response;
+ }
+
+ const organizationId = createId();
+ const imageUrl = result.data.logo
+ ? await uploadOrganizationLogo({
+ file: result.data.logo,
+ organizationId,
+ supabase,
+ })
+ : "";
+
+ const organization = await saveOrganizationWithOwnerToDatabase({
+ organization: {
+ id: organizationId,
+ imageUrl,
+ name: result.data.name,
+ slug: slugify(result.data.name),
+ },
+ userId: user.id,
+ });
+
+ return redirect(`/organizations/${organization.slug}`, { headers });
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-constants.ts b/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-constants.ts
new file mode 100644
index 0000000..e262985
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-constants.ts
@@ -0,0 +1 @@
+export const CREATE_ORGANIZATION_INTENT = "createOrganization";
diff --git a/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-form-card.test.tsx b/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-form-card.test.tsx
new file mode 100644
index 0000000..71fbefa
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-form-card.test.tsx
@@ -0,0 +1,76 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, test } from "vitest";
+
+import type { CreateOrganizationFormCardProps } from "./create-organization-form-card";
+import { CreateOrganizationFormCard } from "./create-organization-form-card";
+import { createRoutesStub } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createProps: Factory = ({
+ isCreatingOrganization = false,
+ lastResult,
+} = {}) => ({ isCreatingOrganization, lastResult });
+
+describe("CreateOrganizationFormCard Component", () => {
+ test("given: component renders with default props, should: render a card with name input, logo upload, and submit button", () => {
+ const path = "/organizations/new";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify card title and description are displayed
+ expect(screen.getByText(/create a new organization/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(/tell us about your organization/i),
+ ).toBeInTheDocument();
+
+ // Verify form elements are present
+ expect(
+ screen.getByPlaceholderText(/organization name/i),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/^logo$/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /create organization/i }),
+ ).toHaveAttribute("type", "submit");
+ });
+
+ test("given: isCreatingOrganization is true, should: disable form and show loading state", () => {
+ const props = createProps({ isCreatingOrganization: true });
+ const path = "/organizations/new";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify form elements are disabled
+ expect(screen.getByPlaceholderText(/organization name/i)).toBeDisabled();
+ expect(screen.getByRole("button")).toBeDisabled();
+
+ // Verify loading indicator is shown
+ expect(screen.getByText(/saving/i)).toBeInTheDocument();
+ });
+
+ // Note: Error validation is covered by E2E tests.
+ // Unit testing error states with Conform requires complex setup that adds little value
+ // compared to the comprehensive E2E tests.
+
+ test("given: component renders, should: display terms and privacy links", () => {
+ const path = "/organizations/new";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify terms and privacy links are present
+ expect(
+ screen.getByRole("link", { name: /terms of service/i }),
+ ).toHaveAttribute("href", "/terms-of-service");
+ expect(
+ screen.getByRole("link", { name: /privacy policy/i }),
+ ).toHaveAttribute("href", "/privacy-policy");
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-form-card.tsx b/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-form-card.tsx
new file mode 100644
index 0000000..5df760c
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-form-card.tsx
@@ -0,0 +1,175 @@
+import type { SubmissionResult } from "@conform-to/react/future";
+import { useForm } from "@conform-to/react/future";
+import { coerceFormValue } from "@conform-to/zod/v4/future";
+import { IconBuilding } from "@tabler/icons-react";
+import { Trans, useTranslation } from "react-i18next";
+import { Form, href, Link } from "react-router";
+
+import { CREATE_ORGANIZATION_INTENT } from "./create-organization-constants";
+import { createOrganizationFormSchema } from "./create-organization-schemas";
+import {
+ AvatarUpload,
+ AvatarUploadDescription,
+ AvatarUploadInput,
+ AvatarUploadPreviewImage,
+} from "~/components/avatar-upload";
+import { Avatar, AvatarFallback } from "~/components/ui/avatar";
+import { Button } from "~/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import {
+ Field,
+ FieldDescription,
+ FieldError,
+ FieldLabel,
+ FieldSet,
+} from "~/components/ui/field";
+import { Input } from "~/components/ui/input";
+import { Spinner } from "~/components/ui/spinner";
+
+const ONE_MB = 1_000_000;
+
+export type CreateOrganizationFormCardProps = {
+ isCreatingOrganization?: boolean;
+ lastResult?: SubmissionResult;
+};
+
+export function CreateOrganizationFormCard({
+ isCreatingOrganization = false,
+ lastResult,
+}: CreateOrganizationFormCardProps) {
+ const { t } = useTranslation("organizations", { keyPrefix: "new.form" });
+ const { form, fields } = useForm(
+ coerceFormValue(createOrganizationFormSchema),
+ {
+ lastResult,
+ },
+ );
+
+ return (
+
+
+
+ {t("cardTitle")}
+ {t("cardDescription")}
+
+
+
+
+
+
+
+ {t("nameLabel")}
+
+
+ {/* @ts-expect-error - TypeScript can't infer this key from keyPrefix */}
+ {t("nameDescription")}
+
+
+
+
+
+
+ {({ error }) => (
+
+
+ {t("logoLabel")}
+
+
+ {/* @ts-expect-error - TypeScript can't infer this key from keyPrefix */}
+ {t("logoDescription")}
+
+
+
+
+
+
+
+
+
+
+
+ {/* @ts-expect-error - TypeScript can't infer this key from keyPrefix */}
+ {t("logoFormats")}
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ {isCreatingOrganization ? (
+ <>
+
+ {t("saving")}
+ >
+ ) : (
+ t("submitButton")
+ )}
+
+
+
+
+
+ ,
+ 2: ,
+ }}
+ i18nKey="new.form.termsAndPrivacy"
+ ns="organizations"
+ />
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-schemas.ts b/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-schemas.ts
new file mode 100644
index 0000000..337f07c
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/create-organization/create-organization-schemas.ts
@@ -0,0 +1,35 @@
+import { z } from "zod";
+
+import { CREATE_ORGANIZATION_INTENT } from "./create-organization-constants";
+
+const MIN_NAME_LENGTH = 3;
+const MAX_NAME_LENGTH = 255;
+const ONE_MB = 1_000_000;
+
+z.config({ jitless: true });
+
+export const createOrganizationFormSchema = z.object({
+ intent: z.literal(CREATE_ORGANIZATION_INTENT),
+ logo: z
+ .file()
+ .max(ONE_MB, { message: "organizations:new.form.logoTooLarge" })
+ .mime(["image/png", "image/jpeg", "image/gif", "image/webp"], {
+ message: "organizations:new.form.logoInvalidType",
+ })
+ .optional(),
+ name: z
+ .string({
+ message: "organizations:new.form.nameMinLength",
+ })
+ .trim()
+ .min(MIN_NAME_LENGTH, {
+ message: "organizations:new.form.nameMinLength",
+ })
+ .max(MAX_NAME_LENGTH, {
+ message: "organizations:new.form.nameMaxLength",
+ }),
+});
+
+export type CreateOrganizationFormSchema = z.infer<
+ typeof createOrganizationFormSchema
+>;
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/app-header.test.tsx b/apps/react-router/saas-template/app/features/organizations/layout/app-header.test.tsx
new file mode 100644
index 0000000..af3acb2
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/app-header.test.tsx
@@ -0,0 +1,135 @@
+import { describe, expect, test } from "vitest";
+
+import type { AppHeaderProps } from "./app-header";
+import { AppHeader } from "./app-header";
+import { SidebarProvider } from "~/components/ui/sidebar";
+import { createRoutesStub, render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createProps: Factory = ({
+ breadcrumbs,
+ notificationsButtonProps = {
+ allNotifications: [],
+ showBadge: false,
+ unreadNotifications: [],
+ },
+} = {}) => ({ breadcrumbs, notificationsButtonProps });
+
+describe("AppHeader Component", () => {
+ test("given: breadcrumbs, should: render header with breadcrumbs and notification button", () => {
+ const breadcrumbs = [
+ { title: "Home", to: "/" },
+ { title: "Dashboard", to: "/dashboard" },
+ { title: "Settings", to: "/settings" },
+ ];
+ const props = createProps({ breadcrumbs });
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ // Verify breadcrumb links are displayed as clickable anchor elements
+ const homeLink = screen.getByRole("link", { name: "Home" });
+ expect(homeLink).toBeInTheDocument();
+ expect(homeLink.tagName).toBe("A");
+
+ const dashboardLink = screen.getByRole("link", { name: "Dashboard" });
+ expect(dashboardLink).toBeInTheDocument();
+ expect(dashboardLink.tagName).toBe("A");
+
+ // Verify last breadcrumb is displayed as h1 heading with page span inside
+ const settingsHeading = screen.getByRole("heading", {
+ level: 1,
+ name: "Settings",
+ });
+ expect(settingsHeading).toBeInTheDocument();
+
+ // Verify the page span inside the h1 has the correct attributes
+ const settingsPage = screen.getByText("Settings");
+ expect(settingsPage).toHaveAttribute("aria-current", "page");
+ expect(settingsPage).toHaveAttribute("aria-disabled", "true");
+ expect(settingsPage.tagName).toBe("SPAN");
+
+ // Verify the notification button is present
+ const notificationButton = screen.getByRole("button", {
+ name: /open notifications/i,
+ });
+ expect(notificationButton).toBeInTheDocument();
+ expect(notificationButton).toHaveClass("size-8");
+ });
+
+ test("given: no breadcrumbs, should: render header without breadcrumbs but with notification button", () => {
+ const props = createProps({ breadcrumbs: undefined });
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ // Verify the breadcrumbs are not displayed
+ expect(
+ screen.queryByRole("navigation", { name: "breadcrumb" }),
+ ).not.toBeInTheDocument();
+
+ // Verify the notification button is still present
+ const notificationButton = screen.getByRole("button", {
+ name: /open notifications/i,
+ });
+ expect(notificationButton).toBeInTheDocument();
+ });
+
+ test("given: empty breadcrumbs array, should: render header without breadcrumbs", () => {
+ const props = createProps({ breadcrumbs: [] });
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ // Verify the breadcrumbs are not displayed
+ expect(
+ screen.queryByRole("navigation", { name: "breadcrumb" }),
+ ).not.toBeInTheDocument();
+ });
+
+ test("given: single breadcrumb, should: render as h1 heading without clickable link", () => {
+ const breadcrumbs = [{ title: "Home", to: "/" }];
+ const props = createProps({ breadcrumbs });
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ // Verify single breadcrumb is displayed as h1 heading
+ const homeHeading = screen.getByRole("heading", { level: 1, name: "Home" });
+ expect(homeHeading).toBeInTheDocument();
+
+ // Verify the page span inside the h1 has the correct attributes
+ const homePage = screen.getByText("Home");
+ expect(homePage).toHaveAttribute("aria-current", "page");
+ expect(homePage).toHaveAttribute("aria-disabled", "true");
+ expect(homePage.tagName).toBe("SPAN");
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/app-header.tsx b/apps/react-router/saas-template/app/features/organizations/layout/app-header.tsx
new file mode 100644
index 0000000..ee75a85
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/app-header.tsx
@@ -0,0 +1,159 @@
+import { Fragment } from "react";
+import { Link } from "react-router";
+
+import {
+ Breadcrumb,
+ BreadcrumbEllipsis,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "~/components/ui/breadcrumb";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "~/components/ui/dropdown-menu";
+import { Separator } from "~/components/ui/separator";
+import { SidebarTrigger } from "~/components/ui/sidebar";
+import { ThemeToggle } from "~/features/color-scheme/theme-toggle";
+import type { NotificationsButtonProps } from "~/features/notifications/notifications-button";
+import { NotificationsButton } from "~/features/notifications/notifications-button";
+import { useMediaQuery } from "~/hooks/use-media-query";
+
+export type AppHeaderProps = {
+ breadcrumbs?: {
+ to: string;
+ title: string;
+ }[];
+ notificationsButtonProps: NotificationsButtonProps;
+};
+
+const MOBILE_MAX_ITEMS = 2;
+const DESKTOP_MAX_ITEMS = 4;
+
+export function AppHeader({
+ breadcrumbs = [],
+ notificationsButtonProps,
+}: AppHeaderProps) {
+ const isMobile = useMediaQuery("(max-width: 767px)");
+
+ // Show ellipsis if:
+ // - On mobile and more than 2 items, OR
+ // - More than 4 items (regardless of screen size)
+ const shouldShowEllipsis =
+ (isMobile && breadcrumbs.length > MOBILE_MAX_ITEMS) ||
+ breadcrumbs.length > DESKTOP_MAX_ITEMS;
+
+ const firstItem = breadcrumbs[0];
+ const lastItem = breadcrumbs[breadcrumbs.length - 1];
+ const middleItems = breadcrumbs.slice(1, -1);
+
+ return (
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/app-sidebar.tsx b/apps/react-router/saas-template/app/features/organizations/layout/app-sidebar.tsx
new file mode 100644
index 0000000..835afea
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/app-sidebar.tsx
@@ -0,0 +1,143 @@
+import {
+ IconChartBar,
+ IconFolder,
+ IconHelp,
+ IconLayoutDashboard,
+ IconSettings,
+ IconFileText,
+} from "@tabler/icons-react";
+import type { ComponentProps } from "react";
+import { useTranslation } from "react-i18next";
+import { href } from "react-router";
+
+import { NavGroup } from "./nav-group";
+import type { NavUserProps } from "./nav-user";
+import { NavUser } from "./nav-user";
+import type { OrganizationSwitcherProps } from "./organization-switcher";
+import { OrganizationSwitcher } from "./organization-switcher";
+import type { Route } from ".react-router/types/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/+types/_sidebar-layout";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarHeader,
+ SidebarRail,
+} from "~/components/ui/sidebar";
+import type { BillingSidebarCardProps } from "~/features/billing/billing-sidebar-card";
+import { BillingSidebarCard } from "~/features/billing/billing-sidebar-card";
+import { cn } from "~/lib/utils";
+
+type AppSidebarProps = {
+ organizationSlug: Route.ComponentProps["params"]["organizationSlug"];
+ billingSidebarCardProps?: BillingSidebarCardProps;
+ organizationSwitcherProps: OrganizationSwitcherProps;
+ navUserProps: NavUserProps;
+} & ComponentProps;
+
+export function AppSidebar({
+ billingSidebarCardProps,
+ navUserProps,
+ organizationSlug,
+ organizationSwitcherProps,
+ ...props
+}: AppSidebarProps) {
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "layout.appSidebar.nav",
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ {billingSidebarCardProps && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/layout-helpers.server.test.ts b/apps/react-router/saas-template/app/features/organizations/layout/layout-helpers.server.test.ts
new file mode 100644
index 0000000..3f91fdc
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/layout-helpers.server.test.ts
@@ -0,0 +1,478 @@
+import { href } from "react-router";
+import { describe, expect, test } from "vitest";
+
+import { createPopulatedOrganization } from "../organizations-factories.server";
+import {
+ getSidebarState,
+ mapOnboardingUserToBillingSidebarCardProps,
+ mapOnboardingUserToOrganizationLayoutProps,
+ switchSlugInRoute,
+} from "./layout-helpers.server";
+import { priceLookupKeysByTierAndInterval } from "~/features/billing/billing-constants";
+import {
+ createPopulatedStripeSubscriptionItemWithPriceAndProduct,
+ createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct,
+} from "~/features/billing/billing-factories.server";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { createOnboardingUser } from "~/test/test-utils";
+
+describe("getSidebarState", () => {
+ test('given: request with sidebar_state cookie set to "true", should: return true', () => {
+ const request = new Request("http://localhost", {
+ headers: {
+ cookie: "sidebar_state=true",
+ },
+ });
+
+ const actual = getSidebarState(request);
+ const expected = true;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test('given: request with sidebar_state cookie set to "false", should: return false', () => {
+ const request = new Request("http://localhost", {
+ headers: {
+ cookie: "sidebar_state=false",
+ },
+ });
+
+ const actual = getSidebarState(request);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: request with no sidebar_state cookie, should: return true", () => {
+ const request = new Request("http://localhost");
+
+ const actual = getSidebarState(request);
+ const expected = true;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: request with invalid sidebar_state cookie value, should: return false", () => {
+ const request = new Request("http://localhost", {
+ headers: {
+ cookie: "sidebar_state=invalid",
+ },
+ });
+
+ const actual = getSidebarState(request);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+});
+
+describe("mapOnboardingUserToOrganizationLayoutProps()", () => {
+ test("given: onboarding user with organizations where the current organization has no subscription, should: map to organization layout props", () => {
+ const user = createOnboardingUser({
+ email: "john@example.com",
+ imageUrl: "https://example.com/avatar.jpg",
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: {
+ id: "org1",
+ imageUrl: "https://example.com/org1.jpg",
+ name: "Organization 1",
+ slug: "org-1",
+ stripeSubscriptions: [],
+ },
+ role: "member",
+ },
+ {
+ deactivatedAt: null,
+ organization: {
+ id: "org2",
+ imageUrl: "https://example.com/org2.jpg",
+ name: "Organization 2",
+ slug: "org-2",
+ stripeSubscriptions: [
+ createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct(
+ {
+ items: [
+ createPopulatedStripeSubscriptionItemWithPriceAndProduct({
+ price: {
+ lookupKey: priceLookupKeysByTierAndInterval.low.annual,
+ },
+ }),
+ ],
+ },
+ ),
+ ],
+ },
+ role: "member",
+ },
+ ],
+ name: "John Doe",
+ });
+
+ const organizationSlug = "org-1";
+
+ const actual = mapOnboardingUserToOrganizationLayoutProps({
+ organizationSlug,
+ user,
+ });
+ const expected = {
+ navUserProps: {
+ user: {
+ avatar: "https://example.com/avatar.jpg",
+ email: "john@example.com",
+ name: "John Doe",
+ },
+ },
+ organizationSwitcherProps: {
+ currentOrganization: {
+ id: "org1",
+ logo: "https://example.com/org1.jpg",
+ name: "Organization 1",
+ slug: "org-1",
+ tier: "high",
+ },
+ organizations: [
+ {
+ id: "org1",
+ logo: "https://example.com/org1.jpg",
+ name: "Organization 1",
+ slug: "org-1",
+ tier: "high",
+ },
+ {
+ id: "org2",
+ logo: "https://example.com/org2.jpg",
+ name: "Organization 2",
+ slug: "org-2",
+ tier: "low",
+ },
+ ],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: onboarding user with organizations where the current organization has a subscription, should: map to organization layout props", () => {
+ const user = createOnboardingUser({
+ email: "john@example.com",
+ imageUrl: "https://example.com/avatar.jpg",
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: {
+ id: "org1",
+ imageUrl: "https://example.com/org1.jpg",
+ name: "Organization 1",
+ slug: "org-1",
+ stripeSubscriptions: [
+ createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct(
+ {
+ items: [
+ createPopulatedStripeSubscriptionItemWithPriceAndProduct({
+ price: {
+ lookupKey:
+ priceLookupKeysByTierAndInterval.high.monthly,
+ },
+ }),
+ ],
+ },
+ ),
+ ],
+ },
+ role: "member",
+ },
+ {
+ deactivatedAt: null,
+ organization: {
+ id: "org2",
+ imageUrl: "https://example.com/org2.jpg",
+ name: "Organization 2",
+ slug: "org-2",
+ stripeSubscriptions: [
+ createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct(
+ {
+ items: [
+ createPopulatedStripeSubscriptionItemWithPriceAndProduct({
+ price: {
+ lookupKey: priceLookupKeysByTierAndInterval.low.monthly,
+ },
+ }),
+ ],
+ },
+ ),
+ ],
+ },
+ role: "member",
+ },
+ ],
+ name: "John Doe",
+ });
+ const organizationSlug = "org-2";
+
+ const actual = mapOnboardingUserToOrganizationLayoutProps({
+ organizationSlug,
+ user,
+ });
+ const expected = {
+ navUserProps: {
+ user: {
+ avatar: "https://example.com/avatar.jpg",
+ email: "john@example.com",
+ name: "John Doe",
+ },
+ },
+ organizationSwitcherProps: {
+ currentOrganization: {
+ id: "org2",
+ logo: "https://example.com/org2.jpg",
+ name: "Organization 2",
+ slug: "org-2",
+ tier: "low",
+ },
+ organizations: [
+ {
+ id: "org1",
+ logo: "https://example.com/org1.jpg",
+ name: "Organization 1",
+ slug: "org-1",
+ tier: "high",
+ },
+ {
+ id: "org2",
+ logo: "https://example.com/org2.jpg",
+ name: "Organization 2",
+ slug: "org-2",
+ tier: "low",
+ },
+ ],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: onboarding user with no organizations, should: return empty organizations array", () => {
+ const user = createOnboardingUser({
+ email: "john@example.com",
+ imageUrl: "https://example.com/avatar.jpg",
+ memberships: [],
+ name: "John Doe",
+ });
+
+ const actual = mapOnboardingUserToOrganizationLayoutProps({
+ organizationSlug: "org-1",
+ user,
+ });
+ const expected = {
+ navUserProps: {
+ user: {
+ avatar: "https://example.com/avatar.jpg",
+ email: "john@example.com",
+ name: "John Doe",
+ },
+ },
+ organizationSwitcherProps: {
+ currentOrganization: undefined,
+ organizations: [],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+});
+
+describe("mapOnboardingUserToBillingSidebarCardProps()", () => {
+ test("given: a user without a membership for the given organization, should: return empty object", () => {
+ const now = new Date();
+ const user = createOnboardingUser({ memberships: [] });
+ const organizationSlug = "org-1";
+
+ const actual = mapOnboardingUserToBillingSidebarCardProps({
+ now,
+ organizationSlug,
+ user,
+ });
+ const expected = {};
+
+ expect(actual).toEqual(expected);
+ });
+
+ test.each([
+ OrganizationMembershipRole.member,
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: an onboarded %s user without a free trial, should: return an empty object", (role) => {
+ const now = new Date();
+ const subscription =
+ createPopulatedStripeSubscriptionItemWithPriceAndProduct({
+ price: {
+ lookupKey: priceLookupKeysByTierAndInterval.low.monthly,
+ },
+ });
+ const organization = createPopulatedOrganization();
+ const user = createOnboardingUser({
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: {
+ ...organization,
+ stripeSubscriptions: [subscription],
+ },
+ role,
+ },
+ ],
+ });
+
+ const actual = mapOnboardingUserToBillingSidebarCardProps({
+ now,
+ organizationSlug: organization.slug,
+ user,
+ });
+ const expected = {};
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an onboarded member user with a free trial, should: show the billing sidebar card without the button", () => {
+ const now = new Date();
+ const organization = createPopulatedOrganization({
+ // 1 day ago
+ createdAt: new Date(now.getTime() - 1000 * 60 * 60 * 24),
+ });
+ const user = createOnboardingUser({
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: { ...organization, stripeSubscriptions: [] },
+ role: OrganizationMembershipRole.member,
+ },
+ ],
+ });
+
+ const actual = mapOnboardingUserToBillingSidebarCardProps({
+ now,
+ organizationSlug: organization.slug,
+ user,
+ });
+ const expected = {
+ billingSidebarCardProps: {
+ showButton: false,
+ state: "trialing",
+ trialEndDate: organization.trialEnd,
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test.each([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: an onboarded %s user with a free trial, should: show the billing sidebar card with the button", (role) => {
+ const now = new Date();
+ const organization = createPopulatedOrganization({
+ // 1 day ago
+ createdAt: new Date(now.getTime() - 1000 * 60 * 60 * 24),
+ });
+ const user = createOnboardingUser({
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: { ...organization, stripeSubscriptions: [] },
+ role,
+ },
+ ],
+ });
+
+ const actual = mapOnboardingUserToBillingSidebarCardProps({
+ now,
+ organizationSlug: organization.slug,
+ user,
+ });
+
+ const expected = {
+ billingSidebarCardProps: {
+ showButton: true,
+ state: "trialing",
+ trialEndDate: organization.trialEnd,
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: any onboarded user with a free trial that has run out, should: show the billing sidebar card with the correct content", () => {
+ const now = new Date();
+ const organization = createPopulatedOrganization({
+ // 30 days ago
+ createdAt: new Date(now.getTime() - 1000 * 60 * 60 * 24 * 30),
+ });
+ const user = createOnboardingUser({
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: { ...organization, stripeSubscriptions: [] },
+ role: OrganizationMembershipRole.member,
+ },
+ ],
+ });
+
+ const actual = mapOnboardingUserToBillingSidebarCardProps({
+ now,
+ organizationSlug: organization.slug,
+ user,
+ });
+
+ const expected = {
+ billingSidebarCardProps: {
+ showButton: false,
+ state: "trialEnded",
+ trialEndDate: organization.trialEnd,
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+});
+
+describe("switchSlugInRoute()", () => {
+ test.each([
+ {
+ expected: href("/organizations/:organizationSlug", {
+ organizationSlug: "org-1",
+ }),
+ route: href("/organizations/:organizationSlug", {
+ organizationSlug: createPopulatedOrganization().slug,
+ }),
+ slug: "org-1",
+ },
+ {
+ expected: href("/organizations/:organizationSlug/dashboard", {
+ organizationSlug: "org-1",
+ }),
+ route: href("/organizations/:organizationSlug/dashboard", {
+ organizationSlug: createPopulatedOrganization().slug,
+ }),
+ slug: "org-1",
+ },
+ {
+ expected: href("/organizations/:organizationSlug/settings/general", {
+ organizationSlug: "org-1",
+ }),
+ route: href("/organizations/:organizationSlug/settings/general", {
+ organizationSlug: createPopulatedOrganization().slug,
+ }),
+ slug: "org-1",
+ },
+ ])("given: a route with a slug, should: return the route with the slug replaced", ({
+ route,
+ slug,
+ expected,
+ }) => {
+ const actual = switchSlugInRoute(route, slug);
+
+ expect(actual).toEqual(expected);
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/layout-helpers.server.ts b/apps/react-router/saas-template/app/features/organizations/layout/layout-helpers.server.ts
new file mode 100644
index 0000000..2c4395b
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/layout-helpers.server.ts
@@ -0,0 +1,145 @@
+import type { NavUserProps } from "./nav-user";
+import type { OrganizationSwitcherProps } from "./organization-switcher";
+import { priceLookupKeysByTierAndInterval } from "~/features/billing/billing-constants";
+import { getTierAndIntervalForLookupKey } from "~/features/billing/billing-helpers";
+import type { BillingSidebarCardProps } from "~/features/billing/billing-sidebar-card";
+import type { OnboardingUser } from "~/features/onboarding/onboarding-helpers.server";
+import type { Organization } from "~/generated/client";
+import { OrganizationMembershipRole } from "~/generated/client";
+
+/**
+ * Gets the sidebar state from the request cookies. This is used to determine
+ * whether to server render the sidebar open or closed.
+ *
+ * @param request - The request object containing cookies
+ * @returns boolean - The sidebar state (true if sidebar_state cookie is "true",
+ * false otherwise).
+ */
+export function getSidebarState(request: Request): boolean {
+ const cookies = request.headers.get("cookie") ?? "";
+ const sidebarState = cookies
+ .split(";")
+ .map((cookie) => cookie.trim())
+ .find((cookie) => cookie.startsWith("sidebar_state="))
+ ?.split("=")[1];
+
+ // Return true by default if no cookie is found
+ if (!sidebarState) return true;
+
+ return sidebarState === "true";
+}
+
+/**
+ * Maps an onboarding user to the organization layout props.
+ * @param user - The onboarding user to map
+ * @returns The organization layout props containing organizations and user data
+ */
+export function mapOnboardingUserToOrganizationLayoutProps({
+ user,
+ organizationSlug,
+}: {
+ user: OnboardingUser;
+ organizationSlug: Organization["slug"];
+}): {
+ navUserProps: NavUserProps;
+ organizationSwitcherProps: OrganizationSwitcherProps;
+} {
+ const mappedOrganizations = user.memberships.map((membership) => ({
+ id: membership.organization.id,
+ logo: membership.organization.imageUrl,
+ name: membership.organization.name,
+ slug: membership.organization.slug,
+ tier: getTierAndIntervalForLookupKey(
+ // Actual plan if the organization has a subscription.
+ membership.organization.stripeSubscriptions.length > 0
+ ? // biome-ignore lint/style/noNonNullAssertion: The check above ensures that there is a subscription
+ membership.organization.stripeSubscriptions[0]!.items[0]!.price
+ .lookupKey
+ : // Default plan during the trial period.
+ priceLookupKeysByTierAndInterval.high.annual,
+ ).tier,
+ }));
+
+ return {
+ navUserProps: {
+ user: {
+ avatar: user.imageUrl,
+ email: user.email,
+ name: user.name,
+ },
+ },
+ organizationSwitcherProps: {
+ currentOrganization: mappedOrganizations.find(
+ (organization) => organization.slug === organizationSlug,
+ ),
+ organizations: mappedOrganizations,
+ },
+ };
+}
+
+export function mapOnboardingUserToBillingSidebarCardProps({
+ now,
+ organizationSlug,
+ user,
+}: {
+ now: Date;
+ organizationSlug: Organization["slug"];
+ user: OnboardingUser;
+}): {
+ billingSidebarCardProps?: Omit<
+ BillingSidebarCardProps,
+ "createSubscriptionModalProps"
+ >;
+} {
+ const currentMembership = user.memberships.find(
+ (membership) => membership.organization.slug === organizationSlug,
+ );
+
+ if (!currentMembership) {
+ return {};
+ }
+
+ const currentOrganization = currentMembership?.organization;
+
+ if (!currentOrganization) {
+ return {};
+ }
+
+ const showButton =
+ currentMembership.role === OrganizationMembershipRole.admin ||
+ currentMembership.role === OrganizationMembershipRole.owner;
+
+ if (currentOrganization.stripeSubscriptions.length > 0) {
+ // biome-ignore lint/style/noNonNullAssertion: The check above ensures that there is a subscription
+ const subscription = currentOrganization.stripeSubscriptions[0]!;
+ const isCancelled = subscription.status === "canceled";
+ return isCancelled
+ ? {
+ billingSidebarCardProps: {
+ showButton,
+ state: "cancelled",
+ trialEndDate: new Date(),
+ },
+ }
+ : {};
+ }
+
+ return {
+ billingSidebarCardProps: {
+ showButton,
+ state: now < currentOrganization.trialEnd ? "trialing" : "trialEnded",
+ trialEndDate: currentOrganization.trialEnd,
+ },
+ };
+}
+
+/**
+ * Switches the slug in the route with the given slug.
+ *
+ * @param route - The route to switch the slug in
+ * @param slug - The slug to switch in the route
+ * @returns The route with the slug switched
+ */
+export function switchSlugInRoute(route: string, slug: Organization["slug"]) {
+ return route.replace(/\/organizations\/[^/]+/, `/organizations/${slug}`);
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/layout-helpers.test.ts b/apps/react-router/saas-template/app/features/organizations/layout/layout-helpers.test.ts
new file mode 100644
index 0000000..629786b
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/layout-helpers.test.ts
@@ -0,0 +1,172 @@
+import type { UIMatch } from "react-router";
+import { describe, expect, test } from "vitest";
+
+import { findBreadcrumbs } from "./layout-helpers";
+
+describe("findBreadcrumbs()", () => {
+ test("given an array of matches: returns all breadcrumbs from matches", () => {
+ const matches: UIMatch<
+ { breadcrumb?: { title: string; to: string } } & Record
+ >[] = [
+ {
+ data: {},
+ handle: {},
+ id: "root",
+ loaderData: {},
+ params: { organizationSlug: "tromp---schinner" },
+ pathname: "/",
+ },
+ {
+ data: {
+ breadcrumb: {
+ title: "Organization",
+ to: "/organizations/tromp---schinner",
+ },
+ },
+ handle: {},
+ id: "routes/organization_.$organizationSlug",
+ loaderData: {
+ breadcrumb: {
+ title: "Organization",
+ to: "/organizations/tromp---schinner",
+ },
+ },
+ params: { organizationSlug: "tromp---schinner" },
+ pathname: "/organizations/tromp---schinner",
+ },
+ {
+ data: {
+ breadcrumb: {
+ title: "Dashboard",
+ to: "/organizations/tromp---schinner/dashboard",
+ },
+ },
+ handle: {},
+ id: "routes/organization_.$organizationSlug.dashboard",
+ loaderData: {
+ breadcrumb: {
+ title: "Dashboard",
+ to: "/organizations/tromp---schinner/dashboard",
+ },
+ },
+ params: { organizationSlug: "tromp---schinner" },
+ pathname: "/organizations/tromp---schinner/dashboard",
+ },
+ ];
+
+ const actual = findBreadcrumbs(matches);
+ const expected = [
+ {
+ title: "Organization",
+ to: "/organizations/tromp---schinner",
+ },
+ {
+ title: "Dashboard",
+ to: "/organizations/tromp---schinner/dashboard",
+ },
+ ];
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given matches with no breadcrumbs: returns empty array", () => {
+ const matches: UIMatch<
+ { breadcrumb?: { title: string; to: string } } & Record
+ >[] = [
+ {
+ data: {},
+ handle: {},
+ id: "root",
+ loaderData: {},
+ params: {},
+ pathname: "/",
+ },
+ {
+ data: { someOtherData: "value" },
+ handle: {},
+ id: "routes/some-route",
+ loaderData: { someOtherData: "value" },
+ params: {},
+ pathname: "/some-route",
+ },
+ ];
+
+ const actual = findBreadcrumbs(matches);
+ const expected: { title: string; to: string }[] = [];
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given matches with some breadcrumbs: returns only those with breadcrumbs", () => {
+ const matches: UIMatch<
+ { breadcrumb?: { title: string; to: string } } & Record
+ >[] = [
+ {
+ data: {},
+ handle: {},
+ id: "root",
+ loaderData: {},
+ params: {},
+ pathname: "/",
+ },
+ {
+ data: {
+ breadcrumb: {
+ title: "First",
+ to: "/first",
+ },
+ },
+ handle: {},
+ id: "routes/first",
+ loaderData: {
+ breadcrumb: {
+ title: "First",
+ to: "/first",
+ },
+ },
+ params: {},
+ pathname: "/first",
+ },
+ {
+ data: { someOtherData: "value" },
+ handle: {},
+ id: "routes/middle",
+ loaderData: { someOtherData: "value" },
+ params: {},
+ pathname: "/first/middle",
+ },
+ {
+ data: {
+ breadcrumb: {
+ title: "Last",
+ to: "/first/middle/last",
+ },
+ },
+ handle: {},
+ id: "routes/last",
+ loaderData: {
+ breadcrumb: {
+ title: "Last",
+ to: "/first/middle/last",
+ },
+ },
+ params: {},
+ pathname: "/first/middle/last",
+ },
+ ];
+
+ const actual = findBreadcrumbs(matches);
+ const expected = [
+ {
+ title: "First",
+ to: "/first",
+ },
+ {
+ title: "Last",
+ to: "/first/middle/last",
+ },
+ ];
+
+ expect(actual).toEqual(expected);
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/layout-helpers.ts b/apps/react-router/saas-template/app/features/organizations/layout/layout-helpers.ts
new file mode 100644
index 0000000..2c88ab2
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/layout-helpers.ts
@@ -0,0 +1,23 @@
+import type { UIMatch } from "react-router";
+
+type Breadcrumb = {
+ title: string;
+ to: string;
+};
+
+export const findBreadcrumbs = (
+ matches: UIMatch<{ breadcrumb?: Breadcrumb }>[],
+): Breadcrumb[] => {
+ const breadcrumbs: Breadcrumb[] = [];
+
+ for (const match of matches) {
+ if (match.loaderData && "breadcrumb" in match.loaderData) {
+ const breadcrumb = match.loaderData.breadcrumb;
+ if (breadcrumb) {
+ breadcrumbs.push(breadcrumb);
+ }
+ }
+ }
+
+ return breadcrumbs;
+};
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/nav-group.test.tsx b/apps/react-router/saas-template/app/features/organizations/layout/nav-group.test.tsx
new file mode 100644
index 0000000..7aa5fa0
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/nav-group.test.tsx
@@ -0,0 +1,168 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: Test code */
+import { faker } from "@faker-js/faker";
+import { IconHome, IconSettings } from "@tabler/icons-react";
+import userEvent from "@testing-library/user-event";
+import { createRoutesStub } from "react-router";
+import { describe, expect, test } from "vitest";
+
+import type { NavGroupItemWithoutChildren, NavGroupProps } from "./nav-group";
+import { NavGroup } from "./nav-group";
+import { SidebarProvider } from "~/components/ui/sidebar";
+import { render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createNavGroupItemWithoutChildren: Factory<
+ NavGroupItemWithoutChildren
+> = ({
+ title = faker.lorem.words(2),
+ icon = faker.helpers.arrayElement([IconHome, IconSettings]),
+ url = faker.helpers.arrayElement([
+ "/account",
+ "/dashboard",
+ "/home",
+ "/profile",
+ "/settings",
+ ]),
+} = {}) => ({ icon, title, url });
+
+const createItemsWithoutChildren = (
+ length: number,
+): NavGroupItemWithoutChildren[] =>
+ faker.helpers
+ .uniqueArray(() => createNavGroupItemWithoutChildren().url, length)
+ .map((url) => createNavGroupItemWithoutChildren({ url }));
+
+const createProps: Factory = ({
+ items = createItemsWithoutChildren(2),
+ size = "default",
+ title,
+ className = faker.lorem.word(),
+} = {}) => ({ className, items, size, title });
+
+describe("NavGroup Component", () => {
+ test("given: items with icons, should: render navigation group with title and items", () => {
+ const title = faker.lorem.words(3);
+ const props = createProps({ title });
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ // Verify title is rendered.
+ expect(screen.getByText(title)).toBeInTheDocument();
+
+ // Verify all items are rendered.
+ for (const item of props.items) {
+ expect(screen.getByText(item.title)).toBeInTheDocument();
+ if ("url" in item) {
+ const link = screen.getByRole("link", { name: item.title });
+ expect(link).toHaveAttribute("href", item.url);
+ }
+ }
+ });
+
+ test("given: collapsible items, should: render collapsible navigation group and expand on click", async () => {
+ const user = userEvent.setup();
+ const props = createProps({
+ items: [
+ {
+ icon: IconSettings,
+ items: [
+ { title: "Profile", url: "/settings/profile" },
+ { title: "Security", url: "/settings/security" },
+ ],
+ title: "Settings",
+ },
+ ],
+ });
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ // Verify parent item is rendered
+ const settingsButton = screen.getByRole("button", { name: /settings/i });
+ expect(settingsButton).toBeInTheDocument();
+
+ // Verify child items are initially hidden
+ expect(screen.queryByText("Profile")).not.toBeInTheDocument();
+ expect(screen.queryByText("Security")).not.toBeInTheDocument();
+
+ // Click the settings button to expand
+ await user.click(settingsButton);
+
+ // Verify child items are now visible
+ expect(screen.getByText("Profile")).toBeInTheDocument();
+ expect(screen.getByText("Security")).toBeInTheDocument();
+ });
+
+ test("given: an active route, should: highlight the active navigation item", () => {
+ const items = createItemsWithoutChildren(2);
+ const props = createProps({ items });
+ const path = items[0]!.url;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ const homeLink = screen.getByRole("link", { name: items[0]!.title });
+ expect(homeLink).toHaveAttribute("aria-current", "page");
+
+ const settingsLink = screen.getByRole("link", { name: items[1]!.title });
+ expect(settingsLink).not.toHaveAttribute("aria-current");
+ });
+
+ test("given: no title, should: render navigation group without title", () => {
+ const props = createProps();
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ // This is a workaround and an absolute exception to check if the title
+ // is NOT rendered.
+ const { container } = render(
+
+
+ ,
+ );
+
+ // Verify title is NOT rendered.
+ expect(
+ container.querySelector('[data-slot="sidebar-group-label"]'),
+ ).not.toBeInTheDocument();
+ });
+
+ test("given: custom className, should: apply the className to the navigation group", () => {
+ const props = createProps();
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild?.firstChild).toHaveClass(props.className!);
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/nav-group.tsx b/apps/react-router/saas-template/app/features/organizations/layout/nav-group.tsx
new file mode 100644
index 0000000..47bb447
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/nav-group.tsx
@@ -0,0 +1,121 @@
+import type { Icon } from "@tabler/icons-react";
+import { IconChevronRight } from "@tabler/icons-react";
+import type { ComponentProps } from "react";
+import { NavLink, useLocation } from "react-router";
+
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "~/components/ui/collapsible";
+import {
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+} from "~/components/ui/sidebar";
+
+type NavGroupItem = {
+ title: string;
+ icon?: Icon;
+ isActive?: boolean;
+};
+
+export type NavGroupItemWithoutChildren = NavGroupItem & {
+ url: string;
+};
+
+type NavGroupItemWithChildren = NavGroupItem & {
+ items: {
+ isActive?: boolean;
+ title: string;
+ url: string;
+ }[];
+};
+
+export type NavGroupProps = {
+ className?: string;
+ items: (NavGroupItemWithoutChildren | NavGroupItemWithChildren)[];
+ size?: ComponentProps["size"];
+ title?: string;
+};
+
+export function NavGroup({ className, items, size, title }: NavGroupProps) {
+ const location = useLocation();
+
+ return (
+
+ {title && {title} }
+
+
+ {items.map((item) => {
+ if ("items" in item) {
+ const isParentActive = item.items.some(
+ (subItem) => location.pathname === subItem.url,
+ );
+
+ return (
+ }
+ >
+
+ }
+ >
+ {item.icon && }
+ {item.title}
+
+
+
+
+
+ {item.items?.map((subItem) => (
+
+
+ {({ isActive: childIsActive }) => (
+
+ {subItem.title}
+
+ )}
+
+
+ ))}
+
+
+
+ );
+ }
+
+ return (
+
+
+ {({ isActive }) => (
+
+ {item.icon && }
+ {item.title}
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/nav-user.test.tsx b/apps/react-router/saas-template/app/features/organizations/layout/nav-user.test.tsx
new file mode 100644
index 0000000..2ac1ea1
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/nav-user.test.tsx
@@ -0,0 +1,121 @@
+import userEvent from "@testing-library/user-event";
+import { createRoutesStub } from "react-router";
+import { describe, expect, test } from "vitest";
+
+import type { NavUserProps } from "./nav-user";
+import { NavUser } from "./nav-user";
+import { SidebarProvider } from "~/components/ui/sidebar";
+import { createPopulatedUserAccount } from "~/features/user-accounts/user-accounts-factories.server";
+import { render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createUser: Factory = ({
+ name = createPopulatedUserAccount().name,
+ email = createPopulatedUserAccount().email,
+ avatar = createPopulatedUserAccount().imageUrl,
+} = {}) => ({ avatar, email, name });
+
+const createProps: Factory = ({ user = createUser() } = {}) => ({
+ user,
+});
+
+describe("NavUser Component", () => {
+ test("given: user data, should: render user information and handle menu interactions", () => {
+ const props = createProps();
+ const { user } = props;
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ // Verify user name and email are displayed
+ expect(screen.getByText(user.name)).toBeInTheDocument();
+ expect(screen.getByText(user.email)).toBeInTheDocument();
+
+ // Verify dropdown menu is initially closed
+ expect(
+ screen.queryByRole("menuitem", { name: /account/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ test("given: user data, should: render avatar fallback with initials when avatar fails to load", () => {
+ const props = createProps({
+ user: createUser({ avatar: "invalid-url" }),
+ });
+ const { user } = props;
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ // Verify avatar fallback is rendered with initials
+ const avatarFallback = screen.getByText(
+ user.name.slice(0, 2).toUpperCase(),
+ );
+ expect(avatarFallback).toBeInTheDocument();
+ });
+
+ test("given: user menu interactions, should: handle opening, closing, and navigation correctly", async () => {
+ const user = userEvent.setup();
+ const props = createProps();
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ // Verify dropdown menu is initially closed
+ expect(
+ screen.queryByRole("menuitem", { name: /account/i }),
+ ).not.toBeInTheDocument();
+
+ // Click the user button to open the menu
+ const userButton = screen.getByRole("button", {
+ name: /open user menu/i,
+ });
+ await user.click(userButton);
+
+ // Verify dropdown menu items are now visible
+ expect(
+ screen.getByRole("menuitem", { name: /account/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /log out/i }),
+ ).toBeInTheDocument();
+
+ // Verify account link
+ const accountLink = screen.getByRole("link", { name: /account/i });
+ expect(accountLink).toHaveAttribute("href", "/settings/account");
+
+ // Verify logout button
+ const logoutButton = screen.getByRole("menuitem", { name: /log out/i });
+ expect(logoutButton).toHaveAttribute("name", "intent");
+ expect(logoutButton).toHaveAttribute("value", "logout");
+ expect(logoutButton).toHaveAttribute("type", "submit");
+
+ // Press escape to close the dropdown
+ await user.keyboard("{Escape}");
+
+ // Verify menu is closed
+ expect(
+ screen.queryByRole("menuitem", { name: /account/i }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/nav-user.tsx b/apps/react-router/saas-template/app/features/organizations/layout/nav-user.tsx
new file mode 100644
index 0000000..5bca88a
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/nav-user.tsx
@@ -0,0 +1,138 @@
+import {
+ IconLogout,
+ IconRosetteDiscountCheck,
+ IconSelector,
+} from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { Form, href, Link } from "react-router";
+import { useHydrated } from "remix-utils/use-hydrated";
+
+import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "~/components/ui/dropdown-menu";
+import {
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from "~/components/ui/sidebar";
+
+export type NavUserProps = {
+ user: {
+ name: string;
+ email: string;
+ avatar: string;
+ };
+};
+
+export function NavUser({ user }: NavUserProps) {
+ const { isMobile } = useSidebar();
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "layout.navUser",
+ });
+ const hydrated = useHydrated();
+
+ return (
+
+
+
+
+ }
+ >
+
+
+
+
+ {user.name.slice(0, 2).toUpperCase()}
+
+
+
+
+ {user.name}
+ {user.email}
+
+
+
+
+
+
+
+
+
+
+
+
+ {user.name.slice(0, 2).toUpperCase()}
+
+
+
+
+ {user.name}
+ {user.email}
+
+
+
+
+
+
+
+
+
+
+
+ {t("account")}
+
+
+
+
+
+
+
+
+ }
+ >
+
+ {t("logOut")}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/organization-switcher-session.server.spec.ts b/apps/react-router/saas-template/app/features/organizations/layout/organization-switcher-session.server.spec.ts
new file mode 100644
index 0000000..3b59ae7
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/organization-switcher-session.server.spec.ts
@@ -0,0 +1,84 @@
+import { describe, expect, test } from "vitest";
+
+import { createPopulatedOrganization } from "../organizations-factories.server";
+import {
+ createCookieForOrganizationSwitcherSession,
+ destroyOrganizationSwitcherSession,
+ getCurrentOrganizationIdFromSession,
+} from "./organization-switcher-session.server";
+
+describe("organization switcher session", () => {
+ describe("createCookieForOrganizationSwitcherSession() & getCurrentOrganizationIdFromSession()", () => {
+ test("given: a request with no session and an organizationId, should: create a cookie and return the organizationId", async () => {
+ const organizationId = createPopulatedOrganization().id;
+ const initialRequest = new Request("http://example.com");
+
+ const setCookie = await createCookieForOrganizationSwitcherSession(
+ initialRequest,
+ organizationId,
+ );
+ const newRequest = new Request("http://example.com", {
+ headers: { Cookie: setCookie },
+ });
+
+ const actual = await getCurrentOrganizationIdFromSession(newRequest);
+ const expected = organizationId;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a request with no session, should: return undefined", async () => {
+ const request = new Request("http://example.com");
+
+ const actual = await getCurrentOrganizationIdFromSession(request);
+ const expected = undefined;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a request with an existing organizationId and a new organizationId, should: update and return the new organizationId", async () => {
+ const initialOrganizationId = createPopulatedOrganization().id;
+ const initialRequest = new Request("http://example.com");
+ const setCookie1 = await createCookieForOrganizationSwitcherSession(
+ initialRequest,
+ initialOrganizationId,
+ );
+ const request1 = new Request("http://example.com", {
+ headers: { Cookie: setCookie1 },
+ });
+ const newOrganizationId = createPopulatedOrganization().id;
+ const setCookie2 = await createCookieForOrganizationSwitcherSession(
+ request1,
+ newOrganizationId,
+ );
+ const request2 = new Request("http://example.com", {
+ headers: { Cookie: setCookie2 },
+ });
+
+ const actual = await getCurrentOrganizationIdFromSession(request2);
+ const expected = newOrganizationId;
+
+ expect(actual).toEqual(expected);
+ });
+ });
+
+ describe("destroyOrganizationSwitcherSession()", () => {
+ test("given: a request with an existing organizationId, should: destroy the cookie", async () => {
+ const organizationId = createPopulatedOrganization().id;
+ const request = new Request("http://example.com");
+ const setCookie = await createCookieForOrganizationSwitcherSession(
+ request,
+ organizationId,
+ );
+ const newRequest = new Request("http://example.com", {
+ headers: { Cookie: setCookie },
+ });
+
+ const actual = await destroyOrganizationSwitcherSession(newRequest);
+ const expected =
+ "__organization_switcher=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax";
+
+ expect(actual).toEqual(expected);
+ });
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/organization-switcher-session.server.ts b/apps/react-router/saas-template/app/features/organizations/layout/organization-switcher-session.server.ts
new file mode 100644
index 0000000..52b3ac7
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/organization-switcher-session.server.ts
@@ -0,0 +1,69 @@
+import { createCookieSessionStorage } from "react-router";
+
+const ORGANIZATION_SWITCHER_SESSION_KEY = "currentOrganizationId";
+const ORGANIZATION_SWITCHER_SESSION_NAME = "__organization_switcher";
+const TEN_YEARS_IN_SECONDS = 60 * 60 * 24 * 365 * 10;
+
+const organizationSwitcherSession = createCookieSessionStorage<{
+ [ORGANIZATION_SWITCHER_SESSION_KEY]: string;
+}>({
+ cookie: {
+ httpOnly: true, // Prevents client-side JS from accessing the cookie
+ maxAge: 0,
+ name: ORGANIZATION_SWITCHER_SESSION_NAME,
+ path: "/", // Cookie is available across the entire site
+ sameSite: "lax", // Helps mitigate CSRF attacks
+ secrets: [process.env.COOKIE_SECRET], // Secret to sign the cookie (replace with a secure value)
+ secure: process.env.NODE_ENV === "production", // Only send over HTTPS in production
+ },
+});
+
+/**
+ * Gets the organization switcher session from the request cookies.
+ *
+ * @param request - The request object containing cookies
+ * @returns The organization switcher session
+ */
+function getOrganizationSwitcherSession(request: Request) {
+ return organizationSwitcherSession.getSession(request.headers.get("Cookie"));
+}
+
+/**
+ * Gets the current organization ID from the session.
+ *
+ * @param request - The request object containing cookies
+ * @returns The current organization ID stored in the session
+ */
+export async function getCurrentOrganizationIdFromSession(request: Request) {
+ const session = await getOrganizationSwitcherSession(request);
+ return session.get(ORGANIZATION_SWITCHER_SESSION_KEY);
+}
+
+/**
+ * Sets the current organization ID in the session.
+ *
+ * @param request - The request object containing cookies
+ * @param organizationId - The ID of the organization to set as current
+ * @returns The committed session with the updated organization ID
+ */
+export async function createCookieForOrganizationSwitcherSession(
+ request: Request,
+ organizationId: string,
+) {
+ const session = await getOrganizationSwitcherSession(request);
+ session.set(ORGANIZATION_SWITCHER_SESSION_KEY, organizationId);
+ return organizationSwitcherSession.commitSession(session, {
+ maxAge: TEN_YEARS_IN_SECONDS,
+ });
+}
+
+/**
+ * Destroys the organization switcher session.
+ *
+ * @param request - The request object containing cookies
+ * @returns The response headers to destroy the session cookie
+ */
+export async function destroyOrganizationSwitcherSession(request: Request) {
+ const session = await getOrganizationSwitcherSession(request);
+ return organizationSwitcherSession.destroySession(session);
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/organization-switcher.test.tsx b/apps/react-router/saas-template/app/features/organizations/layout/organization-switcher.test.tsx
new file mode 100644
index 0000000..f689377
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/organization-switcher.test.tsx
@@ -0,0 +1,92 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: Test code */
+import userEvent from "@testing-library/user-event";
+import { createRoutesStub } from "react-router";
+import { describe, expect, test } from "vitest";
+
+import { createPopulatedOrganization } from "../organizations-factories.server";
+import type { OrganizationSwitcherProps } from "./organization-switcher";
+import { OrganizationSwitcher } from "./organization-switcher";
+import { SidebarProvider } from "~/components/ui/sidebar";
+import { getRandomTier } from "~/features/billing/billing-factories.server";
+import { render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createOrganization: Factory<
+ OrganizationSwitcherProps["organizations"][0]
+> = ({
+ id = createPopulatedOrganization().id,
+ slug = createPopulatedOrganization().slug,
+ name = createPopulatedOrganization().name,
+ logo = createPopulatedOrganization().imageUrl,
+ tier = getRandomTier(),
+} = {}) => ({ id, logo, name, slug, tier });
+
+const createProps: Factory = ({
+ organizations = [
+ createOrganization({ name: "Home Org" }),
+ createOrganization({ name: "Work Org" }),
+ ],
+ currentOrganization = createOrganization({ name: "Work Org" }),
+} = {}) => ({ currentOrganization, organizations });
+
+describe("OrganizationSwitcher Component", () => {
+ test("given: organizations data, should: render current organization in the button", () => {
+ const currentOrganization = createOrganization({ tier: "low" });
+ const props = createProps({ currentOrganization });
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ // Verify current organization is displayed.
+ expect(screen.getByText(currentOrganization.name)).toBeInTheDocument();
+ expect(screen.getByText(/hobby/i)).toBeInTheDocument();
+ });
+
+ test("given: organizations data, should: handle dropdown menu interactions", async () => {
+ const user = userEvent.setup();
+ const props = createProps();
+ const { organizations } = props;
+ const path = "/test";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ // Verify dropdown menu is initially closed.
+ expect(screen.queryByText(organizations[0]!.name)).not.toBeInTheDocument();
+
+ // Click the organization button to open the menu.
+ const orgButton = screen.getByRole("button");
+ await user.click(orgButton);
+
+ // Verify all organizations are now visible.
+ for (const org of organizations) {
+ expect(
+ screen.getByRole("menuitem", { name: new RegExp(org.name, "i") }),
+ ).toBeInTheDocument();
+ }
+
+ // Verify new organization button is displayed.
+ expect(
+ screen.getByRole("link", { name: /new organization/i }),
+ ).toHaveAttribute("href", "/organizations/new");
+
+ // Press escape to close the dropdown.
+ await user.keyboard("{Escape}");
+
+ // Verify menu is closed.
+ expect(screen.queryByText(organizations[0]!.name)).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/organization-switcher.tsx b/apps/react-router/saas-template/app/features/organizations/layout/organization-switcher.tsx
new file mode 100644
index 0000000..5927f14
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/organization-switcher.tsx
@@ -0,0 +1,168 @@
+import { IconPlus, IconSelector } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { Form, Link, useLocation } from "react-router";
+import { useHydrated } from "remix-utils/use-hydrated";
+
+import { SWITCH_ORGANIZATION_INTENT } from "./sidebar-layout-constants";
+import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "~/components/ui/dropdown-menu";
+import {
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from "~/components/ui/sidebar";
+import type { Tier } from "~/features/billing/billing-constants";
+import type { Organization } from "~/generated/browser";
+
+type OrganizationSwitcherOrganization = {
+ id: Organization["id"];
+ name: Organization["name"];
+ logo: Organization["imageUrl"];
+ slug: Organization["slug"];
+ tier: Tier;
+};
+
+export type OrganizationSwitcherProps = {
+ organizations: OrganizationSwitcherOrganization[];
+ currentOrganization?: OrganizationSwitcherOrganization;
+};
+
+export function OrganizationSwitcher({
+ organizations,
+ currentOrganization,
+}: OrganizationSwitcherProps) {
+ const { isMobile } = useSidebar();
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "layout.organizationSwitcher",
+ });
+ const { t: tTier } = useTranslation("billing", {
+ keyPrefix: "pricing.plans",
+ });
+ const location = useLocation();
+ const currentPath = location.pathname;
+ const hydrated = useHydrated();
+
+ if (!currentOrganization) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ }
+ >
+
+
+
+
+ {currentOrganization.name.slice(0, 2).toUpperCase()}
+
+
+
+
+
+ {currentOrganization.name}
+
+
+
+ {tTier(`${currentOrganization.tier}.title`, {
+ defaultValue: "Enterprise",
+ })}
+
+
+
+
+
+
+
+
+
+ {t("organizations")}
+
+
+ {organizations.map((organization) => (
+
+
+ }
+ >
+
+
+
+
+
+
+
+ {organization.name.slice(0, 2).toUpperCase()}
+
+
+ {organization.name}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {t("newOrganization")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/sidebar-layout-action.server.ts b/apps/react-router/saas-template/app/features/organizations/layout/sidebar-layout-action.server.ts
new file mode 100644
index 0000000..0a0be96
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/sidebar-layout-action.server.ts
@@ -0,0 +1,151 @@
+import { data, redirect } from "react-router";
+import { safeRedirect } from "remix-utils/safe-redirect";
+import { z } from "zod";
+
+import { findOrganizationIfUserIsMemberById } from "../organizations-helpers.server";
+import { organizationMembershipContext } from "../organizations-middleware.server";
+import { switchSlugInRoute } from "./layout-helpers.server";
+import { createCookieForOrganizationSwitcherSession } from "./organization-switcher-session.server";
+import { SWITCH_ORGANIZATION_INTENT } from "./sidebar-layout-constants";
+import { switchOrganizationSchema } from "./sidebar-layout-schemas";
+import type { Route } from ".react-router/types/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/+types/_sidebar-layout";
+import { OPEN_CHECKOUT_SESSION_INTENT } from "~/features/billing/billing-constants";
+import { extractBaseUrl } from "~/features/billing/billing-helpers.server";
+import { openCustomerCheckoutSessionSchema } from "~/features/billing/billing-schemas";
+import { createStripeCheckoutSession } from "~/features/billing/stripe-helpers.server";
+import { retrieveStripePriceWithProductFromDatabaseByLookupKey } from "~/features/billing/stripe-prices-model.server";
+import {
+ MARK_ALL_NOTIFICATIONS_AS_READ_INTENT,
+ MARK_ONE_NOTIFICATION_AS_READ_INTENT,
+ NOTIFICATION_PANEL_OPENED_INTENT,
+} from "~/features/notifications/notification-constants";
+import {
+ markAllUnreadNotificationsAsReadForUserAndOrganizationInDatabaseById,
+ markNotificationAsReadForUserAndOrganizationInDatabaseById,
+ updateNotificationPanelLastOpenedAtForUserAndOrganizationInDatabaseById,
+} from "~/features/notifications/notifications-model.server";
+import {
+ markAllAsReadSchema,
+ markOneAsReadSchema,
+ notificationPanelOpenedSchema,
+} from "~/features/notifications/notifications-schemas";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { combineHeaders } from "~/utils/combine-headers.server";
+import { getIsDataWithResponseInit } from "~/utils/get-is-data-with-response-init.server";
+import { requestToUrl } from "~/utils/get-search-parameter-from-request.server";
+import { conflict, forbidden, notFound } from "~/utils/http-responses.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+const schema = z.discriminatedUnion("intent", [
+ markAllAsReadSchema,
+ markOneAsReadSchema,
+ notificationPanelOpenedSchema,
+ switchOrganizationSchema,
+ openCustomerCheckoutSessionSchema,
+]);
+
+export async function sidebarLayoutAction({
+ context,
+ request,
+}: Route.ActionArgs) {
+ try {
+ const { user, organization, headers, role } = context.get(
+ organizationMembershipContext,
+ );
+ const result = await validateFormData(request, schema);
+ if (!result.success) return result.response;
+
+ const body = result.data;
+
+ switch (body.intent) {
+ case SWITCH_ORGANIZATION_INTENT: {
+ const { organization } = findOrganizationIfUserIsMemberById(
+ user,
+ body.organizationId,
+ );
+ const cookie = await createCookieForOrganizationSwitcherSession(
+ request,
+ organization.id,
+ );
+ return redirect(
+ safeRedirect(switchSlugInRoute(body.currentPath, organization.slug)),
+ { headers: combineHeaders(headers, { "Set-Cookie": cookie }) },
+ );
+ }
+
+ case MARK_ALL_NOTIFICATIONS_AS_READ_INTENT: {
+ await markAllUnreadNotificationsAsReadForUserAndOrganizationInDatabaseById(
+ { organizationId: organization.id, userId: user.id },
+ );
+ return data({}, { headers });
+ }
+
+ case MARK_ONE_NOTIFICATION_AS_READ_INTENT: {
+ const result =
+ await markNotificationAsReadForUserAndOrganizationInDatabaseById({
+ organizationId: organization.id,
+ recipientId: body.recipientId,
+ userId: user.id,
+ });
+
+ if (result === null) {
+ return notFound({}, { headers });
+ }
+
+ return data({}, { headers });
+ }
+
+ case NOTIFICATION_PANEL_OPENED_INTENT: {
+ await updateNotificationPanelLastOpenedAtForUserAndOrganizationInDatabaseById(
+ { organizationId: organization.id, userId: user.id },
+ );
+ return data({}, { headers });
+ }
+
+ case OPEN_CHECKOUT_SESSION_INTENT: {
+ if (role === OrganizationMembershipRole.member) {
+ return forbidden();
+ }
+
+ if (organization.stripeSubscriptions[0]) {
+ return conflict();
+ }
+
+ const price =
+ await retrieveStripePriceWithProductFromDatabaseByLookupKey(
+ body.lookupKey,
+ );
+
+ if (!price) {
+ throw new Error("Price not found");
+ }
+
+ if (organization._count.memberships > price.product.maxSeats) {
+ return conflict();
+ }
+
+ const baseUrl = extractBaseUrl(requestToUrl(request));
+
+ const checkoutSession = await createStripeCheckoutSession({
+ baseUrl,
+ customerEmail: organization.billingEmail,
+ customerId: organization.stripeCustomerId,
+ organizationId: organization.id,
+ organizationSlug: organization.slug,
+ priceId: price.stripeId,
+ purchasedById: user.id,
+ seatsUsed: organization._count.memberships,
+ });
+
+ // biome-ignore lint/style/noNonNullAssertion: Checkout sessions always have a URL
+ return redirect(checkoutSession.url!);
+ }
+ }
+ } catch (error) {
+ if (getIsDataWithResponseInit(error)) {
+ return error;
+ }
+
+ throw error;
+ }
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/sidebar-layout-constants.ts b/apps/react-router/saas-template/app/features/organizations/layout/sidebar-layout-constants.ts
new file mode 100644
index 0000000..36d7654
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/sidebar-layout-constants.ts
@@ -0,0 +1 @@
+export const SWITCH_ORGANIZATION_INTENT = "switchOrganization";
diff --git a/apps/react-router/saas-template/app/features/organizations/layout/sidebar-layout-schemas.ts b/apps/react-router/saas-template/app/features/organizations/layout/sidebar-layout-schemas.ts
new file mode 100644
index 0000000..7b231c1
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/layout/sidebar-layout-schemas.ts
@@ -0,0 +1,11 @@
+import { z } from "zod";
+
+import { SWITCH_ORGANIZATION_INTENT } from "./sidebar-layout-constants";
+
+z.config({ jitless: true });
+
+export const switchOrganizationSchema = z.object({
+ currentPath: z.string(),
+ intent: z.literal(SWITCH_ORGANIZATION_INTENT),
+ organizationId: z.string(),
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/organization-constants.ts b/apps/react-router/saas-template/app/features/organizations/organization-constants.ts
new file mode 100644
index 0000000..137806a
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/organization-constants.ts
@@ -0,0 +1,2 @@
+export const BUCKET_NAME = "app-images";
+export const LOGO_PATH_PREFIX = "organization-logos";
diff --git a/apps/react-router/saas-template/app/features/organizations/organization-membership-model.server.ts b/apps/react-router/saas-template/app/features/organizations/organization-membership-model.server.ts
new file mode 100644
index 0000000..5cb0369
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/organization-membership-model.server.ts
@@ -0,0 +1,71 @@
+import type { Organization, Prisma, UserAccount } from "~/generated/client";
+import { prisma } from "~/utils/database.server";
+
+/* READ */
+
+/**
+ * Retrieves an organization membership by user ID and organization ID.
+ *
+ * @param userId - The ID of the user.
+ * @param organizationId - The ID of the organization.
+ * @returns The organization membership or null if not found.
+ */
+export async function retrieveOrganizationMembershipFromDatabaseByUserIdAndOrganizationId({
+ userId,
+ organizationId,
+}: {
+ userId: UserAccount["id"];
+ organizationId: Organization["id"];
+}) {
+ return prisma.organizationMembership.findUnique({
+ where: {
+ memberId_organizationId: { memberId: userId, organizationId },
+ },
+ });
+}
+
+/**
+ * Retrieves an *active* organization membership by user email and organization ID.
+ * This is useful for checking if a user with a specific email is already
+ * an active member before inviting them.
+ *
+ * @param email - The email address of the potential member.
+ * @param organizationId - The ID of the organization.
+ * @returns The active organization membership or null if not found or inactive.
+ */
+export async function retrieveActiveOrganizationMembershipByEmailAndOrganizationId({
+ email,
+ organizationId,
+}: {
+ email: UserAccount["email"];
+ organizationId: Organization["id"];
+}) {
+ return prisma.organizationMembership.findFirst({
+ where: { member: { email: email }, organizationId: organizationId },
+ });
+}
+
+/* UPDATE */
+
+/**
+ * Updates a specific organization membership.
+ *
+ * @param userId - The ID of the user (member) whose membership is being updated.
+ * @param organizationId - The ID of the organization.
+ * @param data - The data to update the membership with (e.g., role, deactivatedAt).
+ * @returns The updated organization membership.
+ */
+export async function updateOrganizationMembershipInDatabase({
+ userId,
+ organizationId,
+ data,
+}: {
+ userId: UserAccount["id"];
+ organizationId: Organization["id"];
+ data: Prisma.OrganizationMembershipUpdateInput; // Use Prisma type for flexibility
+}) {
+ return prisma.organizationMembership.update({
+ data,
+ where: { memberId_organizationId: { memberId: userId, organizationId } },
+ });
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/organizations-email-invite-link-model.server.ts b/apps/react-router/saas-template/app/features/organizations/organizations-email-invite-link-model.server.ts
new file mode 100644
index 0000000..649d29a
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/organizations-email-invite-link-model.server.ts
@@ -0,0 +1,97 @@
+import type {
+ Organization,
+ OrganizationEmailInviteLink,
+ Prisma,
+} from "~/generated/client";
+import { prisma } from "~/utils/database.server";
+
+/* CREATE */
+
+/**
+ * Saves an organization email invite link to the database.
+ *
+ * @param emailInviteLink - The email invite link to save.
+ * @returns The saved email invite link.
+ */
+export async function saveOrganizationEmailInviteLinkToDatabase(
+ emailInviteLink: Prisma.OrganizationEmailInviteLinkUncheckedCreateInput,
+) {
+ return prisma.organizationEmailInviteLink.create({ data: emailInviteLink });
+}
+
+/* READ */
+
+/**
+ * Retrieves an organization email invite link from the database by its ID.
+ *
+ * @param id - The ID of the email invite link to retrieve.
+ * @returns The email invite link or null if not found.
+ */
+export async function retrieveEmailInviteLinkFromDatabaseById(
+ id: OrganizationEmailInviteLink["id"],
+) {
+ return prisma.organizationEmailInviteLink.findUnique({
+ where: { id },
+ });
+}
+
+/**
+ * Retrieves an active organization email invite link from the database based on
+ * its token.
+ *
+ * @param token - The token of the email invite link to get.
+ * @returns The email invite link with a given token or null if it wasn't found
+ * or has expired.
+ */
+export async function retrieveActiveEmailInviteLinkFromDatabaseByToken(
+ token: OrganizationEmailInviteLink["token"],
+) {
+ const now = new Date();
+ return prisma.organizationEmailInviteLink.findUnique({
+ include: {
+ invitedBy: { select: { id: true, name: true } },
+ organization: { select: { id: true, name: true, slug: true } },
+ },
+ where: { deactivatedAt: null, expiresAt: { gt: now }, token },
+ });
+}
+
+/**
+ * Retrieves all active email invite links for an organization.
+ *
+ * @param organizationId - The id of the organization to retrieve the email
+ * invite links for.
+ * @returns An array of active email invite links for the organization.
+ */
+export async function retrieveActiveEmailInviteLinksFromDatabaseByOrganizationId(
+ organizationId: Organization["id"],
+) {
+ const now = new Date();
+ return prisma.organizationEmailInviteLink.findMany({
+ include: { invitedBy: { select: { id: true, name: true } } },
+ orderBy: { createdAt: "desc" },
+ where: { deactivatedAt: null, expiresAt: { gt: now }, organizationId },
+ });
+}
+
+/* UPDATE */
+
+/**
+ * Updates an email invite link in the database.
+ *
+ * @param id - The id of the email invite link to update.
+ * @param emailInviteLink - The email invite link to update.
+ * @returns The updated email invite link.
+ */
+export async function updateEmailInviteLinkInDatabaseById({
+ id,
+ emailInviteLink,
+}: {
+ id: OrganizationEmailInviteLink["id"];
+ emailInviteLink: Prisma.OrganizationEmailInviteLinkUncheckedUpdateInput;
+}) {
+ return prisma.organizationEmailInviteLink.update({
+ data: emailInviteLink,
+ where: { id },
+ });
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/organizations-factories.server.ts b/apps/react-router/saas-template/app/features/organizations/organizations-factories.server.ts
new file mode 100644
index 0000000..c40be97
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/organizations-factories.server.ts
@@ -0,0 +1,168 @@
+import { faker } from "@faker-js/faker";
+import { createId } from "@paralleldrive/cuid2";
+import { addDays } from "date-fns";
+
+import { createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct } from "../billing/billing-factories.server";
+import type { OrganizationWithMembershipsAndSubscriptions } from "../onboarding/onboarding-helpers.server";
+import type {
+ InviteLinkUse,
+ Organization,
+ OrganizationEmailInviteLink,
+ OrganizationInviteLink,
+ OrganizationMembership,
+} from "~/generated/client";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { slugify } from "~/utils/slugify.server";
+import type { Factory } from "~/utils/types";
+
+/* BASE */
+
+/**
+ * Creates an organization with populated values.
+ *
+ * @param organizationParams - Organization params to create organization with.
+ * @returns A populated organization with given params.
+ */
+export const createPopulatedOrganization: Factory = ({
+ id = createId(),
+ name = faker.company.name(),
+ slug = slugify(name),
+ updatedAt = faker.date.recent({ days: 10 }),
+ createdAt = faker.date.past({ refDate: updatedAt, years: 1 }),
+ imageUrl = faker.image.url(),
+ billingEmail = faker.internet.email(),
+ stripeCustomerId = `cus_${createId()}`,
+ trialEnd = addDays(createdAt, 14),
+} = {}) => ({
+ billingEmail,
+ createdAt,
+ id,
+ imageUrl,
+ name,
+ slug,
+ stripeCustomerId,
+ trialEnd,
+ updatedAt,
+});
+
+/**
+ * Creates an organization invite link with populated values.
+ *
+ * @param linkParams - OrganizationInviteLink params to create organization invite link with.
+ * @returns A populated organization invite link with given params.
+ */
+export const createPopulatedOrganizationInviteLink: Factory<
+ OrganizationInviteLink
+> = ({
+ updatedAt = faker.date.recent({ days: 1 }),
+ createdAt = faker.date.recent({ days: 1, refDate: updatedAt }),
+ id = createId(),
+ organizationId = createId(),
+ creatorId = createId(),
+ expiresAt = faker.date.soon({ days: 3, refDate: addDays(updatedAt, 2) }),
+ token = createId(),
+ deactivatedAt = null,
+} = {}) => ({
+ createdAt,
+ creatorId,
+ deactivatedAt,
+ expiresAt,
+ id,
+ organizationId,
+ token,
+ updatedAt,
+});
+
+/**
+ * Creates an invite link usage with populated values.
+ *
+ * @param usageParams - inviteLinkUse params to create invite link usage with.
+ * @returns A populated invite link usage with given params.
+ */
+export const createPopulatedInviteLinkUse: Factory = ({
+ updatedAt = faker.date.recent({ days: 1 }),
+ createdAt = faker.date.recent({ days: 1, refDate: updatedAt }),
+ id = createId(),
+ inviteLinkId = createId(),
+ userId = createId(),
+} = {}) => ({ createdAt, id, inviteLinkId, updatedAt, userId });
+
+/**
+ * Creates an organization membership with populated values.
+ *
+ * @param membershipParams - OrganizationMembership params to create membership with.
+ * @returns A populated organization membership with given params.
+ */
+export const createPopulatedOrganizationMembership: Factory<
+ OrganizationMembership
+> = ({
+ updatedAt = faker.date.recent({ days: 1 }),
+ createdAt = faker.date.recent({ days: 1, refDate: updatedAt }),
+ memberId = createId(),
+ organizationId = createId(),
+ role = "member",
+ deactivatedAt = null,
+} = {}) => ({
+ createdAt,
+ deactivatedAt,
+ memberId,
+ organizationId,
+ role,
+ updatedAt,
+});
+
+/**
+ * Creates an organization email invite link with populated values.
+ *
+ * @param emailInviteLinkParams - OrganizationEmailInviteLink params to create email invite link with.
+ * @returns A populated organization email invite link with given params.
+ */
+export const createPopulatedOrganizationEmailInviteLink: Factory<
+ OrganizationEmailInviteLink
+> = ({
+ updatedAt = faker.date.recent({ days: 1 }),
+ createdAt = faker.date.recent({ days: 1, refDate: updatedAt }),
+ id = createId(),
+ organizationId = createId(),
+ invitedById = createId(),
+ email = faker.internet.email(),
+ token = createId(),
+ role = OrganizationMembershipRole.member,
+ expiresAt = faker.date.soon({ days: 3, refDate: addDays(updatedAt, 2) }),
+ deactivatedAt = null,
+} = {}) => ({
+ createdAt,
+ deactivatedAt,
+ email,
+ expiresAt,
+ id,
+ invitedById,
+ organizationId,
+ role,
+ token,
+ updatedAt,
+});
+
+/* COMPOUND */
+
+/**
+ * Creates an organization with membership count and stripe subscriptions.
+ * This matches the shape needed for the OnboardingUser type's organization field.
+ *
+ * @param params - Parameters to create the organization with
+ * @param params.organization - Base organization object to extend
+ * @param params.memberCount - Number of members in the organization
+ * @param params.stripeSubscriptions - Array of stripe subscriptions for the organization
+ * @returns An organization with membership count and subscriptions
+ */
+export const createOrganizationWithMembershipsAndSubscriptions = ({
+ organization = createPopulatedOrganization(),
+ memberCount = faker.number.int({ max: 10, min: 1 }),
+ stripeSubscriptions = [
+ createPopulatedStripeSubscriptionWithScheduleAndItemsWithPriceAndProduct(),
+ ],
+} = {}): OrganizationWithMembershipsAndSubscriptions => ({
+ ...organization,
+ _count: { memberships: memberCount },
+ stripeSubscriptions,
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/organizations-helpers.server.test.ts b/apps/react-router/saas-template/app/features/organizations/organizations-helpers.server.test.ts
new file mode 100644
index 0000000..1d80859
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/organizations-helpers.server.test.ts
@@ -0,0 +1,174 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: Test code */
+
+import { describe, expect, test } from "vitest";
+
+import { createPopulatedOrganization } from "./organizations-factories.server";
+import {
+ findOrganizationIfUserIsMemberById,
+ findOrganizationIfUserIsMemberBySlug,
+} from "./organizations-helpers.server";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { createOnboardingUser } from "~/test/test-utils";
+import { notFound } from "~/utils/http-responses.server";
+
+describe("findOrganizationIfUserIsMemberBySlug()", () => {
+ test("given: a user who is a member of the organization, should: return the organization and role", () => {
+ const organization = createPopulatedOrganization();
+ const user = createOnboardingUser({
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization,
+ role: OrganizationMembershipRole.member,
+ },
+ ],
+ });
+
+ const actual = findOrganizationIfUserIsMemberBySlug(
+ user,
+ organization.slug,
+ );
+ const expected = {
+ organization: user.memberships[0]!.organization,
+ role: OrganizationMembershipRole.member,
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a user who is an admin of the organization, should: return the organization and admin role", () => {
+ const organization = createPopulatedOrganization();
+ const user = createOnboardingUser({
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization,
+ role: OrganizationMembershipRole.admin,
+ },
+ ],
+ });
+
+ const actual = findOrganizationIfUserIsMemberBySlug(
+ user,
+ organization.slug,
+ );
+ const expected = {
+ organization: user.memberships[0]!.organization,
+ role: OrganizationMembershipRole.admin,
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a user who is not a member of the organization, should: throw a 404", () => {
+ expect.assertions(1);
+
+ const user = createOnboardingUser({
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: createPopulatedOrganization(),
+ role: OrganizationMembershipRole.member,
+ },
+ ],
+ });
+ const nonExistentSlug = createPopulatedOrganization().slug;
+
+ try {
+ findOrganizationIfUserIsMemberBySlug(user, nonExistentSlug);
+ } catch (error) {
+ expect(error).toEqual(notFound());
+ }
+ });
+
+ test("given: a user with no memberships, should: throw a 404", () => {
+ expect.assertions(1);
+
+ const user = createOnboardingUser({ memberships: [] });
+ const organizationSlug = createPopulatedOrganization().slug;
+
+ try {
+ findOrganizationIfUserIsMemberBySlug(user, organizationSlug);
+ } catch (error) {
+ expect(error).toEqual(notFound());
+ }
+ });
+});
+
+describe("findOrganizationIfUserIsMemberById()", () => {
+ test("given: a user who is a member of the organization, should: return the organization and role", () => {
+ const organization = createPopulatedOrganization();
+ const user = createOnboardingUser({
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization,
+ role: OrganizationMembershipRole.member,
+ },
+ ],
+ });
+
+ const actual = findOrganizationIfUserIsMemberById(user, organization.id);
+ const expected = {
+ organization: user.memberships[0]!.organization,
+ role: OrganizationMembershipRole.member,
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a user who is an admin of the organization, should: return the organization and admin role", () => {
+ const organization = createPopulatedOrganization();
+ const user = createOnboardingUser({
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization,
+ role: OrganizationMembershipRole.admin,
+ },
+ ],
+ });
+
+ const actual = findOrganizationIfUserIsMemberById(user, organization.id);
+ const expected = {
+ organization: user.memberships[0]!.organization,
+ role: OrganizationMembershipRole.admin,
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a user who is not a member of the organization, should: throw a 404", () => {
+ expect.assertions(1);
+
+ const user = createOnboardingUser({
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: createPopulatedOrganization(),
+ role: OrganizationMembershipRole.member,
+ },
+ ],
+ });
+ const nonExistentId = createPopulatedOrganization().id;
+
+ try {
+ findOrganizationIfUserIsMemberById(user, nonExistentId);
+ } catch (error) {
+ expect(error).toEqual(notFound());
+ }
+ });
+
+ test("given: a user with no memberships, should: throw a 404", () => {
+ expect.assertions(1);
+
+ const user = createOnboardingUser({ memberships: [] });
+ const organizationId = createPopulatedOrganization().id;
+
+ try {
+ findOrganizationIfUserIsMemberById(user, organizationId);
+ } catch (error) {
+ expect(error).toEqual(notFound());
+ }
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/organizations-helpers.server.ts b/apps/react-router/saas-template/app/features/organizations/organizations-helpers.server.ts
new file mode 100644
index 0000000..02a4964
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/organizations-helpers.server.ts
@@ -0,0 +1,376 @@
+import type { FileUpload } from "@remix-run/form-data-parser";
+import type { SupabaseClient } from "@supabase/supabase-js";
+import type { i18n } from "i18next";
+import type { RouterContextProvider } from "react-router";
+import { href } from "react-router";
+import { promiseHash } from "remix-utils/promise";
+
+import {
+ adjustSeats,
+ deactivateStripeCustomer,
+} from "../billing/stripe-helpers.server";
+import type {
+ OnboardingUser,
+ OrganizationWithMembershipsAndSubscriptions,
+} from "../onboarding/onboarding-helpers.server";
+import { requireOnboardedUserAccountExists } from "../onboarding/onboarding-helpers.server";
+import { getValidEmailInviteInfo } from "./accept-email-invite/accept-email-invite-helpers.server";
+import { destroyEmailInviteInfoSession } from "./accept-email-invite/accept-email-invite-session.server";
+import { getValidInviteLinkInfo } from "./accept-invite-link/accept-invite-link-helpers.server";
+import { destroyInviteLinkInfoSession } from "./accept-invite-link/accept-invite-link-session.server";
+import { saveInviteLinkUseToDatabase } from "./accept-invite-link/invite-link-use-model.server";
+import { BUCKET_NAME, LOGO_PATH_PREFIX } from "./organization-constants";
+import { updateEmailInviteLinkInDatabaseById } from "./organizations-email-invite-link-model.server";
+import {
+ addMembersToOrganizationInDatabaseById,
+ deleteOrganizationFromDatabaseById,
+ retrieveMemberCountAndLatestStripeSubscriptionFromDatabaseByOrganizationId,
+ retrieveOrganizationWithSubscriptionsFromDatabaseById,
+} from "./organizations-model.server";
+import type {
+ Organization,
+ OrganizationEmailInviteLink,
+ OrganizationInviteLink,
+ UserAccount,
+} from "~/generated/client";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { combineHeaders } from "~/utils/combine-headers.server";
+import { notFound } from "~/utils/http-responses.server";
+import { createAdminS3Client } from "~/utils/s3.server";
+import { uploadToStorage } from "~/utils/storage.server";
+import { removeImageFromStorage } from "~/utils/storage-helpers.server";
+import { throwIfEntityIsMissing } from "~/utils/throw-if-entity-is-missing.server";
+import { redirectWithToast } from "~/utils/toast.server";
+
+/**
+ * Finds an organization by ID if the given user is a member of it.
+ *
+ * @param user - The user to check membership for.
+ * @param organizationId - The ID of the organization to find.
+ * @returns The organization if found and user is a member.
+ * @throws {Response} 404 Not Found if user is not a member or organization
+ * doesn't exist.
+ */
+export function findOrganizationIfUserIsMemberById(
+ user: User,
+ organizationId: Organization["id"],
+) {
+ const membership = user.memberships.find(
+ (membership) => membership.organization.id === organizationId,
+ );
+
+ if (!membership) {
+ throw notFound();
+ }
+
+ const organization = throwIfEntityIsMissing(membership.organization);
+
+ return { organization, role: membership.role };
+}
+
+/**
+ * Finds an organization by slug if the given user is a member of it.
+ *
+ * @param user - The user to check membership for.
+ * @param organizationSlug - The slug of the organization to find.
+ * @returns The organization if found and user is a member.
+ * @throws {Response} 404 Not Found if user is not a member or organization
+ * doesn't exist.
+ */
+export function findOrganizationIfUserIsMemberBySlug<
+ User extends OnboardingUser,
+>(user: User, organizationSlug: Organization["slug"]) {
+ const membership = user.memberships.find(
+ (membership) => membership.organization.slug === organizationSlug,
+ );
+
+ if (!membership) {
+ throw notFound();
+ }
+
+ const organization = throwIfEntityIsMissing(membership.organization);
+
+ return { organization, role: membership.role };
+}
+
+/**
+ * Requires that the authenticated user from the request is a member of the
+ * specified organization.
+ *
+ * @param request - The incoming request.
+ * @param organizationSlug - The slug of the organization to check membership
+ * for.
+ * @returns Object containing the user, organization and auth headers.
+ * @throws {Response} 404 Not Found if user is not a member or organization
+ * doesn't exist.
+ */
+export async function requireUserIsMemberOfOrganization({
+ context,
+ request,
+ organizationSlug,
+}: {
+ context: Readonly;
+ request: Request;
+ organizationSlug: Organization["slug"];
+}) {
+ const { user, headers } = await requireOnboardedUserAccountExists({
+ context,
+ request,
+ });
+ const { organization, role } = findOrganizationIfUserIsMemberBySlug(
+ user,
+ organizationSlug,
+ );
+ return { headers, organization, role, user };
+}
+
+/**
+ * Deletes an organization and all associated subscriptions.
+ *
+ * @param organizationId - The ID of the organization to delete.
+ */
+export async function deleteOrganization(organizationId: Organization["id"]) {
+ const organization =
+ await retrieveOrganizationWithSubscriptionsFromDatabaseById(organizationId);
+
+ if (organization) {
+ if (organization.stripeCustomerId) {
+ await deactivateStripeCustomer(organization.stripeCustomerId);
+ }
+
+ await removeImageFromStorage(organization.imageUrl);
+
+ await deleteOrganizationFromDatabaseById(organizationId);
+ }
+}
+
+/**
+ * Accepts an invite link and adds the user to the organization. Also adjusts
+ * the number of seats on the organization's subscription if it exists.
+ *
+ * @param userAccountId - The ID of the user account to add to the organization.
+ * @param organizationId - The ID of the organization to add the user to.
+ * @param inviteLinkId - The ID of the invite link to accept.
+ */
+export async function acceptInviteLink({
+ i18n,
+ inviteLinkId,
+ inviteLinkToken,
+ organizationId,
+ request,
+ userAccountId,
+}: {
+ i18n: i18n;
+ inviteLinkId: OrganizationInviteLink["id"];
+ inviteLinkToken: OrganizationInviteLink["token"];
+ organizationId: Organization["id"];
+ request: Request;
+ userAccountId: UserAccount["id"];
+}) {
+ const organization =
+ await retrieveMemberCountAndLatestStripeSubscriptionFromDatabaseByOrganizationId(
+ organizationId,
+ );
+
+ if (organization) {
+ const subscription = organization.stripeSubscriptions[0];
+
+ if (subscription) {
+ const maxSeats = subscription.items[0]?.price.product.maxSeats ?? 25;
+
+ if (organization._count.memberships >= maxSeats) {
+ throw await redirectWithToast(
+ `${href("/organizations/invite-link")}?token=${inviteLinkToken}`,
+ {
+ description: i18n.t(
+ "organizations:acceptInviteLink.organizationFullToastDescription",
+ ),
+ title: i18n.t(
+ "organizations:acceptInviteLink.organizationFullToastTitle",
+ ),
+ type: "error",
+ },
+ { headers: await destroyInviteLinkInfoSession(request) },
+ );
+ }
+ }
+
+ await addMembersToOrganizationInDatabaseById({
+ id: organizationId,
+ members: [userAccountId],
+ role: OrganizationMembershipRole.member,
+ });
+ await saveInviteLinkUseToDatabase({
+ inviteLinkId,
+ userId: userAccountId,
+ });
+
+ if (
+ subscription &&
+ subscription.status !== "canceled" &&
+ subscription.items[0]
+ ) {
+ await adjustSeats({
+ newQuantity: organization._count.memberships + 1,
+ subscriptionId: subscription.stripeId,
+ subscriptionItemId: subscription.items[0].stripeId,
+ });
+ }
+ }
+}
+
+/**
+ * Accepts an email invite and adds the user to the organization. Also adjusts
+ * the number of seats on the organization's subscription if it exists.
+ *
+ * @param userAccountId - The ID of the user account to add to the organization.
+ * @param organizationId - The ID of the organization to add the user to.
+ * @param inviteLinkId - The ID of the invite link to accept.
+ */
+export async function acceptEmailInvite({
+ deactivatedAt = new Date(),
+ emailInviteId,
+ emailInviteToken,
+ i18n,
+ organizationId,
+ request,
+ role,
+ userAccountId,
+}: {
+ deactivatedAt?: OrganizationEmailInviteLink["deactivatedAt"];
+ emailInviteId: OrganizationEmailInviteLink["id"];
+ emailInviteToken: OrganizationEmailInviteLink["token"];
+ i18n: i18n;
+ organizationId: Organization["id"];
+ request: Request;
+ role: OrganizationMembershipRole;
+ userAccountId: UserAccount["id"];
+}) {
+ const organization =
+ await retrieveMemberCountAndLatestStripeSubscriptionFromDatabaseByOrganizationId(
+ organizationId,
+ );
+
+ if (organization) {
+ const subscription = organization.stripeSubscriptions[0];
+
+ if (subscription) {
+ const maxSeats = subscription.items[0]?.price.product.maxSeats ?? 25;
+
+ if (organization._count.memberships >= maxSeats) {
+ throw await redirectWithToast(
+ `${href("/organizations/email-invite")}?token=${emailInviteToken}`,
+ {
+ description: i18n.t(
+ "organizations:acceptEmailInvite.organizationFullToastDescription",
+ ),
+ title: i18n.t(
+ "organizations:acceptEmailInvite.organizationFullToastTitle",
+ ),
+ type: "error",
+ },
+ { headers: await destroyEmailInviteInfoSession(request) },
+ );
+ }
+ }
+
+ await addMembersToOrganizationInDatabaseById({
+ id: organizationId,
+ members: [userAccountId],
+ role,
+ });
+ await updateEmailInviteLinkInDatabaseById({
+ emailInviteLink: { deactivatedAt },
+ id: emailInviteId,
+ });
+
+ if (
+ subscription &&
+ subscription.status !== "canceled" &&
+ subscription.items[0]
+ ) {
+ await adjustSeats({
+ newQuantity: organization._count.memberships + 1,
+ subscriptionId: subscription.stripeId,
+ subscriptionItemId: subscription.items[0].stripeId,
+ });
+ }
+ }
+}
+
+/**
+ * Checks if the organization is full.
+ *
+ * @param organization - The organization to check.
+ * @returns `true` if the organization is full; otherwise, `false`.
+ */
+export const getOrganizationIsFull = (
+ organization: OrganizationWithMembershipsAndSubscriptions,
+) => {
+ const currentSubscription = organization.stripeSubscriptions[0];
+ const currentSubscriptionIsActive =
+ !!currentSubscription &&
+ !["canceled", "past_due"].includes(currentSubscription.status);
+ const maxSeats =
+ (currentSubscriptionIsActive &&
+ currentSubscription.items[0]?.price.product.maxSeats) ||
+ 25;
+ return organization._count.memberships >= maxSeats;
+};
+
+/**
+ * Retrieves the invite info from the request.
+ *
+ * @param request - The request to get the invite info from.
+ * @returns The invite info.
+ */
+export async function getInviteInfoForAuthRoutes(request: Request) {
+ const { inviteLinkInfo, emailInviteInfo } = await promiseHash({
+ emailInviteInfo: getValidEmailInviteInfo(request),
+ inviteLinkInfo: getValidInviteLinkInfo(request),
+ });
+
+ return {
+ headers: combineHeaders(inviteLinkInfo.headers, emailInviteInfo.headers),
+ inviteLinkInfo: emailInviteInfo.emailInviteInfo
+ ? {
+ creatorName: emailInviteInfo.emailInviteInfo.inviterName,
+ inviteLinkId: emailInviteInfo.emailInviteInfo.emailInviteId,
+ organizationName: emailInviteInfo.emailInviteInfo.organizationName,
+ organizationSlug: emailInviteInfo.emailInviteInfo.organizationSlug,
+ type: "emailInvite",
+ }
+ : inviteLinkInfo.inviteLinkInfo
+ ? { ...inviteLinkInfo.inviteLinkInfo, type: "inviteLink" }
+ : undefined,
+ };
+}
+
+/**
+ * Uploads an organization's logo to storage and returns its public URL.
+ *
+ * @param file - The logo file to upload
+ * @param organizationId - The ID of the organization whose logo is being uploaded
+ * @param supabase - The Supabase client instance
+ * @returns The public URL of the uploaded logo
+ */
+export async function uploadOrganizationLogo({
+ file,
+ organizationId,
+ supabase,
+}: {
+ file: File | FileUpload;
+ organizationId: string;
+ supabase: SupabaseClient;
+}) {
+ const fileExtension = file.name.split(".").pop() ?? "";
+ const key = `${LOGO_PATH_PREFIX}/${organizationId}.${fileExtension}`;
+ await uploadToStorage({
+ bucket: BUCKET_NAME,
+ client: createAdminS3Client(),
+ contentType: file.type,
+ file,
+ key,
+ });
+ return supabase.storage.from(BUCKET_NAME).getPublicUrl(key).data.publicUrl;
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/organizations-invite-link-model.server.ts b/apps/react-router/saas-template/app/features/organizations/organizations-invite-link-model.server.ts
new file mode 100644
index 0000000..9492ceb
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/organizations-invite-link-model.server.ts
@@ -0,0 +1,140 @@
+import type { OrganizationInviteLink, Prisma } from "~/generated/client";
+import { prisma } from "~/utils/database.server";
+
+/* CREATE */
+
+/**
+ * Saves an organization invite link to the database.
+ *
+ * @param inviteLink - The invite link to save.
+ * @returns The saved invite link.
+ */
+export async function saveOrganizationInviteLinkToDatabase(
+ inviteLink: Prisma.OrganizationInviteLinkUncheckedCreateInput,
+) {
+ return prisma.organizationInviteLink.create({ data: inviteLink });
+}
+
+/* READ */
+
+/**
+ * Retrieves an organization invite link from the database by id.
+ *
+ * @param id - The id of the organization invite link to retrieve.
+ * @returns The organization invite link or null if not found.
+ */
+export async function retrieveOrganizationInviteLinkFromDatabaseById(
+ id: OrganizationInviteLink["id"],
+) {
+ return prisma.organizationInviteLink.findUnique({ where: { id } });
+}
+
+/**
+ * Retrieves an active organization invite link from the database based on
+ * its id.
+ *
+ * @param id - The id of the organization invite link to get.
+ * @returns The organization invite link with a given id or null if it
+ * wasn't found or its deactivated or expired.
+ */
+export async function retrieveActiveOrganizationInviteLinkFromDatabaseByToken(
+ token: OrganizationInviteLink["token"],
+) {
+ return prisma.organizationInviteLink.findUnique({
+ include: {
+ creator: { select: { id: true, name: true } },
+ organization: { select: { id: true, name: true, slug: true } },
+ },
+ where: { deactivatedAt: null, expiresAt: { gt: new Date() }, token },
+ });
+}
+
+/**
+ * Retrieves an active organization invite link and its associated creator and
+ * organization from the database based on the token.
+ *
+ * @param token - The token of the OrganizationInviteLink to retrieve.
+ * @returns An object containing the invite link id, creator details, expiration
+ * date, and organization details, or null if no active link was found.
+ */
+export async function retrieveCreatorAndOrganizationForActiveLinkFromDatabaseByToken(
+ token: OrganizationInviteLink["token"],
+) {
+ return prisma.organizationInviteLink.findUnique({
+ select: {
+ creator: { select: { id: true, name: true } },
+ expiresAt: true,
+ id: true,
+ organization: { select: { id: true, name: true } },
+ },
+ where: { deactivatedAt: null, expiresAt: { gt: new Date() }, token },
+ });
+}
+
+/**
+ * Retrieves the latest active invite link for an organization.
+ *
+ * @param organizationId - The id of the organization to retrieve the invite
+ * link for.
+ * @returns The latest active invite link or null if not found.
+ */
+export async function retrieveLatestInviteLinkFromDatabaseByOrganizationId(
+ organizationId: OrganizationInviteLink["organizationId"],
+) {
+ return prisma.organizationInviteLink.findFirst({
+ orderBy: { createdAt: "desc" },
+ take: 1,
+ where: {
+ deactivatedAt: null,
+ expiresAt: { gt: new Date() },
+ organizationId,
+ },
+ });
+}
+
+/**
+ * Retrieves an active OrganizationInviteLink record from the database based on
+ * its token.
+ *
+ * @param token - The token of the OrganizationInviteLink to get.
+ * @returns The OrganizationInviteLink with a given token or null if it wasn't
+ * found or its deactivated or expired.
+ */
+export async function retrieveActiveInviteLinkFromDatabaseByToken(
+ token: OrganizationInviteLink["token"],
+) {
+ const now = new Date();
+ return prisma.organizationInviteLink.findFirst({
+ select: {
+ creator: { select: { id: true, name: true } },
+ deactivatedAt: true,
+ expiresAt: true,
+ id: true,
+ organization: { select: { id: true, name: true, slug: true } },
+ token: true,
+ },
+ where: { deactivatedAt: null, expiresAt: { gt: now }, token },
+ });
+}
+
+/* UPDATE */
+
+/**
+ * Updates an organization invite link by its id.
+ *
+ * @param id - The id of the invite link to update.
+ * @param organizationInviteLink - The new data for the invite link.
+ * @returns The updated invite link.
+ */
+export async function updateOrganizationInviteLinkInDatabaseById({
+ id,
+ organizationInviteLink,
+}: {
+ id: OrganizationInviteLink["id"];
+ organizationInviteLink: Prisma.OrganizationInviteLinkUpdateInput;
+}) {
+ return prisma.organizationInviteLink.update({
+ data: organizationInviteLink,
+ where: { id },
+ });
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/organizations-middleware.server.ts b/apps/react-router/saas-template/app/features/organizations/organizations-middleware.server.ts
new file mode 100644
index 0000000..5b56602
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/organizations-middleware.server.ts
@@ -0,0 +1,43 @@
+import type { MiddlewareFunction } from "react-router";
+import { createContext } from "react-router";
+
+import type { OnboardingUser } from "../onboarding/onboarding-helpers.server";
+import { requireUserIsMemberOfOrganization } from "./organizations-helpers.server";
+import type { OrganizationMembershipRole } from "~/generated/client";
+
+export const organizationMembershipContext = createContext<{
+ headers: Headers;
+ organization: OnboardingUser["memberships"][number]["organization"];
+ role: OrganizationMembershipRole;
+ user: OnboardingUser;
+}>();
+
+export const organizationMembershipMiddleware: MiddlewareFunction = async (
+ { request, params, context },
+ next,
+) => {
+ const organizationSlug = params.organizationSlug;
+
+ if (!organizationSlug) {
+ throw new Error(
+ "organizationMembershipMiddleware: organizationSlug parameter is required. " +
+ "This middleware must only be applied to routes with an $organizationSlug parameter.",
+ );
+ }
+
+ const { user, organization, role, headers } =
+ await requireUserIsMemberOfOrganization({
+ context,
+ organizationSlug,
+ request,
+ });
+
+ context.set(organizationMembershipContext, {
+ headers,
+ organization,
+ role,
+ user,
+ });
+
+ return await next();
+};
diff --git a/apps/react-router/saas-template/app/features/organizations/organizations-model.server.ts b/apps/react-router/saas-template/app/features/organizations/organizations-model.server.ts
new file mode 100644
index 0000000..deec4b9
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/organizations-model.server.ts
@@ -0,0 +1,357 @@
+import type Stripe from "stripe";
+
+import type { StripeSubscriptionWithItemsAndPrice } from "../billing/billing-factories.server";
+import type {
+ Organization,
+ OrganizationMembership,
+ Prisma,
+ UserAccount,
+} from "~/generated/client";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { prisma } from "~/utils/database.server";
+
+/* CREATE */
+
+/**
+ * Saves a new organization to the database.
+ *
+ * @param organization - Parameters of the organization that should be created.
+ * @returns The newly created organization.
+ */
+export async function saveOrganizationToDatabase(
+ organization: Prisma.OrganizationCreateInput,
+) {
+ return prisma.organization.create({ data: organization });
+}
+
+/**
+ * Saves a new organization to the database with an owner.
+ *
+ * @param organization - Parameters of the organization that should be created.
+ * @param userId - The id of the user who will be the owner.
+ * @returns The newly created organization.
+ */
+export async function saveOrganizationWithOwnerToDatabase({
+ organization,
+ userId,
+}: {
+ organization: Omit & {
+ trialEnd?: Date;
+ };
+ userId: UserAccount["id"];
+}) {
+ return prisma.organization.create({
+ // @ts-expect-error - trialEnd will be set in the Prisma middleware.
+ data: {
+ ...organization,
+ memberships: {
+ create: { memberId: userId, role: OrganizationMembershipRole.owner },
+ },
+ },
+ });
+}
+
+/* READ */
+
+/**
+ * Retrieves an organization by its id.
+ *
+ * @param id - The id of the organization to retrieve.
+ * @returns The organization or null if not found.
+ */
+export async function retrieveOrganizationFromDatabaseById(
+ id: Organization["id"],
+) {
+ return prisma.organization.findUnique({ where: { id } });
+}
+
+/**
+ * Retrieves an organization by its slug with memberships.
+ *
+ * @param slug - The slug of the organization to retrieve.
+ * @returns The organization with memberships or null if not found.
+ */
+export async function retrieveOrganizationWithMembershipsFromDatabaseBySlug(
+ slug: Organization["slug"],
+) {
+ return prisma.organization.findUnique({
+ include: { memberships: { include: { member: true } } },
+ where: { slug },
+ });
+}
+
+export async function retrieveOrganizationWithSubscriptionsFromDatabaseById(
+ id: Organization["id"],
+) {
+ return prisma.organization.findUnique({
+ include: {
+ stripeSubscriptions: {
+ include: { items: { include: { price: true } } },
+ },
+ },
+ where: { id },
+ });
+}
+
+/**
+ * Retrieves an organization by its slug with memberships.
+ *
+ * @param slug - The slug of the organization to retrieve.
+ * @returns The organization with memberships and subscriptions or null if not found.
+ */
+export async function retrieveOrganizationWithMembershipsAndSubscriptionsFromDatabaseBySlug(
+ slug: Organization["slug"],
+) {
+ return prisma.organization.findUnique({
+ include: {
+ memberships: { include: { member: true } },
+ stripeSubscriptions: { include: { items: { include: { price: true } } } },
+ },
+ where: { slug },
+ });
+}
+
+/**
+ * Retrieves an organization by its slug with memberships and latest active
+ * invite links (both regular and email invites).
+ *
+ * @param slug - The slug of the organization to retrieve.
+ * @returns The organization with memberships and latest active invite links or
+ * null if not found.
+ */
+export async function retrieveOrganizationWithMembersAndLatestInviteLinkFromDatabaseBySlug(
+ slug: Organization["slug"],
+) {
+ const now = new Date();
+ return prisma.organization.findUnique({
+ include: {
+ memberships: {
+ include: { member: true },
+ orderBy: { createdAt: "desc" },
+ },
+ organizationEmailInviteLink: {
+ orderBy: { createdAt: "desc" },
+ where: { deactivatedAt: null, expiresAt: { gt: now } },
+ },
+ organizationInviteLinks: {
+ orderBy: { createdAt: "desc" },
+ take: 1,
+ where: { deactivatedAt: null, expiresAt: { gt: now } },
+ },
+ stripeSubscriptions: {
+ include: {
+ items: { include: { price: { include: { product: true } } } },
+ },
+ orderBy: { created: "desc" },
+ take: 1,
+ },
+ },
+ where: { slug },
+ });
+}
+
+/**
+ * Retrieves a count of members and the latest Stripe subscription for an
+ * organization.
+ *
+ * @param organizationId - The id of the organization to retrieve.
+ * @returns The count of members and the latest Stripe subscription.
+ */
+export async function retrieveMemberCountAndLatestStripeSubscriptionFromDatabaseByOrganizationId(
+ organizationId: Organization["id"],
+) {
+ return prisma.organization.findUnique({
+ select: {
+ _count: {
+ select: {
+ memberships: {
+ where: {
+ OR: [
+ { deactivatedAt: null },
+ { deactivatedAt: { gt: new Date() } },
+ ],
+ },
+ },
+ },
+ },
+ stripeSubscriptions: {
+ include: {
+ items: { include: { price: { include: { product: true } } } },
+ schedule: {
+ include: {
+ phases: {
+ include: { price: true },
+ orderBy: { startDate: "asc" },
+ },
+ },
+ },
+ },
+ orderBy: { created: "desc" },
+ take: 1,
+ },
+ },
+ where: { id: organizationId },
+ });
+}
+
+/* UPDATE */
+
+/**
+ * Updates an organization by its id.
+ *
+ * @param id - The id of the organization to update.
+ * @param organization - The new data for the organization.
+ * @returns The updated organization.
+ */
+export async function updateOrganizationInDatabaseById({
+ id,
+ organization,
+}: {
+ id: Organization["id"];
+ organization: Omit;
+}) {
+ return prisma.organization.update({ data: organization, where: { id } });
+}
+
+/**
+ * Updates an organization by its slug.
+ *
+ * @param slug - The slug of the organization to update.
+ * @param organization - The new data for the organization.
+ * @returns The updated organization.
+ */
+export async function updateOrganizationInDatabaseBySlug({
+ slug,
+ organization,
+}: {
+ slug: Organization["slug"];
+ organization: Omit;
+}) {
+ return prisma.organization.update({ data: organization, where: { slug } });
+}
+
+/**
+ * Adds members to an organization.
+ *
+ * @param options - An object with the organization's id, the id of the user who
+ * assigned the members and the ids of the members.
+ * @returns The updated organization.
+ */
+export async function addMembersToOrganizationInDatabaseById({
+ id,
+ members,
+ role = OrganizationMembershipRole.member,
+}: {
+ id: Organization["id"];
+ members: UserAccount["id"][];
+ role?: OrganizationMembership["role"];
+}) {
+ return prisma.organization.update({
+ data: {
+ // 1) add each member
+ memberships: {
+ create: members.map((memberId) => ({
+ member: { connect: { id: memberId } },
+ role,
+ })),
+ },
+ // 2) create a NotificationPanel for each new member
+ notificationPanels: {
+ create: members.map((memberId) => ({
+ user: { connect: { id: memberId } },
+ })),
+ },
+ },
+ where: { id },
+ });
+}
+
+/**
+ * Upserts a Stripe subscription (with items and prices) into an organization.
+ *
+ * @param organizationId - The id of the organization.
+ * @param purchasedById - The id of the user who bought the subscription.
+ * @param stripeCustomerId - The id of the Stripe customer.
+ * @param stripeSubscription - The subscription object from Stripe.
+ * @returns The updated organization.
+ */
+export async function upsertStripeSubscriptionForOrganizationInDatabaseById({
+ organizationId,
+ purchasedById,
+ stripeCustomerId,
+ subscription,
+}: {
+ organizationId: Organization["id"];
+ purchasedById: UserAccount["id"];
+ stripeCustomerId: Stripe.Customer["id"];
+ subscription: StripeSubscriptionWithItemsAndPrice;
+}) {
+ return await prisma.organization.update({
+ data: {
+ stripeCustomerId,
+ stripeSubscriptions: {
+ upsert: {
+ create: {
+ cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
+ created: subscription.created,
+ items: {
+ create: subscription.items.map((item) => ({
+ currentPeriodEnd: item.currentPeriodEnd,
+ currentPeriodStart: item.currentPeriodStart,
+ priceId: item.priceId,
+ stripeId: item.stripeId,
+ })),
+ },
+ purchasedById,
+ status: subscription.status,
+ stripeId: subscription.stripeId,
+ },
+ update: {
+ cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
+ created: subscription.created,
+ items: {
+ create: subscription.items.map((item) => ({
+ currentPeriodEnd: item.currentPeriodEnd,
+ currentPeriodStart: item.currentPeriodStart,
+ priceId: item.priceId,
+ stripeId: item.stripeId,
+ })),
+ deleteMany: {}, // Delete existing items first (to prevent duplicates)
+ },
+ purchasedById,
+ status: subscription.status,
+ stripeId: subscription.stripeId,
+ },
+ where: { organizationId, stripeId: subscription.stripeId },
+ },
+ },
+ },
+ include: {
+ stripeSubscriptions: {
+ include: {
+ items: { include: { price: { include: { product: true } } } },
+ schedule: {
+ include: {
+ phases: { include: { price: { include: { product: true } } } },
+ },
+ },
+ },
+ },
+ },
+ where: { id: organizationId },
+ });
+}
+
+/* DELETE */
+
+/**
+ * Deletes an organization from the database.
+ *
+ * @param id - The id of the organization to delete.
+ * @returns The deleted organization.
+ */
+export async function deleteOrganizationFromDatabaseById(
+ id: Organization["id"],
+) {
+ return prisma.organization.delete({ where: { id } });
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/general/danger-zone.tsx b/apps/react-router/saas-template/app/features/organizations/settings/general/danger-zone.tsx
new file mode 100644
index 0000000..fb4b7fe
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/general/danger-zone.tsx
@@ -0,0 +1,185 @@
+import { useForm } from "@conform-to/react/future";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { Form, useNavigation } from "react-router";
+import { useHydrated } from "remix-utils/use-hydrated";
+import { z } from "zod";
+
+import { DELETE_ORGANIZATION_INTENT } from "./general-settings-constants";
+import { deleteOrganizationFormSchema } from "./general-settings-schemas";
+import { Button } from "~/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog";
+import { Field, FieldError, FieldLabel, FieldSet } from "~/components/ui/field";
+import { Input } from "~/components/ui/input";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemTitle,
+} from "~/components/ui/item";
+import { Spinner } from "~/components/ui/spinner";
+
+export type DangerZoneProps = {
+ organizationName: string;
+};
+
+function DeleteOrganizationDialogComponent({
+ organizationName,
+}: DangerZoneProps) {
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "settings.general.dangerZone",
+ });
+
+ const localDeleteOrganizationFormSchema = useMemo(
+ () =>
+ deleteOrganizationFormSchema.and(
+ z.object({
+ confirmation: z
+ .string()
+ .min(1, {
+ message:
+ "organizations:settings.general.dangerZone.errors.confirmationRequired",
+ })
+ .refine((value) => value === organizationName, {
+ message:
+ "organizations:settings.general.dangerZone.errors.confirmationMismatch",
+ }),
+ }),
+ ),
+ [organizationName],
+ );
+
+ const { form, fields, intent } = useForm(localDeleteOrganizationFormSchema, {
+ lastResult: null,
+ shouldRevalidate: "onInput",
+ shouldValidate: "onInput",
+ });
+
+ const navigation = useNavigation();
+ const isSubmitting =
+ navigation.state === "submitting" &&
+ navigation.formData?.get("intent") === DELETE_ORGANIZATION_INTENT;
+ const hydrated = useHydrated();
+
+ return (
+ {
+ if (!isOpen) {
+ intent.reset();
+ }
+ }}
+ >
+
+ }
+ >
+ {t("triggerButton")}
+
+
+
+
+ {t("dialogTitle")}
+ {t("dialogDescription")}
+
+
+
+
+
+
+ {t("confirmationLabel", { organizationName })}
+
+
+
+
+
+
+
+
+
+ }
+ >
+ {t("cancelButton")}
+
+
+
+ {isSubmitting ? (
+ <>
+
+ {t("deleteButtonSubmitting")}
+ >
+ ) : (
+ t("deleteButton")
+ )}
+
+
+
+
+ );
+}
+
+export function DangerZone({ organizationName }: DangerZoneProps) {
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "settings.general.dangerZone",
+ });
+
+ return (
+
+
+ {t("title")}
+
+ -
+
+ {t("deleteTitle")}
+ {t("deleteDescription")}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/general/general-organization-settings-action.server.ts b/apps/react-router/saas-template/app/features/organizations/settings/general/general-organization-settings-action.server.ts
new file mode 100644
index 0000000..fbb5c1a
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/general/general-organization-settings-action.server.ts
@@ -0,0 +1,144 @@
+import { coerceFormValue } from "@conform-to/zod/v4/future";
+import { data, href } from "react-router";
+import { z } from "zod";
+
+import {
+ deleteOrganization,
+ uploadOrganizationLogo,
+} from "../../organizations-helpers.server";
+import { organizationMembershipContext } from "../../organizations-middleware.server";
+import { updateOrganizationInDatabaseBySlug } from "../../organizations-model.server";
+import {
+ DELETE_ORGANIZATION_INTENT,
+ UPDATE_ORGANIZATION_INTENT,
+} from "./general-settings-constants";
+import {
+ deleteOrganizationFormSchema,
+ updateOrganizationFormSchema,
+} from "./general-settings-schemas";
+import type { Route } from ".react-router/types/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/settings+/+types/general";
+import { updateStripeCustomer } from "~/features/billing/stripe-helpers.server";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { authContext } from "~/features/user-authentication/user-authentication-middleware.server";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { combineHeaders } from "~/utils/combine-headers.server";
+import { forbidden } from "~/utils/http-responses.server";
+import { slugify } from "~/utils/slugify.server";
+import { removeImageFromStorage } from "~/utils/storage-helpers.server";
+import { createToastHeaders, redirectWithToast } from "~/utils/toast.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+const generalOrganizationSettingsActionSchema = coerceFormValue(
+ z.discriminatedUnion("intent", [
+ deleteOrganizationFormSchema,
+ updateOrganizationFormSchema,
+ ]),
+);
+
+export async function generalOrganizationSettingsAction({
+ request,
+ params,
+ context,
+}: Route.ActionArgs) {
+ const { headers, organization, role } = context.get(
+ organizationMembershipContext,
+ );
+ const i18n = getInstance(context);
+
+ if (role !== OrganizationMembershipRole.owner) {
+ return forbidden();
+ }
+
+ const result = await validateFormData(
+ request,
+ generalOrganizationSettingsActionSchema,
+ {
+ maxFileSize: 1_000_000, // 1MB
+ },
+ );
+
+ if (!result.success) {
+ return result.response;
+ }
+
+ switch (result.data.intent) {
+ case UPDATE_ORGANIZATION_INTENT: {
+ const updates: { name?: string; slug?: string; imageUrl?: string } = {};
+
+ if (result.data.name && result.data.name !== organization.name) {
+ const newSlug = slugify(result.data.name);
+ updates.name = result.data.name;
+ updates.slug = newSlug;
+ }
+
+ if (result.data.logo) {
+ const { supabase } = context.get(authContext);
+ // Remove old logo if it exists
+ if (organization.imageUrl) {
+ await removeImageFromStorage(organization.imageUrl);
+ }
+ // Upload new logo
+ const imageUrl = await uploadOrganizationLogo({
+ file: result.data.logo,
+ organizationId: organization.id,
+ supabase,
+ });
+ updates.imageUrl = imageUrl;
+ }
+
+ if (Object.keys(updates).length > 0) {
+ await updateOrganizationInDatabaseBySlug({
+ organization: updates,
+ slug: params.organizationSlug,
+ });
+
+ if (updates.name && organization.stripeCustomerId) {
+ await updateStripeCustomer({
+ customerId: organization.stripeCustomerId,
+ customerName: updates.name,
+ });
+ }
+
+ if (updates.slug) {
+ return redirectWithToast(
+ href(`/organizations/:organizationSlug/settings/general`, {
+ organizationSlug: updates.slug,
+ }),
+ {
+ title: i18n.t(
+ "organizations:settings.general.toast.organizationProfileUpdated",
+ ),
+ type: "success",
+ },
+ { headers },
+ );
+ }
+ }
+
+ const toastHeaders = await createToastHeaders({
+ title: i18n.t(
+ "organizations:settings.general.toast.organizationProfileUpdated",
+ ),
+ type: "success",
+ });
+ return data(
+ { result: undefined },
+ { headers: combineHeaders(headers, toastHeaders) },
+ );
+ }
+
+ case DELETE_ORGANIZATION_INTENT: {
+ await deleteOrganization(organization.id);
+ return redirectWithToast(
+ href("/organizations"),
+ {
+ title: i18n.t(
+ "organizations:settings.general.toast.organizationDeleted",
+ ),
+ type: "success",
+ },
+ { headers },
+ );
+ }
+ }
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/general/general-organization-settings.tsx b/apps/react-router/saas-template/app/features/organizations/settings/general/general-organization-settings.tsx
new file mode 100644
index 0000000..421a03d
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/general/general-organization-settings.tsx
@@ -0,0 +1,219 @@
+import type { SubmissionResult } from "@conform-to/react/future";
+import { useForm } from "@conform-to/react/future";
+import { coerceFormValue } from "@conform-to/zod/v4/future";
+import { Trans, useTranslation } from "react-i18next";
+import { Form, useNavigation } from "react-router";
+
+import { UPDATE_ORGANIZATION_INTENT } from "./general-settings-constants";
+import { updateOrganizationFormSchema } from "./general-settings-schemas";
+import {
+ AvatarUpload,
+ AvatarUploadDescription,
+ AvatarUploadInput,
+ AvatarUploadPreviewImage,
+} from "~/components/avatar-upload";
+import { Avatar, AvatarFallback } from "~/components/ui/avatar";
+import { Button } from "~/components/ui/button";
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSet,
+} from "~/components/ui/field";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "~/components/ui/hover-card";
+import { Input } from "~/components/ui/input";
+import { Spinner } from "~/components/ui/spinner";
+import type { Organization } from "~/generated/browser";
+
+const ONE_MB = 1_000_000;
+
+export type GeneralOrganizationSettingsProps = {
+ lastResult?: SubmissionResult;
+ organization: Pick;
+};
+
+function WarningHoverCard({
+ children,
+ content,
+}: {
+ children?: React.ReactNode;
+ content: string;
+}) {
+ return (
+
+ }
+ >
+ {children}
+
+
+ {content}
+
+
+ );
+}
+
+export function GeneralOrganizationSettings({
+ lastResult,
+ organization,
+}: GeneralOrganizationSettingsProps) {
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "settings.general",
+ });
+
+ const { form, fields } = useForm(
+ coerceFormValue(updateOrganizationFormSchema),
+ {
+ lastResult,
+ },
+ );
+
+ const navigation = useNavigation();
+ const isSubmitting =
+ navigation.state === "submitting" &&
+ navigation.formData?.get("intent") === UPDATE_ORGANIZATION_INTENT;
+
+ return (
+ 0
+ ? `${form.descriptionId} ${form.errorId}`
+ : form.descriptionId
+ }
+ aria-invalid={form.errors && form.errors.length > 0 ? true : undefined}
+ >
+
+
+ {t("pageTitle")}
+
+ {t("description")}
+
+
+ {/* Organization Name */}
+
+
+
+ {t("form.nameLabel")}
+
+
+
+ ,
+ warning: (
+
+ ),
+ }}
+ i18nKey="form.nameDescription"
+ parent={null}
+ t={t}
+ />
+
+
+
+
+
+
+
+
+
+
+ {/* Logo Upload */}
+
+ {({ error }) => (
+
+
+
+ {t("form.logoLabel")}
+
+
+ {t("form.logoDescription")}
+
+
+
+
+
+
+
+
+ {organization.name.slice(0, 2).toUpperCase()}
+
+
+
+
+
+
+ {t("form.logoFormats")}
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ {isSubmitting ? (
+ <>
+
+ {t("form.saving")}
+ >
+ ) : (
+ t("form.save")
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/general/general-settings-constants.ts b/apps/react-router/saas-template/app/features/organizations/settings/general/general-settings-constants.ts
new file mode 100644
index 0000000..f7f04e5
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/general/general-settings-constants.ts
@@ -0,0 +1,2 @@
+export const DELETE_ORGANIZATION_INTENT = "delete-organization";
+export const UPDATE_ORGANIZATION_INTENT = "updateOrganization";
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/general/general-settings-schemas.ts b/apps/react-router/saas-template/app/features/organizations/settings/general/general-settings-schemas.ts
new file mode 100644
index 0000000..c943877
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/general/general-settings-schemas.ts
@@ -0,0 +1,38 @@
+import { z } from "zod";
+
+import {
+ DELETE_ORGANIZATION_INTENT,
+ UPDATE_ORGANIZATION_INTENT,
+} from "./general-settings-constants";
+
+const ONE_MB = 1_000_000;
+const MIN_NAME_LENGTH = 3;
+const MAX_NAME_LENGTH = 255;
+
+z.config({ jitless: true });
+
+export const deleteOrganizationFormSchema = z.object({
+ intent: z.literal(DELETE_ORGANIZATION_INTENT),
+});
+
+export const updateOrganizationFormSchema = z.object({
+ intent: z.literal(UPDATE_ORGANIZATION_INTENT),
+ logo: z
+ .file()
+ .max(ONE_MB, {
+ message: "organizations:settings.general.errors.logoTooLarge",
+ })
+ .mime(["image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp"], {
+ message: "organizations:settings.general.errors.invalidFileType",
+ })
+ .optional(),
+ name: z
+ .string()
+ .trim()
+ .min(MIN_NAME_LENGTH, {
+ message: "organizations:settings.general.errors.nameMin",
+ })
+ .max(MAX_NAME_LENGTH, {
+ message: "organizations:settings.general.errors.nameMax",
+ }),
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/general/organization-info.test.tsx b/apps/react-router/saas-template/app/features/organizations/settings/general/organization-info.test.tsx
new file mode 100644
index 0000000..d40c419
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/general/organization-info.test.tsx
@@ -0,0 +1,62 @@
+import { faker } from "@faker-js/faker";
+import { describe, expect, test } from "vitest";
+
+import { createPopulatedOrganization } from "../../organizations-factories.server";
+import type { OrganizationInfoProps } from "./organization-info";
+import { OrganizationInfo } from "./organization-info";
+import { createRoutesStub, render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createProps: Factory = ({
+ organizationName = createPopulatedOrganization().name,
+ organizationLogoUrl = createPopulatedOrganization().imageUrl,
+} = {}) => ({ organizationLogoUrl, organizationName });
+
+describe("OrganizationInfo Component", () => {
+ test("given: organization data, should: render organization name", () => {
+ const props = createProps();
+ const path = "/organizations/test/settings/general";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify organization name is displayed
+ expect(screen.getByText(props.organizationName)).toBeInTheDocument();
+ });
+
+ test("given: organization without logo, should: render organization name and fallback avatar", () => {
+ const organizationName = faker.company.name();
+ const props = createProps({ organizationLogoUrl: "", organizationName });
+ const path = "/organizations/test/settings/general";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify organization name is displayed
+ expect(screen.getByText(organizationName)).toBeInTheDocument();
+
+ // Verify fallback avatar is displayed with initials
+ const fallback = screen.getByText(
+ organizationName.slice(0, 2).toUpperCase(),
+ );
+ expect(fallback).toBeInTheDocument();
+ });
+
+ test("given: component renders, should: not have any interactive elements", () => {
+ const props = createProps();
+ const path = "/organizations/test/settings/general";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify no buttons or inputs are present
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/general/organization-info.tsx b/apps/react-router/saas-template/app/features/organizations/settings/general/organization-info.tsx
new file mode 100644
index 0000000..1fe8201
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/general/organization-info.tsx
@@ -0,0 +1,56 @@
+import { useTranslation } from "react-i18next";
+
+import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
+
+export type OrganizationInfoProps = {
+ organizationName: string;
+ organizationLogoUrl: string;
+};
+
+export function OrganizationInfo({
+ organizationName,
+ organizationLogoUrl,
+}: OrganizationInfoProps) {
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "settings.general.organizationInfo",
+ });
+
+ return (
+
+
+
+
{t("nameTitle")}
+
+ {t("nameDescription")}
+
+
+
+
+
+
+
+
+
{t("logoTitle")}
+
+ {t("logoDescription")}
+
+
+
+
+
+
+
+ {organizationName.slice(0, 2).toUpperCase()}
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/settings-sidebar.tsx b/apps/react-router/saas-template/app/features/organizations/settings/settings-sidebar.tsx
new file mode 100644
index 0000000..51a3ed7
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/settings-sidebar.tsx
@@ -0,0 +1,79 @@
+import { IconBuilding, IconCreditCard, IconUsers } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { href, useMatch } from "react-router";
+
+import { NavGroup } from "../layout/nav-group";
+import type { OrganizationMembershipRole } from "~/generated/browser";
+import { cn } from "~/lib/utils";
+
+type SettingsSidebarProps = {
+ organizationSlug: string;
+ role: OrganizationMembershipRole;
+};
+
+export function SettingsSidebar({
+ organizationSlug,
+ role,
+}: SettingsSidebarProps) {
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "settings.layout",
+ });
+
+ // Check if we're exactly on the settings index route
+ const isOnSettingsIndex = useMatch(
+ href("/organizations/:organizationSlug/settings", {
+ organizationSlug,
+ }),
+ );
+
+ return (
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-by-email-card.test.tsx b/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-by-email-card.test.tsx
new file mode 100644
index 0000000..2ef08e8
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-by-email-card.test.tsx
@@ -0,0 +1,148 @@
+import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
+
+import type { EmailInviteCardProps } from "./invite-by-email-card";
+import { EmailInviteCard } from "./invite-by-email-card";
+import { createPopulatedOrganization } from "~/features/organizations/organizations-factories.server";
+import {
+ createRoutesStub,
+ render,
+ screen,
+ userEvent,
+ waitFor,
+} from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createProps: Factory = ({
+ currentUserIsOwner = false,
+ lastResult,
+ isInvitingByEmail = false,
+ organizationIsFull = false,
+} = {}) => ({
+ currentUserIsOwner,
+ isInvitingByEmail,
+ lastResult,
+ organizationIsFull,
+});
+
+const originalHasPointerCapture = (pointerId: number) =>
+ globalThis.HTMLElement.prototype.hasPointerCapture.call(
+ globalThis.HTMLElement.prototype,
+ pointerId,
+ );
+
+beforeAll(() => {
+ globalThis.HTMLElement.prototype.hasPointerCapture = vi.fn();
+});
+
+afterAll(() => {
+ globalThis.HTMLElement.prototype.hasPointerCapture =
+ originalHasPointerCapture;
+});
+
+describe("EmailInviteCard Component", () => {
+ test("given: component renders, should: display card with title, description, and form elements", () => {
+ const props = createProps();
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify card title and description
+ expect(screen.getByText(/invite by email/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /enter your colleagues' email addresses, and we'll send them a personalized invitation to join your organization. you can also choose the role they'll join with./i,
+ ),
+ ).toBeInTheDocument();
+
+ // Verify form elements
+ expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/role/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /send email invitation/i }),
+ ).toBeInTheDocument();
+ });
+
+ test("given: isInvitingByEmail is true, should: disable form and show loading state", () => {
+ const props = createProps({ isInvitingByEmail: true });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify form is disabled
+ expect(screen.getByLabelText(/email/i)).toBeDisabled();
+ expect(screen.getByLabelText(/role/i)).toBeDisabled();
+ expect(screen.getByRole("button")).toBeDisabled();
+
+ // Verify loading state
+ expect(screen.getByText(/sending/i)).toBeInTheDocument();
+ });
+
+ // Note: Validation error display is tested in the integration tests (members.spec.ts)
+ // as it requires a properly structured SubmissionResult from Conform which is complex to mock
+
+ test("given: current user is NOT an owner, should: not show owner role option", async () => {
+ const props = createProps({ currentUserIsOwner: false });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Open the select dropdown
+ const select = screen.getByRole("combobox", { name: /role/i });
+ await userEvent.click(select);
+
+ // Wait for dropdown to open and verify owner option is not present
+ await waitFor(() => {
+ expect(screen.queryByRole("option", { name: /owner/i })).toBeNull();
+ });
+ });
+
+ test("given: current user is an owner, should: show owner role option", async () => {
+ const props = createProps({ currentUserIsOwner: true });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Open the select dropdown
+ const select = screen.getByRole("combobox", { name: /role/i });
+ await userEvent.click(select);
+
+ // Wait for dropdown to open and verify owner option is present
+ await waitFor(() => {
+ expect(
+ screen.getByRole("option", { name: /owner/i }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ test("given: organization is full, should: disable form", () => {
+ const props = createProps({ organizationIsFull: true });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify form is disabled
+ expect(screen.getByLabelText(/email/i)).toBeDisabled();
+ expect(screen.getByLabelText(/role/i)).toBeDisabled();
+ expect(screen.getByRole("button")).toBeDisabled();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-by-email-card.tsx b/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-by-email-card.tsx
new file mode 100644
index 0000000..38e5824
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-by-email-card.tsx
@@ -0,0 +1,181 @@
+import type { SubmissionResult } from "@conform-to/react/future";
+import { useForm } from "@conform-to/react/future";
+import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { Form } from "react-router";
+import { useHydrated } from "remix-utils/use-hydrated";
+
+import { INVITE_BY_EMAIL_INTENT } from "./team-members-constants";
+import { inviteByEmailSchema } from "./team-members-settings-schemas";
+import { Button } from "~/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { Field, FieldError, FieldLabel, FieldSet } from "~/components/ui/field";
+import { Input } from "~/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import { Spinner } from "~/components/ui/spinner";
+import { OrganizationMembershipRole } from "~/generated/browser";
+
+export type EmailInviteCardProps = {
+ currentUserIsOwner: boolean;
+ isInvitingByEmail?: boolean;
+ lastResult?: SubmissionResult;
+ organizationIsFull?: boolean;
+ successEmail?: string;
+};
+
+export function EmailInviteCard({
+ currentUserIsOwner,
+ isInvitingByEmail = false,
+ lastResult,
+ organizationIsFull = false,
+ successEmail,
+}: EmailInviteCardProps) {
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "settings.teamMembers.inviteByEmail",
+ });
+
+ const { form, fields, intent } = useForm(inviteByEmailSchema, {
+ lastResult,
+ });
+
+ // If the invite was successful, clear the email input
+ useEffect(() => {
+ if (successEmail) {
+ intent.reset();
+ }
+ }, [successEmail, intent]);
+
+ const hydrated = useHydrated();
+ const disabled = isInvitingByEmail || organizationIsFull;
+
+ return (
+
+
+ {t("cardTitle")}
+
+ {t("cardDescription")}
+
+
+
+
+
+
+
+
+
+ {t("form.email")}
+
+
+
+
+
+
+
+ {t("form.role")}
+
+
+
+
+
+ {(value) => {
+ if (value === OrganizationMembershipRole.member)
+ return t("form.roleMember");
+ if (value === OrganizationMembershipRole.admin)
+ return t("form.roleAdmin");
+ if (value === OrganizationMembershipRole.owner)
+ return t("form.roleOwner");
+ return null;
+ }}
+
+
+
+
+
+ {t("form.roleMember")}
+
+
+
+ {t("form.roleAdmin")}
+
+
+ {currentUserIsOwner && (
+
+ {t("form.roleOwner")}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isInvitingByEmail ? (
+ <>
+
+ {t("form.inviting")}
+ >
+ ) : (
+ t("form.submitButton")
+ )}
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-email.tsx b/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-email.tsx
new file mode 100644
index 0000000..8e4d455
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-email.tsx
@@ -0,0 +1,80 @@
+import { Button, Container, Html, Text } from "@react-email/components";
+
+type InviteEmailProps = {
+ /**
+ * Pre-translated and interpolated strings from:
+ * organizations.settings.team-members.invite-email.*
+ */
+ title: string;
+ description: string;
+ callToAction: string;
+ buttonText: string;
+ buttonUrl: string;
+};
+
+/**
+ * Email template for organization invites.
+ * Usage:
+ * ```tsx
+ *
+ * ```
+ */
+export function InviteEmail({
+ title,
+ description,
+ callToAction,
+ buttonText,
+ buttonUrl,
+}: InviteEmailProps) {
+ return (
+
+
+
+ {title}
+
+
+
+ {description}
+
+
+
+ {callToAction}
+
+
+
+ {buttonText}
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-link-card.test.tsx b/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-link-card.test.tsx
new file mode 100644
index 0000000..0dde07a
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-link-card.test.tsx
@@ -0,0 +1,132 @@
+import { faker } from "@faker-js/faker";
+import { describe, expect, test, vi } from "vitest";
+
+import {
+ createPopulatedOrganization,
+ createPopulatedOrganizationInviteLink,
+} from "../../organizations-factories.server";
+import type { InviteLinkCardProps } from "./invite-link-card";
+import { InviteLinkCard } from "./invite-link-card";
+import {
+ createRoutesStub,
+ render,
+ screen,
+ userEvent,
+} from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+vi.mock("copy-to-clipboard", () => ({ default: vi.fn() }));
+
+const createProps: Factory = ({
+ inviteLink = {
+ expiryDate: createPopulatedOrganizationInviteLink().expiresAt.toISOString(),
+ href: faker.internet.url(),
+ },
+ ...props
+} = {}) => ({ inviteLink, ...props });
+
+describe("TeamMembersInviteLinkCard component", () => {
+ test("given no invite link: renders the correct heading, description and a button to create a new invite link", () => {
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const props = createProps();
+ const RemixStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // It renders the correct heading and description.
+ expect(screen.getByText(/share an invite link/i)).toBeInTheDocument();
+ expect(screen.getByText(/valid for 48 hours/i)).toBeInTheDocument();
+
+ // It renders a button to generate a new invite link.
+ expect(
+ screen.getByRole("button", { name: /create new invite link/i }),
+ ).toHaveAttribute("type", "submit");
+ });
+
+ test("given an invite link: renders the correct heading, description, a button re-generate the invite link, a button to deactivate the invite link and button to copy the invite link", async () => {
+ const user = userEvent.setup();
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const props = createProps({
+ inviteLink: {
+ expiryDate: "2025-03-28T12:00:00.000Z",
+ href: faker.internet.url(),
+ },
+ });
+ const RemixStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // It renders the correct heading and description.
+ expect(screen.getByText(/share an invite link/i)).toBeInTheDocument();
+ expect(screen.getByText(/valid for 48 hours/i)).toBeInTheDocument();
+
+ // It renders the invite link, a button to copy the link, a button to
+ // regenerate it and a button to deactivate it.
+ await user.click(screen.getByRole("button", { name: /copy invite link/i }));
+ expect(
+ screen.getByRole("button", { name: /invite link copied/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /regenerate link/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /deactivate link/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: /go to the invite link's page/i }),
+ ).toHaveAttribute("href", props.inviteLink?.href);
+ expect(
+ screen.getByText(/Your link is valid until Friday, March 28, 2025/i),
+ ).toBeInTheDocument();
+ });
+
+ test("given: organization is full and no invite link, should: disable form", () => {
+ const props = createProps({ organizationIsFull: true });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // Verify form is disabled
+ expect(
+ screen.getByRole("button", { name: /create new invite link/i }),
+ ).toBeDisabled();
+ });
+
+ test("given: organization is full and has an invite link, should: disable form", () => {
+ const props = createProps({ organizationIsFull: true });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify form is disabled
+ expect(
+ screen.getByRole("button", { name: /regenerate link/i }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: /deactivate link/i }),
+ ).toBeEnabled();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-link-card.tsx b/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-link-card.tsx
new file mode 100644
index 0000000..b3a5c92
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/team-members/invite-link-card.tsx
@@ -0,0 +1,240 @@
+import {
+ IconAlertTriangle,
+ IconClipboardCheck,
+ IconCopy,
+} from "@tabler/icons-react";
+import copyToClipboard from "copy-to-clipboard";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Form, useNavigation } from "react-router";
+
+import {
+ CREATE_NEW_INVITE_LINK_INTENT,
+ DEACTIVATE_INVITE_LINK_INTENT,
+} from "./team-members-constants";
+import { Button } from "~/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { inputClassName } from "~/components/ui/input";
+import { Spinner } from "~/components/ui/spinner";
+import { cn } from "~/lib/utils";
+
+export type InviteLinkCardProps = {
+ inviteLink?: { href: string; expiryDate: string };
+ organizationIsFull?: boolean;
+};
+
+export function InviteLinkCard({
+ inviteLink,
+ organizationIsFull = false,
+}: InviteLinkCardProps) {
+ const { t, i18n } = useTranslation("organizations", {
+ keyPrefix: "settings.teamMembers.inviteLink",
+ });
+
+ const [linkCopied, setLinkCopied] = useState(false);
+
+ // Focus management, so that the input's auto focus the link if it changes.
+ const mounted = useRef(null);
+ const inviteLinkReference = useRef(null);
+
+ useEffect(() => {
+ if (mounted.current && inviteLink?.href) {
+ setLinkCopied(false);
+ inviteLinkReference.current?.focus();
+ }
+
+ // Guard against React 18's ghost remount.
+ mounted.current = mounted.current !== null;
+ }, [inviteLink?.href]);
+
+ const navigation = useNavigation();
+ const isCreatingNewLink =
+ navigation.formData?.get("intent") === CREATE_NEW_INVITE_LINK_INTENT;
+ const isDeactivatingLink =
+ navigation.formData?.get("intent") === DEACTIVATE_INVITE_LINK_INTENT;
+ const isSubmitting = isCreatingNewLink || isDeactivatingLink;
+
+ const formatDate = useCallback(
+ (isoString: string) => {
+ const date = new Date(isoString);
+ return new Intl.DateTimeFormat(i18n.language, {
+ dateStyle: "full", // e.g., "Wednesday, March 26, 2025"
+ timeStyle: "short", // e.g., "2:30 PM"
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, // Browser's timezone
+ }).format(date);
+ },
+ [i18n.language],
+ );
+
+ const disabled = isSubmitting || organizationIsFull;
+
+ return (
+
+
+ {t("cardTitle")}
+ {t("cardDescription")}
+
+
+ {inviteLink ? (
+ <>
+
+
+ {/** biome-ignore lint/a11y/useAnchorContent: the a tag has an aria-label */}
+
+
+ {inviteLink.href}
+
+
+
+
{
+ copyToClipboard(inviteLink.href);
+ setLinkCopied(true);
+ }}
+ size="icon"
+ variant="ghost"
+ >
+ {linkCopied ? (
+ <>
+
+
+ {t("inviteLinkCopied")}
+ >
+ ) : (
+ <>
+
+
+ {t("copyInviteLink")}
+ >
+ )}
+
+
+
+
+ {t("linkValidUntil", {
+ date: formatDate(inviteLink.expiryDate),
+ })}
+
+
+
+ {t("copied")}
+
+
+
+
+
+
+
+
+
+ {isCreatingNewLink ? (
+ <>
+
+ {t("regenerating")}
+ >
+ ) : (
+ t("regenerateLink")
+ )}
+
+
+
+
+
+ {isDeactivatingLink ? (
+ <>
+
+ {t("deactivating")}
+ >
+ ) : (
+ t("deactivateLink")
+ )}
+
+
+
+
+
+
+
+ {t("newLinkDeactivatesOld")}
+
+
+ >
+ ) : (
+
+
+
+ {isCreatingNewLink ? (
+ <>
+
+ {t("creating")}
+ >
+ ) : (
+ t("createNewInviteLink")
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-action.server.tsx b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-action.server.tsx
new file mode 100644
index 0000000..4a053b8
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-action.server.tsx
@@ -0,0 +1,385 @@
+import { report } from "@conform-to/react/future";
+import { createId } from "@paralleldrive/cuid2";
+import { addDays } from "date-fns";
+import { data } from "react-router";
+import { z } from "zod";
+
+import {
+ retrieveActiveOrganizationMembershipByEmailAndOrganizationId,
+ retrieveOrganizationMembershipFromDatabaseByUserIdAndOrganizationId,
+ updateOrganizationMembershipInDatabase,
+} from "../../organization-membership-model.server";
+import { saveOrganizationEmailInviteLinkToDatabase } from "../../organizations-email-invite-link-model.server";
+import { getOrganizationIsFull } from "../../organizations-helpers.server";
+import {
+ retrieveLatestInviteLinkFromDatabaseByOrganizationId,
+ saveOrganizationInviteLinkToDatabase,
+ updateOrganizationInviteLinkInDatabaseById,
+} from "../../organizations-invite-link-model.server";
+import { organizationMembershipContext } from "../../organizations-middleware.server";
+import { InviteEmail } from "./invite-email";
+import {
+ CHANGE_ROLE_INTENT,
+ CREATE_NEW_INVITE_LINK_INTENT,
+ DEACTIVATE_INVITE_LINK_INTENT,
+ INVITE_BY_EMAIL_INTENT,
+} from "./team-members-constants";
+import {
+ changeRoleSchema,
+ inviteByEmailSchema,
+} from "./team-members-settings-schemas";
+import type { Route } from ".react-router/types/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/settings+/+types/members";
+import { adjustSeats } from "~/features/billing/stripe-helpers.server";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import type { Prisma } from "~/generated/client";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { combineHeaders } from "~/utils/combine-headers.server";
+import { sendEmail } from "~/utils/email.server";
+import { getIsDataWithResponseInit } from "~/utils/get-is-data-with-response-init.server";
+import { badRequest, created, forbidden } from "~/utils/http-responses.server";
+import { createToastHeaders } from "~/utils/toast.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+const schema = z.discriminatedUnion("intent", [
+ inviteByEmailSchema,
+ z.object({ intent: z.literal(CREATE_NEW_INVITE_LINK_INTENT) }),
+ z.object({ intent: z.literal(DEACTIVATE_INVITE_LINK_INTENT) }),
+ changeRoleSchema,
+]);
+
+export async function teamMembersAction({
+ request,
+ context,
+}: Route.ActionArgs) {
+ try {
+ const { user, organization, role, headers } = context.get(
+ organizationMembershipContext,
+ );
+ const i18n = getInstance(context);
+
+ if (role === OrganizationMembershipRole.member) {
+ throw forbidden();
+ }
+
+ const result = await validateFormData(request, schema);
+
+ if (!result.success) {
+ return result.response;
+ }
+
+ const { data: body, submission } = result;
+
+ switch (body.intent) {
+ case CREATE_NEW_INVITE_LINK_INTENT: {
+ if (getOrganizationIsFull(organization)) {
+ return badRequest({
+ result: report(submission, {
+ error: {
+ fieldErrors: {
+ email: [
+ "organizations:settings.teamMembers.inviteByEmail.form.organizationFull",
+ ],
+ },
+ formErrors: [],
+ },
+ }),
+ });
+ }
+
+ // Deactivate any existing active invite link
+ const latestInviteLink =
+ await retrieveLatestInviteLinkFromDatabaseByOrganizationId(
+ organization.id,
+ );
+
+ if (latestInviteLink) {
+ await updateOrganizationInviteLinkInDatabaseById({
+ id: latestInviteLink.id,
+ organizationInviteLink: { deactivatedAt: new Date() },
+ });
+ }
+
+ // Create a new invite link that expires in 2 days
+ const token = createId();
+ const expiresAt = addDays(new Date(), 2);
+ await saveOrganizationInviteLinkToDatabase({
+ creatorId: user.id,
+ expiresAt,
+ organizationId: organization.id,
+ token,
+ });
+
+ return created({}, { headers });
+ }
+
+ case DEACTIVATE_INVITE_LINK_INTENT: {
+ const latestInviteLink =
+ await retrieveLatestInviteLinkFromDatabaseByOrganizationId(
+ organization.id,
+ );
+
+ if (latestInviteLink) {
+ await updateOrganizationInviteLinkInDatabaseById({
+ id: latestInviteLink.id,
+ organizationInviteLink: { deactivatedAt: new Date() },
+ });
+ }
+
+ return created({}, { headers });
+ }
+
+ case CHANGE_ROLE_INTENT: {
+ const { userId: targetUserId, role: requestedRoleOrStatus } = body;
+
+ // Prevent users from changing their own role/status
+ if (targetUserId === user.id) {
+ throw forbidden({
+ errors: { form: "You cannot change your own role or status." },
+ });
+ }
+
+ // Retrieve the target member's current membership details
+ const targetMembership =
+ await retrieveOrganizationMembershipFromDatabaseByUserIdAndOrganizationId(
+ {
+ organizationId: organization.id,
+ userId: targetUserId,
+ },
+ );
+
+ // Handle case where target user isn't found in this org
+ if (!targetMembership) {
+ throw badRequest({
+ errors: {
+ userId: "Target user is not a member of this organization.",
+ },
+ });
+ }
+
+ // Apply role-based permissions (requesting user's role = 'role')
+ if (role === OrganizationMembershipRole.admin) {
+ // Admins cannot modify Owners
+ if (targetMembership.role === OrganizationMembershipRole.owner) {
+ throw forbidden({
+ errors: {
+ form: "Administrators cannot modify the role or status of owners.",
+ },
+ });
+ }
+
+ // Admins also cannot promote others to Owner
+ if (requestedRoleOrStatus === OrganizationMembershipRole.owner) {
+ throw forbidden({
+ errors: {
+ form: "Administrators cannot promote members to the owner role.",
+ },
+ });
+ }
+ }
+ // Owners have full permissions (already checked for self-modification)
+
+ /// Get the subscription of the organization, if it exists.
+ const subscription = organization.stripeSubscriptions[0];
+ // Prepare the data for the database update
+ let updateData: Prisma.OrganizationMembershipUpdateInput;
+ if (requestedRoleOrStatus === "deactivated") {
+ // Set deactivatedAt timestamp
+ updateData = { deactivatedAt: new Date() };
+
+ if (subscription?.items[0]) {
+ await adjustSeats({
+ newQuantity: organization._count.memberships - 1,
+ subscriptionId: subscription.stripeId,
+ subscriptionItemId: subscription.items[0].stripeId,
+ });
+ }
+ } else {
+ // Update role and ensure deactivatedAt is null
+ // `requestedRoleOrStatus` here is guaranteed by zod schema to be
+ // 'member', 'admin', or 'owner'
+ const newRole = requestedRoleOrStatus;
+ updateData = { deactivatedAt: null, role: newRole };
+
+ // If the user was deactivated, and there is a subscription,
+ // they will now take up a seat again.
+ if (targetMembership.deactivatedAt) {
+ if (getOrganizationIsFull(organization)) {
+ const toastHeaders = await createToastHeaders({
+ description: i18n.t(
+ "organizations:settings.teamMembers.inviteByEmail.organizationFullToastDescription",
+ ),
+ title: i18n.t(
+ "organizations:settings.teamMembers.inviteByEmail.organizationFullToastTitle",
+ ),
+ type: "error",
+ });
+ return badRequest(
+ {
+ result: report(submission, {
+ error: {
+ fieldErrors: {
+ email: [
+ "organizations:settings.teamMembers.inviteByEmail.form.organizationFull",
+ ],
+ },
+ formErrors: [],
+ },
+ }),
+ },
+ { headers: combineHeaders(headers, toastHeaders) },
+ );
+ }
+
+ if (subscription?.items[0]) {
+ await adjustSeats({
+ newQuantity: organization._count.memberships + 1,
+ subscriptionId: subscription.stripeId,
+ subscriptionItemId: subscription.items[0].stripeId,
+ });
+ }
+ }
+ }
+
+ // Perform the database update
+ await updateOrganizationMembershipInDatabase({
+ data: updateData,
+ organizationId: organization.id,
+ userId: targetUserId,
+ });
+
+ // Return success
+ return data({}, { headers });
+ }
+
+ case INVITE_BY_EMAIL_INTENT: {
+ if (getOrganizationIsFull(organization)) {
+ return badRequest({
+ result: report(submission, {
+ error: {
+ fieldErrors: {
+ email: [
+ "organizations:settings.teamMembers.inviteByEmail.form.organizationFull",
+ ],
+ },
+ formErrors: [],
+ },
+ }),
+ });
+ }
+
+ if (
+ role !== OrganizationMembershipRole.owner &&
+ body.role === OrganizationMembershipRole.owner
+ ) {
+ return forbidden({
+ errors: {
+ message: "Only organization owners can invite as owners.",
+ },
+ });
+ }
+
+ const existingMember =
+ await retrieveActiveOrganizationMembershipByEmailAndOrganizationId({
+ email: body.email,
+ organizationId: organization.id,
+ });
+
+ if (existingMember) {
+ return badRequest({
+ result: report(submission, {
+ error: {
+ fieldErrors: {
+ email: [
+ i18n.t(
+ "organizations:settings.teamMembers.inviteByEmail.form.emailAlreadyMember",
+ { email: body.email },
+ ),
+ ],
+ },
+ formErrors: [],
+ },
+ }),
+ });
+ }
+
+ const emailInvite = await saveOrganizationEmailInviteLinkToDatabase({
+ email: body.email,
+ expiresAt: addDays(new Date(), 2),
+ invitedById: user.id,
+ organizationId: organization.id,
+ role: body.role,
+ });
+
+ const joinUrl = `${process.env.APP_URL}/organizations/email-invite?token=${emailInvite.token}`;
+
+ const result = await sendEmail({
+ react: (
+
+ ),
+ subject: i18n.t(
+ "organizations:settings.teamMembers.inviteByEmail.inviteEmail.subject",
+ {
+ appName: i18n.t("translation:appName"),
+ inviteName: user.name,
+ },
+ ),
+ to: body.email,
+ });
+
+ if (result.status === "error") {
+ return badRequest({
+ result: report(submission, {
+ error: {
+ fieldErrors: { email: [result.error.message] },
+ formErrors: [],
+ },
+ }),
+ });
+ }
+
+ const toastHeaders = await createToastHeaders({
+ title: i18n.t(
+ "organizations:settings.teamMembers.inviteByEmail.successToastTitle",
+ ),
+ type: "success",
+ });
+
+ return data(
+ { success: body.email },
+ { headers: combineHeaders(headers, toastHeaders) },
+ );
+ }
+ }
+ } catch (error) {
+ if (getIsDataWithResponseInit(error)) {
+ return error;
+ }
+
+ throw error;
+ }
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-constants.ts b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-constants.ts
new file mode 100644
index 0000000..f9f3bde
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-constants.ts
@@ -0,0 +1,4 @@
+export const CREATE_NEW_INVITE_LINK_INTENT = "createNewInviteLink";
+export const DEACTIVATE_INVITE_LINK_INTENT = "deactivateInviteLink";
+export const INVITE_BY_EMAIL_INTENT = "inviteByEmail";
+export const CHANGE_ROLE_INTENT = "changeRole";
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-helpers.server.test.ts b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-helpers.server.test.ts
new file mode 100644
index 0000000..c903763
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-helpers.server.test.ts
@@ -0,0 +1,534 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: test code */
+import { faker } from "@faker-js/faker";
+import { createId } from "@paralleldrive/cuid2";
+import { describe, expect, test } from "vitest";
+
+import {
+ createPopulatedOrganization,
+ createPopulatedOrganizationEmailInviteLink,
+ createPopulatedOrganizationInviteLink,
+ createPopulatedOrganizationMembership,
+} from "../../organizations-factories.server";
+import type { OrganizationWithMembers } from "./team-members-helpers.server";
+import {
+ mapOrganizationDataToTeamMemberSettingsProps,
+ tokenToInviteLink,
+} from "./team-members-helpers.server";
+import type { StripeSubscriptionWithItemsAndPriceAndProduct } from "~/features/billing/billing-factories.server";
+import { createPopulatedStripeSubscriptionWithItemsAndPriceAndProduct } from "~/features/billing/billing-factories.server";
+import { createPopulatedUserAccount } from "~/features/user-accounts/user-accounts-factories.server";
+import { OrganizationMembershipRole } from "~/generated/client";
+
+const createOrganizationWithLinksAndMembers = ({
+ emailInviteCount,
+ inviteLinkCount,
+ memberCount,
+ stripeSubscription,
+}: {
+ emailInviteCount: number;
+ inviteLinkCount: number;
+ memberCount: number;
+ stripeSubscription?: StripeSubscriptionWithItemsAndPriceAndProduct;
+}): OrganizationWithMembers => {
+ const organization = createPopulatedOrganization();
+ const memberships = Array.from({ length: memberCount }, () =>
+ createPopulatedUserAccount(),
+ ).map((member) => ({
+ ...createPopulatedOrganizationMembership({
+ memberId: member.id,
+ organizationId: organization.id,
+ }),
+ member,
+ }));
+ const links = Array.from({ length: inviteLinkCount }, () =>
+ createPopulatedOrganizationInviteLink({
+ creatorId: memberships[0]?.member.id,
+ organizationId: organization.id,
+ }),
+ );
+ const emailInvites = Array.from({ length: emailInviteCount }, () =>
+ createPopulatedOrganizationEmailInviteLink({
+ invitedById: memberships[0]?.member.id,
+ organizationId: organization.id,
+ role: faker.helpers.arrayElement([
+ OrganizationMembershipRole.member,
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ]),
+ }),
+ );
+ const stripeSubscriptions = stripeSubscription
+ ? [
+ createPopulatedStripeSubscriptionWithItemsAndPriceAndProduct(
+ stripeSubscription,
+ ),
+ ]
+ : [];
+ return {
+ ...organization,
+ memberships,
+ organizationEmailInviteLink: emailInvites,
+ organizationInviteLinks: links,
+ stripeSubscriptions,
+ };
+};
+
+describe("tokenToInviteLink()", () => {
+ test("given: a token and a request, should: return the invite link", () => {
+ const token = createId();
+ const basePath = "https://example.com";
+ const request = new Request(`${basePath}/foo`);
+
+ const actual = tokenToInviteLink(token, request);
+ const expected = `${basePath}/organizations/invite-link?token=${token}`;
+
+ expect(actual).toEqual(expected);
+ });
+});
+
+describe("mapOrganizationDataToTeamMemberSettingsProps()", () => {
+ test("given: an organization with just one member who is an owner, should: return the correct props", () => {
+ const currentUsersRole = OrganizationMembershipRole.owner;
+ const organization = createOrganizationWithLinksAndMembers({
+ emailInviteCount: 0,
+ inviteLinkCount: 1,
+ memberCount: 1,
+ });
+ organization.memberships[0]!.role = currentUsersRole;
+ const request = new Request("http://localhost");
+
+ const actual = mapOrganizationDataToTeamMemberSettingsProps({
+ currentUsersId: organization.memberships[0]!.member.id,
+ currentUsersRole,
+ organization,
+ request,
+ });
+ const expected = {
+ emailInviteCard: {
+ currentUserIsOwner: true,
+ organizationIsFull: false,
+ },
+ inviteLinkCard: {
+ inviteLink: {
+ expiryDate:
+ organization.organizationInviteLinks[0]!.expiresAt.toISOString(),
+ href: `http://localhost/organizations/invite-link?token=${organization.organizationInviteLinks[0]!.token}`,
+ },
+ organizationIsFull: false,
+ },
+ organizationIsFull: false,
+ teamMemberTable: {
+ currentUsersRole,
+ members: organization.memberships.map((membership) => ({
+ avatar: membership.member.imageUrl,
+ deactivatedAt: undefined,
+ email: membership.member.email,
+ id: membership.member.id,
+ isCurrentUser: true,
+ name: membership.member.name,
+ role: membership.role,
+ status: "createdTheOrganization",
+ })),
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an organization with multiple members, where the current user is a member and no link, should: return the correct props", () => {
+ const currentUsersRole = OrganizationMembershipRole.member;
+ const organization = createOrganizationWithLinksAndMembers({
+ emailInviteCount: 0,
+ inviteLinkCount: 0,
+ memberCount: 2,
+ });
+ organization.memberships[0]!.role = currentUsersRole;
+ const request = new Request("http://localhost");
+
+ const actual = mapOrganizationDataToTeamMemberSettingsProps({
+ currentUsersId: organization.memberships[0]!.member.id,
+ currentUsersRole,
+ organization,
+ request,
+ });
+ const expected = {
+ emailInviteCard: {
+ currentUserIsOwner: false,
+ organizationIsFull: false,
+ },
+ inviteLinkCard: {
+ inviteLink: undefined,
+ organizationIsFull: false,
+ },
+ organizationIsFull: false,
+ teamMemberTable: {
+ currentUsersRole,
+ members: organization.memberships.map((membership, index) => ({
+ avatar: membership.member.imageUrl,
+ deactivatedAt: undefined,
+ email: membership.member.email,
+ id: membership.member.id,
+ isCurrentUser: index === 0,
+ name: membership.member.name,
+ role: membership.role,
+ status: index === 0 ? "createdTheOrganization" : "joinedViaLink",
+ })),
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an organization with email invites and members, should: return email invites first sorted by most recent and then members", () => {
+ const currentUsersRole = OrganizationMembershipRole.owner;
+ const organization = createOrganizationWithLinksAndMembers({
+ emailInviteCount: 3,
+ inviteLinkCount: 0,
+ memberCount: 2,
+ });
+
+ // Set different dates for email invites to test sorting
+ organization.organizationEmailInviteLink[0]!.createdAt = new Date(
+ "2024-03-15",
+ );
+ organization.organizationEmailInviteLink[1]!.createdAt = new Date(
+ "2024-03-14",
+ );
+ organization.organizationEmailInviteLink[2]!.createdAt = new Date(
+ "2024-03-13",
+ );
+
+ const request = new Request("http://localhost");
+
+ const actual = mapOrganizationDataToTeamMemberSettingsProps({
+ currentUsersId: organization.memberships[0]!.member.id,
+ currentUsersRole,
+ organization,
+ request,
+ });
+
+ const expected = {
+ emailInviteCard: {
+ currentUserIsOwner: true,
+ organizationIsFull: false,
+ },
+ inviteLinkCard: {
+ inviteLink: undefined,
+ organizationIsFull: false,
+ },
+ organizationIsFull: false,
+ teamMemberTable: {
+ currentUsersRole,
+ members: [
+ // Email invites first, sorted by most recent
+ ...organization.organizationEmailInviteLink.map((invite) => ({
+ avatar: "",
+ deactivatedAt: undefined,
+ email: invite.email,
+ id: invite.id,
+ isCurrentUser: false,
+ name: "",
+ role: invite.role,
+ status: "emailInvitePending",
+ })),
+ // Then existing members
+ ...organization.memberships.map((membership, index) => ({
+ avatar: membership.member.imageUrl,
+ deactivatedAt: undefined,
+ email: membership.member.email,
+ id: membership.member.id,
+ isCurrentUser: index === 0,
+ name: membership.member.name,
+ role: membership.role,
+ status: index === 0 ? "createdTheOrganization" : "joinedViaLink",
+ })),
+ ],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an organization with only email invites and no members, should: return only email invites", () => {
+ const currentUsersRole = OrganizationMembershipRole.owner;
+ const organization = createOrganizationWithLinksAndMembers({
+ emailInviteCount: 2,
+ inviteLinkCount: 0,
+ memberCount: 0,
+ });
+
+ // Set different dates for email invites to test sorting
+ organization.organizationEmailInviteLink[0]!.createdAt = new Date(
+ "2024-03-15",
+ );
+ organization.organizationEmailInviteLink[1]!.createdAt = new Date(
+ "2024-03-14",
+ );
+
+ const request = new Request("http://localhost");
+
+ const actual = mapOrganizationDataToTeamMemberSettingsProps({
+ currentUsersId: "some-id",
+ currentUsersRole,
+ organization,
+ request,
+ });
+
+ const expected = {
+ emailInviteCard: {
+ currentUserIsOwner: true,
+ organizationIsFull: false,
+ },
+ inviteLinkCard: {
+ inviteLink: undefined,
+ organizationIsFull: false,
+ },
+ organizationIsFull: false,
+ teamMemberTable: {
+ currentUsersRole,
+ members: organization.organizationEmailInviteLink.map((invite) => ({
+ avatar: "",
+ deactivatedAt: undefined,
+ email: invite.email,
+ id: invite.id,
+ isCurrentUser: false,
+ name: "",
+ role: invite.role,
+ status: "emailInvitePending",
+ })),
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: multiple email invites for the same email, should: only show the latest invite", () => {
+ const currentUsersRole = OrganizationMembershipRole.owner;
+ const organization = createOrganizationWithLinksAndMembers({
+ emailInviteCount: 3,
+ inviteLinkCount: 0,
+ memberCount: 0,
+ });
+
+ // Set same email for all invites but different dates
+ const sameEmail = "test@example.com";
+ organization.organizationEmailInviteLink[0]!.email = sameEmail;
+ organization.organizationEmailInviteLink[0]!.createdAt = new Date(
+ "2024-03-15",
+ );
+ organization.organizationEmailInviteLink[1]!.email = sameEmail;
+ organization.organizationEmailInviteLink[1]!.createdAt = new Date(
+ "2024-03-14",
+ );
+ organization.organizationEmailInviteLink[2]!.email = sameEmail;
+ organization.organizationEmailInviteLink[2]!.createdAt = new Date(
+ "2024-03-13",
+ );
+
+ const request = new Request("http://localhost");
+
+ const actual = mapOrganizationDataToTeamMemberSettingsProps({
+ currentUsersId: "some-id",
+ currentUsersRole,
+ organization,
+ request,
+ });
+
+ // Should only show one invite with the latest date
+ expect(actual.teamMemberTable.members).toHaveLength(1);
+ expect(actual.teamMemberTable.members[0]).toEqual({
+ avatar: "",
+ deactivatedAt: undefined,
+ email: sameEmail,
+ id: organization.organizationEmailInviteLink[0]!.id,
+ isCurrentUser: false,
+ name: "",
+ role: organization.organizationEmailInviteLink[0]!.role,
+ status: "emailInvitePending",
+ });
+ });
+
+ test("given: an organization that has reached the subscription seat limit, should: return correct props with organizationIsFull true", () => {
+ const currentUsersRole = OrganizationMembershipRole.member;
+ const stripeSubscriptionOverride = {
+ items: [{ price: { product: { maxSeats: 1 } } }],
+ } as unknown as StripeSubscriptionWithItemsAndPriceAndProduct;
+ const organization = createOrganizationWithLinksAndMembers({
+ emailInviteCount: 0,
+ inviteLinkCount: 0,
+ memberCount: 1,
+ stripeSubscription: stripeSubscriptionOverride,
+ });
+ organization.memberships[0]!.role = currentUsersRole;
+ const request = new Request("http://localhost");
+
+ const actual = mapOrganizationDataToTeamMemberSettingsProps({
+ currentUsersId: organization.memberships[0]!.member.id,
+ currentUsersRole,
+ organization,
+ request,
+ });
+
+ const expected = {
+ emailInviteCard: {
+ currentUserIsOwner: false,
+ organizationIsFull: true,
+ },
+ inviteLinkCard: {
+ inviteLink: undefined,
+ organizationIsFull: true,
+ },
+ organizationIsFull: true,
+ teamMemberTable: {
+ currentUsersRole,
+ members: organization.memberships.map((membership, index) => ({
+ avatar: membership.member.imageUrl,
+ deactivatedAt: undefined,
+ email: membership.member.email,
+ id: membership.member.id,
+ isCurrentUser: index === 0,
+ name: membership.member.name,
+ role: membership.role,
+ status: index === 0 ? "createdTheOrganization" : "joinedViaLink",
+ })),
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an organization that has reached the subscription seat limit, should: return undefined for inviteLink even if link exists", () => {
+ const currentUsersRole = OrganizationMembershipRole.member;
+ const stripeSubscriptionOverride =
+ createPopulatedStripeSubscriptionWithItemsAndPriceAndProduct({
+ items: [{ price: { product: { maxSeats: 1 } } }],
+ });
+ const organization = createOrganizationWithLinksAndMembers({
+ emailInviteCount: 0,
+ inviteLinkCount: 1, // Create a link that should be ignored
+ memberCount: 1,
+ stripeSubscription: stripeSubscriptionOverride,
+ });
+ organization.memberships[0]!.role = currentUsersRole;
+ const request = new Request("http://localhost");
+
+ const actual = mapOrganizationDataToTeamMemberSettingsProps({
+ currentUsersId: organization.memberships[0]!.member.id,
+ currentUsersRole,
+ organization,
+ request,
+ });
+
+ const expected = {
+ emailInviteCard: {
+ currentUserIsOwner: false,
+ organizationIsFull: true,
+ },
+ inviteLinkCard: {
+ inviteLink: undefined, // Should be undefined even though link exists
+ organizationIsFull: true,
+ },
+ organizationIsFull: true,
+ teamMemberTable: {
+ currentUsersRole,
+ members: organization.memberships.map((membership, index) => ({
+ avatar: membership.member.imageUrl,
+ deactivatedAt: undefined,
+ email: membership.member.email,
+ id: membership.member.id,
+ isCurrentUser: index === 0,
+ name: membership.member.name,
+ role: membership.role,
+ status: index === 0 ? "createdTheOrganization" : "joinedViaLink",
+ })),
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an invite for an email that already has a membership, should: not show that invite", () => {
+ const currentUsersRole = OrganizationMembershipRole.owner;
+ // Create one member with a known email…
+ const member = createPopulatedUserAccount({ email: "foo@bar.com" });
+ const organization = {
+ ...createPopulatedOrganization(),
+ memberships: [
+ {
+ ...createPopulatedOrganizationMembership({
+ memberId: member.id,
+ organizationId: "org-id",
+ role: OrganizationMembershipRole.owner,
+ }),
+ member,
+ },
+ ],
+ // Two invites: one for the existing member email, one for a new email
+ organizationEmailInviteLink: [
+ createPopulatedOrganizationEmailInviteLink({
+ createdAt: new Date("2024-04-01"),
+ email: "foo@bar.com", // should be filtered out
+ invitedById: member.id,
+ organizationId: "org-id",
+ }),
+ createPopulatedOrganizationEmailInviteLink({
+ createdAt: new Date("2024-04-02"),
+ email: "new@invite.com", // should be kept
+ invitedById: member.id,
+ organizationId: "org-id",
+ }),
+ ],
+ organizationInviteLinks: [],
+ stripeSubscriptions: [],
+ };
+
+ const request = new Request("http://localhost");
+
+ const actual = mapOrganizationDataToTeamMemberSettingsProps({
+ currentUsersId: member.id,
+ currentUsersRole,
+ organization,
+ request,
+ });
+ const expected = {
+ emailInviteCard: {
+ currentUserIsOwner: true,
+ organizationIsFull: false,
+ },
+ inviteLinkCard: {
+ inviteLink: undefined,
+ organizationIsFull: false,
+ },
+ organizationIsFull: false,
+ teamMemberTable: {
+ currentUsersRole: OrganizationMembershipRole.owner,
+ members: [
+ // only the new invite, since foo@bar.com is already a member
+ {
+ avatar: "",
+ deactivatedAt: undefined,
+ email: "new@invite.com",
+ id: organization.organizationEmailInviteLink[1]!.id,
+ isCurrentUser: false,
+ name: "",
+ role: organization.organizationEmailInviteLink[1]!.role,
+ status: "emailInvitePending",
+ },
+ // then the existing member
+ {
+ avatar: member.imageUrl,
+ deactivatedAt: undefined,
+ email: member.email,
+ id: member.id,
+ isCurrentUser: true,
+ name: member.name,
+ role: OrganizationMembershipRole.owner, // same as currentUsersRole
+ status: "createdTheOrganization",
+ },
+ ],
+ },
+ };
+
+ expect(actual).toEqual(expected);
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-helpers.server.ts b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-helpers.server.ts
new file mode 100644
index 0000000..ba958ed
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-helpers.server.ts
@@ -0,0 +1,145 @@
+import type { EmailInviteCardProps } from "./invite-by-email-card";
+import type { InviteLinkCardProps } from "./invite-link-card";
+import type { TeamMembersTableProps } from "./team-members-table";
+import { retrieveOrganizationWithMembersAndLatestInviteLinkFromDatabaseBySlug } from "~/features/organizations/organizations-model.server";
+import type { OrganizationMembership, UserAccount } from "~/generated/client";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { asyncPipe } from "~/utils/async-pipe.server";
+import { throwIfEntityIsMissing } from "~/utils/throw-if-entity-is-missing.server";
+
+/**
+ * Converts a token to an invite link.
+ *
+ * @param token - The token to convert.
+ * @param request - The request object.
+ * @returns The invite link.
+ */
+export const tokenToInviteLink = (token: string, request: Request) => {
+ const requestUrl = new URL(request.url);
+ const url = new URL("/organizations/invite-link", requestUrl.origin);
+ url.searchParams.set("token", token);
+ return url.toString();
+};
+
+export const requireOrganizationWithMembersAndLatestInviteLinkExistsBySlug =
+ asyncPipe(
+ retrieveOrganizationWithMembersAndLatestInviteLinkFromDatabaseBySlug,
+ throwIfEntityIsMissing,
+ );
+
+export type OrganizationWithMembers = NonNullable<
+ Awaited<
+ ReturnType<
+ typeof retrieveOrganizationWithMembersAndLatestInviteLinkFromDatabaseBySlug
+ >
+ >
+>;
+
+type Member = {
+ avatar: string;
+ email: string;
+ id: string;
+ isCurrentUser: boolean;
+ name: string;
+ role: OrganizationMembership["role"];
+ deactivatedAt: Date | undefined;
+ status: "createdTheOrganization" | "joinedViaLink" | "emailInvitePending";
+};
+
+/**
+ * Maps organization data to team member settings props.
+ *
+ * @param currentUsersRole - The current user's role.
+ * @param organization - The organization.
+ * @param request - The request object.
+ * @returns The team member settings props.
+ */
+export function mapOrganizationDataToTeamMemberSettingsProps({
+ currentUsersId,
+ currentUsersRole,
+ organization,
+ request,
+}: {
+ currentUsersId: UserAccount["id"];
+ currentUsersRole: OrganizationMembership["role"];
+ organization: OrganizationWithMembers;
+ request: Request;
+}): {
+ emailInviteCard: Pick<
+ EmailInviteCardProps,
+ "currentUserIsOwner" | "organizationIsFull"
+ >;
+ inviteLinkCard: InviteLinkCardProps;
+ organizationIsFull: boolean;
+ teamMemberTable: TeamMembersTableProps;
+} {
+ const link = organization.organizationInviteLinks[0];
+ const currentSubscription = organization.stripeSubscriptions[0];
+ const maxSeats = currentSubscription?.items[0]?.price.product.maxSeats ?? 25;
+ const organizationIsFull = organization.memberships.length >= maxSeats;
+
+ // Exclude invites for users who are already members
+ const membershipEmails = new Set(
+ organization.memberships.map((m) => m.member.email),
+ );
+
+ return {
+ emailInviteCard: {
+ currentUserIsOwner: currentUsersRole === OrganizationMembershipRole.owner,
+ organizationIsFull,
+ },
+ inviteLinkCard: {
+ inviteLink:
+ link && !organizationIsFull
+ ? {
+ expiryDate: link.expiresAt.toISOString(),
+ href: tokenToInviteLink(link.token, request),
+ }
+ : undefined,
+ organizationIsFull,
+ },
+ organizationIsFull,
+ teamMemberTable: {
+ currentUsersRole,
+ members: [
+ // Email invites first, sorted, distinct, and excluding existing members
+ ...organization.organizationEmailInviteLink
+ .toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
+ // Filter to only keep the first (most recent) invite for each email
+ .filter(
+ (invite, index, array) =>
+ array.findIndex((index_) => index_.email === invite.email) ===
+ index,
+ )
+ .filter((invite) => !membershipEmails.has(invite.email))
+ .map(
+ (invite): Member => ({
+ avatar: "",
+ deactivatedAt: undefined,
+ email: invite.email,
+ id: invite.id,
+ isCurrentUser: false,
+ name: "",
+ role: invite.role,
+ status: "emailInvitePending",
+ }),
+ ),
+
+ // Then existing members
+ ...organization.memberships.map((membership): Member => {
+ const isCurrentUser = membership.member.id === currentUsersId;
+ return {
+ avatar: membership.member.imageUrl,
+ deactivatedAt: membership.deactivatedAt ?? undefined,
+ email: membership.member.email,
+ id: membership.member.id,
+ isCurrentUser,
+ name: membership.member.name,
+ role: membership.role,
+ status: isCurrentUser ? "createdTheOrganization" : "joinedViaLink",
+ };
+ }),
+ ],
+ },
+ };
+}
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-settings-schemas.ts b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-settings-schemas.ts
new file mode 100644
index 0000000..239322a
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-settings-schemas.ts
@@ -0,0 +1,29 @@
+import { z } from "zod";
+
+import {
+ CHANGE_ROLE_INTENT,
+ INVITE_BY_EMAIL_INTENT,
+} from "./team-members-constants";
+import { OrganizationMembershipRole } from "~/generated/browser";
+
+z.config({ jitless: true });
+
+export const inviteByEmailSchema = z.object({
+ email: z.email({
+ message:
+ "organizations:settings.teamMembers.inviteByEmail.form.emailInvalid",
+ }),
+ intent: z.literal(INVITE_BY_EMAIL_INTENT),
+ role: z.nativeEnum(OrganizationMembershipRole),
+});
+
+export type InviteByEmailSchema = z.infer;
+
+export const changeRoleSchema = z.object({
+ intent: z.literal(CHANGE_ROLE_INTENT),
+ role: z.union([
+ z.nativeEnum(OrganizationMembershipRole),
+ z.literal("deactivated"),
+ ]),
+ userId: z.string(),
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-table.test.tsx b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-table.test.tsx
new file mode 100644
index 0000000..73fd007
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-table.test.tsx
@@ -0,0 +1,311 @@
+import { faker } from "@faker-js/faker";
+import { describe, expect, test } from "vitest";
+
+import type { Member } from "./team-members-table";
+import { TeamMembersTable } from "./team-members-table";
+import { createPopulatedOrganization } from "~/features/organizations/organizations-factories.server";
+import { createPopulatedUserAccount } from "~/features/user-accounts/user-accounts-factories.server";
+import { OrganizationMembershipRole } from "~/generated/browser";
+import { createRoutesStub, render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createMember: Factory = ({
+ email = createPopulatedUserAccount().email,
+ id = createPopulatedUserAccount().id,
+ name = createPopulatedUserAccount().name,
+ role = faker.helpers.arrayElement(Object.values(OrganizationMembershipRole)),
+ deactivatedAt = faker.datatype.boolean() ? faker.date.recent() : null,
+ status = faker.helpers.arrayElement([
+ "joinedViaLink",
+ "joinedViaEmailInvite",
+ ]),
+ avatar = createPopulatedUserAccount().imageUrl,
+ isCurrentUser,
+} = {}) => ({
+ avatar,
+ deactivatedAt,
+ email,
+ id,
+ isCurrentUser,
+ name,
+ role,
+ status,
+});
+
+const createMembers = (count: number): Member[] => {
+ return Array.from({ length: count }, () => createMember());
+};
+
+const createProps: Factory<{
+ currentUsersRole: OrganizationMembershipRole;
+ members: Member[];
+}> = ({
+ currentUsersRole = faker.helpers.arrayElement(
+ Object.values(OrganizationMembershipRole),
+ ),
+ members = createMembers(faker.number.int({ max: 10, min: 1 })),
+} = {}) => ({ currentUsersRole, members });
+
+describe("TeamMembersTable Component", () => {
+ test("given: an array of team members, should: render the table with all columns", () => {
+ const props = createProps({
+ members: [
+ createMember({ deactivatedAt: null, role: "member" }),
+ createMember({ deactivatedAt: null, role: "admin" }),
+ createMember({ deactivatedAt: null, role: "owner" }),
+ ],
+ });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify table headers
+ expect(
+ screen.getByRole("columnheader", { name: /name/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("columnheader", { name: /email/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("columnheader", { name: /status/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("columnheader", { name: /role/i }),
+ ).toBeInTheDocument();
+
+ // Verify member data
+ for (const member of props.members) {
+ expect(
+ screen.getByText(new RegExp(member.name, "i")),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(new RegExp(member.email, "i")),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(new RegExp(member.role, "i")),
+ ).toBeInTheDocument();
+ }
+ });
+
+ test('given: no team members, should: display "no members found" message', () => {
+ const props = createProps({ members: [] });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ expect(screen.getByText(/no members found/i)).toBeInTheDocument();
+ });
+
+ test("given: a member, should: display initials as fallback in avatar", () => {
+ const props = createProps({
+ members: [createMember({ name: "John Doe" })],
+ });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ for (const member of props.members) {
+ const initials = member.name.slice(0, 2).toUpperCase();
+ expect(screen.getByText(initials)).toBeInTheDocument();
+ }
+ });
+
+ test('given: a member with status "joinedViaLink", should: display green checkmark with correct text', () => {
+ const member = createMember({ status: "joinedViaLink" });
+ const props = createProps({ members: [member] });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ const statusBadge = screen.getByText(/joined/i);
+ expect(statusBadge).toBeInTheDocument();
+ });
+
+ test('given: a member with status "emailInvitePending", should: display loading icon with correct text', () => {
+ const member = createMember({ status: "emailInvitePending" });
+ const props = createProps({ members: [member] });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ const statusBadge = screen.getByText(/pending/i);
+ expect(statusBadge).toBeInTheDocument();
+ });
+
+ test("given: current user is a member, should: display roles as text only", () => {
+ const props = createProps({
+ currentUsersRole: OrganizationMembershipRole.member,
+ members: [createMember({ deactivatedAt: null, role: "owner" })],
+ });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify no role switcher buttons are present
+ expect(
+ screen.queryByRole("button", { name: /role/i }),
+ ).not.toBeInTheDocument();
+
+ // Verify role is displayed as text
+ expect(screen.getByText(/owner/i)).toBeInTheDocument();
+ });
+
+ test("given: current user is an owner, should: display role switcher with all roles for other members", () => {
+ const props = createProps({
+ currentUsersRole: OrganizationMembershipRole.owner,
+ members: [
+ createMember({ deactivatedAt: null, role: "member" }),
+ createMember({ deactivatedAt: null, role: "admin" }),
+ createMember({ deactivatedAt: null, role: "owner" }),
+ ],
+ });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify role switcher buttons are present with correct role texts
+ expect(screen.getByRole("button", { name: /member/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /admin/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /owner/i })).toBeInTheDocument();
+ });
+
+ test("given: current user is an admin, should: display role switcher for admins and members", () => {
+ const props = createProps({
+ currentUsersRole: OrganizationMembershipRole.admin,
+ members: [
+ createMember({ deactivatedAt: null, role: "member" }),
+ createMember({ deactivatedAt: null, role: "admin" }),
+ createMember({ deactivatedAt: null, role: "owner" }),
+ ],
+ });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify role switcher buttons are present with correct role texts
+ expect(screen.getByRole("button", { name: /member/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /admin/i })).toBeInTheDocument();
+ // Admins shouldn't be able to switch roles of owners.
+ expect(
+ screen.queryByRole("button", { name: /owner/i }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText(/owner/i)).toBeInTheDocument();
+ });
+
+ test("given: current user is an owner or admin and viewing their own row, should: display role as text only", () => {
+ const currentUser = createMember({
+ deactivatedAt: null,
+ isCurrentUser: true,
+ role: faker.helpers.arrayElement([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ]),
+ });
+ const props = createProps({
+ currentUsersRole: currentUser.role,
+ members: [currentUser],
+ });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify no role switcher button for current user
+ expect(
+ screen.queryByRole("button", { name: new RegExp(currentUser.role, "i") }),
+ ).not.toBeInTheDocument();
+ // Verify role is displayed as text
+ expect(
+ screen.getByText(new RegExp(currentUser.role, "i")),
+ ).toBeInTheDocument();
+ });
+
+ test("given: multiple pages of results, should: display pagination controls", () => {
+ const props = createProps({
+ members: createMembers(25), // More than default page size
+ });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify pagination controls
+ expect(
+ screen.getByRole("button", { name: /previous/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /next/i })).toBeInTheDocument();
+ expect(
+ screen.getByRole("combobox", { name: /rows per page/i }),
+ ).toBeInTheDocument();
+ });
+
+ test("given: a pending invited member, should: display role as text only without role switcher", () => {
+ const props = createProps({
+ currentUsersRole: OrganizationMembershipRole.owner,
+ members: [
+ createMember({
+ deactivatedAt: null,
+ role: "member",
+ status: "emailInvitePending",
+ }),
+ ],
+ });
+ const { slug } = createPopulatedOrganization();
+ const path = `/organizations/${slug}/settings/team-members`;
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify no role switcher button is present for pending invite
+ expect(
+ screen.queryByRole("button", { name: /member/i }),
+ ).not.toBeInTheDocument();
+
+ // Verify role is displayed as text
+ expect(screen.getByText(/member/i)).toBeInTheDocument();
+
+ // Verify status shows pending with loading indicator
+ const pendingBadge = screen.getByText(/pending/i);
+ expect(pendingBadge).toBeInTheDocument();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-table.tsx b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-table.tsx
new file mode 100644
index 0000000..09cbb7e
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/organizations/settings/team-members/team-members-table.tsx
@@ -0,0 +1,455 @@
+import { IconChevronDown } from "@tabler/icons-react";
+import type { ColumnDef } from "@tanstack/react-table";
+import {
+ flexRender,
+ getCoreRowModel,
+ getPaginationRowModel,
+ useReactTable,
+} from "@tanstack/react-table";
+import type { TFunction } from "i18next";
+import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ TbChevronLeft,
+ TbChevronRight,
+ TbChevronsLeft,
+ TbChevronsRight,
+ TbCircleCheckFilled,
+ TbLoader,
+} from "react-icons/tb";
+import { useFetcher } from "react-router";
+import { useHydrated } from "remix-utils/use-hydrated";
+
+import { CHANGE_ROLE_INTENT } from "./team-members-constants";
+import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "~/components/ui/command";
+import { Label } from "~/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "~/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "~/components/ui/table";
+import type { OrganizationMembership, UserAccount } from "~/generated/browser";
+import { OrganizationMembershipRole } from "~/generated/browser";
+
+export type Member = {
+ avatar: UserAccount["imageUrl"];
+ deactivatedAt?: OrganizationMembership["deactivatedAt"];
+ email: UserAccount["email"];
+ id: UserAccount["id"];
+ isCurrentUser?: boolean;
+ name: UserAccount["name"];
+ role: OrganizationMembership["role"];
+ status:
+ | "createdTheOrganization"
+ | "emailInvitePending"
+ | "joinedViaEmailInvite"
+ | "joinedViaLink";
+};
+
+type RoleSwitcherProps = {
+ currentUserIsOwner: boolean;
+ member: Member;
+};
+
+function RoleSwitcher({ currentUserIsOwner, member }: RoleSwitcherProps) {
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "settings.teamMembers.table.roleSwitcher",
+ });
+
+ const [open, setOpen] = useState(false);
+ const fetcher = useFetcher();
+ const role =
+ (fetcher.formData?.get("role") as string) ||
+ (member.deactivatedAt ? "deactivated" : member.role);
+ const hydrated = useHydrated();
+
+ return (
+
+
+ }
+ >
+ {/* @ts-expect-error - role is a dynamic string (member/admin/owner/deactivated) */}
+ {t(role)}
+
+
+
+
+
+ {
+ setOpen(false);
+ }}
+ >
+
+
+
+
+
+
+
+ {t("noRolesFound")}
+
+
+
+
+ {t("member")}
+
+
+ {t("memberDescription")}
+
+
+
+
+
+
+ {t("admin")}
+
+
+ {t("adminDescription")}
+
+
+
+
+ {currentUserIsOwner && (
+
+
+ {t("owner")}
+
+
+ {t("ownerDescription")}
+
+
+
+ )}
+
+
+
+
+
+ {t("deactivated")}
+
+
+ {t("deactivatedDescription")}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const createColumns = ({
+ currentUsersRole,
+ t,
+}: {
+ currentUsersRole: OrganizationMembership["role"];
+ t: TFunction<"organizations", "settings.teamMembers.table">;
+}): ColumnDef[] => [
+ {
+ accessorKey: "avatar",
+ cell: ({ row }) => {
+ return (
+
+
+
+ {row.original.name.slice(0, 2).toUpperCase()}
+
+
+ );
+ },
+ header: () => {t("avatarHeader")}
,
+ },
+ {
+ accessorKey: "name",
+ cell: ({ row }) => {
+ return {row.original.name}
;
+ },
+ header: t("nameHeader"),
+ },
+ {
+ accessorKey: "email",
+ header: t("emailHeader"),
+ },
+ {
+ accessorKey: "status",
+ cell: ({ row }) => {
+ return (
+
+ {row.original.status === "emailInvitePending" ? (
+
+ ) : (
+
+ )}
+ {t(`status.${row.original.status}`)}
+
+ );
+ },
+ header: t("statusHeader"),
+ },
+ {
+ accessorKey: "role",
+ cell: ({ row }) =>
+ // Hide the role switcher if:
+ // 1. Its the current user's own role (can't change your own role)
+ row.original.isCurrentUser ||
+ // 2. If the current user is a member (members can't change roles)
+ currentUsersRole === "member" ||
+ // 3. If the current user is an admin and the row is an owner (admins
+ // can't change owners' roles)
+ (currentUsersRole === "admin" && row.original.role === "owner") ||
+ // 4. If the member is pending email invite (can't change roles of pending invites)
+ row.original.status === "emailInvitePending" ? (
+
+ {row.original.deactivatedAt
+ ? t("roleSwitcher.deactivated")
+ : t(`roleSwitcher.${row.original.role}`)}
+
+ ) : (
+
+ ),
+ header: t("roleHeader"),
+ },
+];
+
+export type TeamMembersTableProps = {
+ currentUsersRole: OrganizationMembership["role"];
+ members: Member[];
+};
+
+export function TeamMembersTable({
+ currentUsersRole,
+ members,
+}: TeamMembersTableProps) {
+ const { t } = useTranslation("organizations", {
+ keyPrefix: "settings.teamMembers.table",
+ });
+
+ const columns = useMemo(
+ () => createColumns({ currentUsersRole, t }),
+ [currentUsersRole, t],
+ );
+
+ const table = useReactTable({
+ columns,
+ data: members,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ });
+
+ const hydrated = useHydrated();
+
+ return (
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? undefined
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ {t("noResults")}
+
+
+ )}
+
+
+
+
+
+
+
+ {t("pagination.rowsPerPage")}
+
+
+ {
+ table.setPageSize(Number(value));
+ }}
+ value={`${table.getState().pagination.pageSize}`}
+ >
+
+
+
+
+
+ {[10, 20, 30, 40, 50].map((pageSize) => (
+
+ {pageSize}
+
+ ))}
+
+
+
+
+
+
+ {t("pagination.pageInfo", {
+ current: table.getState().pagination.pageIndex + 1,
+ total: table.getPageCount(),
+ })}
+
+
+
+ table.setPageIndex(0)}
+ variant="outline"
+ >
+ {t("pagination.goToFirst")}
+
+
+
+ table.previousPage()}
+ size="icon"
+ variant="outline"
+ >
+ {t("pagination.goToPrevious")}
+
+
+
+ table.nextPage()}
+ size="icon"
+ variant="outline"
+ >
+ {t("pagination.goToNext")}
+
+
+
+ table.setPageIndex(table.getPageCount() - 1)}
+ size="icon"
+ variant="outline"
+ >
+ {t("pagination.goToLast")}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/pastebin/paste-helpers.server.ts b/apps/react-router/saas-template/app/features/pastebin/paste-helpers.server.ts
new file mode 100644
index 0000000..1b8fc2c
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/pastebin/paste-helpers.server.ts
@@ -0,0 +1,89 @@
+import type { Tier } from "~/features/billing/billing-constants";
+import { prisma } from "~/utils/database.server";
+
+/**
+ * Get the maximum number of pastes allowed for a subscription tier
+ */
+export function getPasteLimitForTier(tier: Tier | null): number {
+ switch (tier) {
+ case "high":
+ return Infinity; // Unlimited for business plan
+ case "mid":
+ return 500; // Startup plan: 500 pastes
+ case "low":
+ return 50; // Hobby plan: 50 pastes
+ default:
+ return 5; // Free tier: 5 pastes
+ }
+}
+
+/**
+ * Check if an organization can create more pastes based on their subscription
+ */
+export async function canCreatePaste(organizationId: string): Promise<{
+ canCreate: boolean;
+ currentCount: number;
+ limit: number;
+ tier: Tier | null;
+}> {
+ const organization = await prisma.organization.findUnique({
+ where: { id: organizationId },
+ include: {
+ stripeSubscriptions: {
+ where: {
+ status: "active",
+ },
+ include: {
+ items: {
+ include: {
+ price: {
+ include: {
+ product: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ _count: {
+ select: {
+ pastes: true,
+ },
+ },
+ },
+ });
+
+ if (!organization) {
+ throw new Error("Organization not found");
+ }
+
+ // Determine tier from subscription
+ let tier: Tier | null = null;
+ const subscription = organization.stripeSubscriptions[0];
+
+ if (subscription) {
+ const price = subscription.items[0]?.price;
+ if (price) {
+ const lookupKey = price.lookupKey;
+ if (lookupKey.includes("business") || lookupKey.includes("high")) {
+ tier = "high";
+ } else if (lookupKey.includes("startup") || lookupKey.includes("mid")) {
+ tier = "mid";
+ } else if (lookupKey.includes("hobby") || lookupKey.includes("low")) {
+ tier = "low";
+ }
+ }
+ }
+
+ const limit = getPasteLimitForTier(tier);
+ const currentCount = organization._count.pastes;
+ const canCreate = limit === Infinity || currentCount < limit;
+
+ return {
+ canCreate,
+ currentCount,
+ limit,
+ tier,
+ };
+}
+
diff --git a/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-action.server.ts b/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-action.server.ts
new file mode 100644
index 0000000..e623f86
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-action.server.ts
@@ -0,0 +1,173 @@
+import { report } from "@conform-to/react/future";
+import { coerceFormValue } from "@conform-to/zod/v4/future";
+import { data } from "react-router";
+import { z } from "zod";
+
+import {
+ DELETE_USER_ACCOUNT_INTENT,
+ UPDATE_USER_ACCOUNT_INTENT,
+} from "./account-settings-constants";
+import { uploadUserAvatar } from "./account-settings-helpers.server";
+import {
+ deleteUserAccountFormSchema,
+ updateUserAccountFormSchema,
+} from "./account-settings-schemas";
+import type { Route } from ".react-router/types/app/routes/_authenticated-routes+/settings+/+types/account";
+import { adjustSeats } from "~/features/billing/stripe-helpers.server";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { deleteOrganization } from "~/features/organizations/organizations-helpers.server";
+import { requireAuthenticatedUserWithMembershipsAndSubscriptionsExists } from "~/features/user-accounts/user-accounts-helpers.server";
+import {
+ deleteUserAccountFromDatabaseById,
+ updateUserAccountInDatabaseById,
+} from "~/features/user-accounts/user-accounts-model.server";
+import { supabaseAdminClient } from "~/features/user-authentication/supabase.server";
+import { combineHeaders } from "~/utils/combine-headers.server";
+import { badRequest } from "~/utils/http-responses.server";
+import { removeImageFromStorage } from "~/utils/storage-helpers.server";
+import { createToastHeaders, redirectWithToast } from "~/utils/toast.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+const accountSettingsActionSchema = coerceFormValue(
+ z.discriminatedUnion("intent", [
+ deleteUserAccountFormSchema,
+ updateUserAccountFormSchema,
+ ]),
+);
+
+export async function accountSettingsAction({
+ context,
+ request,
+}: Route.ActionArgs) {
+ const { user, headers, supabase } =
+ await requireAuthenticatedUserWithMembershipsAndSubscriptionsExists({
+ context,
+ request,
+ });
+ const i18n = getInstance(context);
+
+ const result = await validateFormData(request, accountSettingsActionSchema, {
+ maxFileSize: 1_000_000, // 1MB
+ });
+
+ if (!result.success) {
+ return result.response;
+ }
+
+ switch (result.data.intent) {
+ case UPDATE_USER_ACCOUNT_INTENT: {
+ const updates: { name?: string; imageUrl?: string } = {};
+
+ if (result.data.name && result.data.name !== user.name) {
+ updates.name = result.data.name;
+ }
+
+ if (result.data.avatar) {
+ await removeImageFromStorage(user.imageUrl);
+
+ const publicUrl = await uploadUserAvatar({
+ file: result.data.avatar,
+ supabase,
+ userId: user.id,
+ });
+ updates.imageUrl = publicUrl;
+ }
+
+ if (Object.keys(updates).length > 0) {
+ await updateUserAccountInDatabaseById({
+ id: user.id,
+ user: updates,
+ });
+ }
+
+ const toastHeaders = await createToastHeaders({
+ title: i18n.t("settings:userAccount.toast.userAccountUpdated"),
+ type: "success",
+ });
+ return data(
+ { result: undefined },
+ { headers: combineHeaders(headers, toastHeaders) },
+ );
+ }
+
+ case DELETE_USER_ACCOUNT_INTENT: {
+ // Check if user is an owner of any organizations with other members
+ const orgsBlockingDeletion = user.memberships.filter(
+ (membership) =>
+ membership.role === "owner" &&
+ membership.organization._count.memberships > 1,
+ );
+
+ if (orgsBlockingDeletion.length > 0) {
+ return badRequest({
+ result: report(result.submission, {
+ error: {
+ fieldErrors: {},
+ formErrors: [
+ "Cannot delete account while owner of organizations with other members",
+ ],
+ },
+ }),
+ });
+ }
+
+ // Find organizations where user is the sole owner (only member)
+ const soleOwnerOrgs = user.memberships.filter(
+ (membership) =>
+ membership.role === "owner" &&
+ membership.organization._count.memberships === 1,
+ );
+
+ // Delete the organizations
+ await Promise.all(
+ soleOwnerOrgs.map(({ organization }) =>
+ deleteOrganization(organization.id),
+ ),
+ );
+
+ // Delete the user's profile picture
+ await removeImageFromStorage(user.imageUrl);
+
+ // Adjust the seats for the other user's memberships
+ await Promise.all(
+ user.memberships
+ .filter(
+ (membership) =>
+ !soleOwnerOrgs
+ .map(({ organization }) => organization.id)
+ .includes(membership.organization.id),
+ )
+ .filter(
+ (membership) => membership.organization.stripeSubscriptions[0],
+ )
+ .map((membership) => {
+ const subscription =
+ // biome-ignore lint/style/noNonNullAssertion: the subscription is guaranteed to exist
+ membership.organization.stripeSubscriptions[0]!;
+ return adjustSeats({
+ newQuantity: membership.organization._count.memberships - 1,
+ subscriptionId: subscription.stripeId,
+ // biome-ignore lint/style/noNonNullAssertion: the subscription item is guaranteed to exist
+ subscriptionItemId: subscription.items[0]!.price.stripeId,
+ });
+ }),
+ );
+
+ // Sign out the user before deleting their account
+ await supabase.auth.signOut();
+
+ // Delete the user account (this will cascade delete their memberships)
+ await deleteUserAccountFromDatabaseById(user.id);
+ await supabaseAdminClient.auth.admin.deleteUser(user.supabaseUserId);
+
+ return redirectWithToast(
+ "/",
+ {
+ title: i18n.t("settings:userAccount.toast.userAccountDeleted"),
+ type: "success",
+ },
+ { headers },
+ );
+ }
+ }
+}
diff --git a/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-constants.ts b/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-constants.ts
new file mode 100644
index 0000000..555fab0
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-constants.ts
@@ -0,0 +1,19 @@
+/*
+Intents
+*/
+export const UPDATE_USER_ACCOUNT_INTENT = "update-user-account";
+export const DELETE_USER_ACCOUNT_INTENT = "delete-user-account";
+
+/*
+Avatar
+*/
+export const AVATAR_MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB
+export const ACCEPTED_IMAGE_TYPES = {
+ "image/jpeg": [".jpeg", ".jpg"],
+ "image/jpg": [".jpg"],
+ "image/png": [".png"],
+ "image/webp": [".webp"],
+} as const;
+export const acceptedFileExtensions = [
+ ...new Set(Object.values(ACCEPTED_IMAGE_TYPES).flat()),
+];
diff --git a/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-helpers.server.test.ts b/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-helpers.server.test.ts
new file mode 100644
index 0000000..eac12e7
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-helpers.server.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, test } from "vitest";
+
+import { mapUserAccountWithMembershipsToDangerZoneProps } from "./account-settings-helpers.server";
+import { createPopulatedOrganization } from "~/features/organizations/organizations-factories.server";
+import { createPopulatedUserAccount } from "~/features/user-accounts/user-accounts-factories.server";
+import { OrganizationMembershipRole } from "~/generated/client";
+
+describe("mapUserAccountWithMembershipsToDangerZoneProps()", () => {
+ test("given: a user account with no memberships, should: return danger zone props with empty arrays", () => {
+ const user = createPopulatedUserAccount();
+
+ const actual = mapUserAccountWithMembershipsToDangerZoneProps({
+ ...user,
+ memberships: [],
+ });
+ const expected = {
+ imlicitlyDeletedOrganizations: [],
+ organizationsBlockingAccountDeletion: [],
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a user who is a member or admin of organizations, should: not include those organizations in any arrays", () => {
+ const user = createPopulatedUserAccount();
+ const org1 = createPopulatedOrganization();
+ const org2 = createPopulatedOrganization();
+
+ const actual = mapUserAccountWithMembershipsToDangerZoneProps({
+ ...user,
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: { ...org1, _count: { memberships: 1 } },
+ role: OrganizationMembershipRole.member,
+ },
+ {
+ deactivatedAt: null,
+ organization: { ...org2, _count: { memberships: 2 } },
+ role: OrganizationMembershipRole.admin,
+ },
+ ],
+ });
+ const expected = {
+ imlicitlyDeletedOrganizations: [],
+ organizationsBlockingAccountDeletion: [],
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a user who is the owner and only member of organizations, should: include those organizations in implicitly deleted array", () => {
+ const user = createPopulatedUserAccount();
+ const org1 = createPopulatedOrganization();
+ const org2 = createPopulatedOrganization();
+
+ const actual = mapUserAccountWithMembershipsToDangerZoneProps({
+ ...user,
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: { ...org1, _count: { memberships: 1 } },
+ role: OrganizationMembershipRole.owner,
+ },
+ {
+ deactivatedAt: null,
+ organization: { ...org2, _count: { memberships: 1 } },
+ role: OrganizationMembershipRole.owner,
+ },
+ ],
+ });
+ const expected = {
+ imlicitlyDeletedOrganizations: [org1.name, org2.name],
+ organizationsBlockingAccountDeletion: [],
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a user who is the owner of organizations with other members, should: include those organizations in blocking array", () => {
+ const user = createPopulatedUserAccount();
+ const org1 = createPopulatedOrganization();
+ const org2 = createPopulatedOrganization();
+
+ const actual = mapUserAccountWithMembershipsToDangerZoneProps({
+ ...user,
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: { ...org1, _count: { memberships: 2 } },
+ role: OrganizationMembershipRole.owner,
+ },
+ {
+ deactivatedAt: null,
+ organization: { ...org2, _count: { memberships: 3 } },
+ role: OrganizationMembershipRole.owner,
+ },
+ ],
+ });
+ const expected = {
+ imlicitlyDeletedOrganizations: [],
+ organizationsBlockingAccountDeletion: [org1.name, org2.name],
+ };
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a user who is the owner of organizations with mixed member counts, should: correctly categorize organizations", () => {
+ const user = createPopulatedUserAccount();
+ const soloOrg = createPopulatedOrganization();
+ const multiMemberOrg = createPopulatedOrganization();
+ const memberOrg = createPopulatedOrganization();
+
+ const actual = mapUserAccountWithMembershipsToDangerZoneProps({
+ ...user,
+ memberships: [
+ {
+ deactivatedAt: null,
+ organization: { ...soloOrg, _count: { memberships: 1 } },
+ role: OrganizationMembershipRole.owner,
+ },
+ {
+ deactivatedAt: null,
+ organization: { ...multiMemberOrg, _count: { memberships: 3 } },
+ role: OrganizationMembershipRole.owner,
+ },
+ {
+ deactivatedAt: null,
+ organization: { ...memberOrg, _count: { memberships: 2 } },
+ role: OrganizationMembershipRole.member,
+ },
+ ],
+ });
+ const expected = {
+ imlicitlyDeletedOrganizations: [soloOrg.name],
+ organizationsBlockingAccountDeletion: [multiMemberOrg.name],
+ };
+
+ expect(actual).toEqual(expected);
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-helpers.server.ts b/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-helpers.server.ts
new file mode 100644
index 0000000..b75ef9a
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-helpers.server.ts
@@ -0,0 +1,70 @@
+import type { Return } from "@prisma/client/runtime/client";
+import type { FileUpload } from "@remix-run/form-data-parser";
+import type { SupabaseClient } from "@supabase/supabase-js";
+
+import { AVATAR_PATH_PREFIX, BUCKET_NAME } from "../../user-account-constants";
+import type { requireAuthenticatedUserWithMembershipsExists } from "../../user-accounts-helpers.server";
+import type { DangerZoneProps } from "./danger-zone";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { createAdminS3Client } from "~/utils/s3.server";
+import { uploadToStorage } from "~/utils/storage.server";
+
+export function mapUserAccountWithMembershipsToDangerZoneProps(
+ user: Awaited<
+ Return
+ >["user"],
+): DangerZoneProps {
+ const imlicitlyDeletedOrganizations: string[] = [];
+ const organizationsBlockingAccountDeletion: string[] = [];
+
+ for (const membership of user.memberships) {
+ // Only consider organizations where the user is an owner
+ if (membership.role !== OrganizationMembershipRole.owner) {
+ continue;
+ }
+
+ const memberCount = membership.organization._count.memberships;
+
+ // If the user is the only member, the organization will be implicitly deleted
+ if (memberCount === 1) {
+ imlicitlyDeletedOrganizations.push(membership.organization.name);
+ }
+ // If there are other members, the organization blocks account deletion
+ else {
+ organizationsBlockingAccountDeletion.push(membership.organization.name);
+ }
+ }
+
+ return {
+ imlicitlyDeletedOrganizations,
+ organizationsBlockingAccountDeletion,
+ };
+}
+/**
+ * Uploads a user's avatar to storage and returns its public URL.
+ *
+ * @param file - The avatar file to upload
+ * @param userId - The ID of the user whose avatar is being uploaded
+ * @param supabase - The Supabase client instance
+ * @returns The public URL of the uploaded avatar
+ */
+export async function uploadUserAvatar({
+ file,
+ userId,
+ supabase,
+}: {
+ file: File | FileUpload;
+ userId: string;
+ supabase: SupabaseClient;
+}) {
+ const fileExtension = file.name.split(".").pop() ?? "";
+ const key = `${AVATAR_PATH_PREFIX}/${userId}.${fileExtension}`;
+ await uploadToStorage({
+ bucket: BUCKET_NAME,
+ client: createAdminS3Client(),
+ contentType: file.type,
+ file,
+ key,
+ });
+ return supabase.storage.from(BUCKET_NAME).getPublicUrl(key).data.publicUrl;
+}
diff --git a/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-schemas.ts b/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-schemas.ts
new file mode 100644
index 0000000..7d993f2
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings-schemas.ts
@@ -0,0 +1,38 @@
+import { z } from "zod";
+
+import {
+ DELETE_USER_ACCOUNT_INTENT,
+ UPDATE_USER_ACCOUNT_INTENT,
+} from "./account-settings-constants";
+
+const ONE_MB = 1_000_000;
+const MIN_NAME_LENGTH = 2;
+const MAX_NAME_LENGTH = 128;
+
+z.config({ jitless: true });
+
+export const deleteUserAccountFormSchema = z.object({
+ intent: z.literal(DELETE_USER_ACCOUNT_INTENT),
+});
+
+export const updateUserAccountFormSchema = z.object({
+ avatar: z
+ .file()
+ .max(ONE_MB, {
+ message: "settings:userAccount.errors.avatarTooLarge",
+ })
+ .mime(["image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp"], {
+ message: "settings:userAccount.errors.invalidFileType",
+ })
+ .optional(),
+ intent: z.literal(UPDATE_USER_ACCOUNT_INTENT),
+ name: z
+ .string()
+ .trim()
+ .min(MIN_NAME_LENGTH, {
+ message: "settings:userAccount.errors.nameMin",
+ })
+ .max(MAX_NAME_LENGTH, {
+ message: "settings:userAccount.errors.nameMax",
+ }),
+});
diff --git a/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings.tsx b/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings.tsx
new file mode 100644
index 0000000..56c1f6b
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/settings/account/account-settings.tsx
@@ -0,0 +1,200 @@
+import type { SubmissionResult } from "@conform-to/react/future";
+import { useForm } from "@conform-to/react/future";
+import { coerceFormValue } from "@conform-to/zod/v4/future";
+import { IconUser } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { Form, useNavigation } from "react-router";
+
+import { UPDATE_USER_ACCOUNT_INTENT } from "./account-settings-constants";
+import { updateUserAccountFormSchema } from "./account-settings-schemas";
+import {
+ AvatarUpload,
+ AvatarUploadDescription,
+ AvatarUploadInput,
+ AvatarUploadPreviewImage,
+} from "~/components/avatar-upload";
+import { Avatar, AvatarFallback } from "~/components/ui/avatar";
+import { Button } from "~/components/ui/button";
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSet,
+} from "~/components/ui/field";
+import { Input } from "~/components/ui/input";
+import { Spinner } from "~/components/ui/spinner";
+import type { UserAccount } from "~/generated/browser";
+
+const ONE_MB = 1_000_000;
+
+export type AccountSettingsProps = {
+ lastResult?: SubmissionResult;
+ user: Pick;
+};
+
+export function AccountSettings({ lastResult, user }: AccountSettingsProps) {
+ const { t } = useTranslation("settings", {
+ keyPrefix: "userAccount",
+ });
+
+ const { form, fields } = useForm(
+ coerceFormValue(updateUserAccountFormSchema),
+ {
+ lastResult,
+ },
+ );
+
+ const navigation = useNavigation();
+ const isSubmitting =
+ navigation.state === "submitting" &&
+ navigation.formData?.get("intent") === UPDATE_USER_ACCOUNT_INTENT;
+
+ return (
+ 0
+ ? `${form.descriptionId} ${form.errorId}`
+ : form.descriptionId
+ }
+ aria-invalid={form.errors && form.errors.length > 0 ? true : undefined}
+ >
+
+
+ {t("pageTitle")}
+
+ {t("description")}
+
+
+ {/* Name Field */}
+
+
+
+ {t("form.nameLabel")}
+
+
+ {t("form.nameDescription")}
+
+
+
+
+
+
+
+
+
+
+ {/* Email Field - Read Only */}
+
+
+ {t("form.emailLabel")}
+
+ {t("form.emailDescription")}
+
+
+
+
+
+
+
+ {/* Avatar Upload */}
+
+ {({ error }) => (
+
+
+
+ {t("form.avatarLabel")}
+
+
+ {t("form.avatarDescription")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("form.avatarFormats")}
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ {isSubmitting ? (
+ <>
+
+ {t("form.saving")}
+ >
+ ) : (
+ t("form.save")
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/user-accounts/settings/account/danger-zone.test.tsx b/apps/react-router/saas-template/app/features/user-accounts/settings/account/danger-zone.test.tsx
new file mode 100644
index 0000000..15f0790
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/settings/account/danger-zone.test.tsx
@@ -0,0 +1,165 @@
+import { faker } from "@faker-js/faker";
+import { describe, expect, test } from "vitest";
+
+import type { DangerZoneProps } from "./danger-zone";
+import { DangerZone, DELETE_USER_ACCOUNT_INTENT } from "./danger-zone";
+import {
+ createRoutesStub,
+ render,
+ screen,
+ userEvent,
+} from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createOrganizationNames = (count: number) =>
+ faker.helpers.uniqueArray(() => faker.company.name(), count);
+
+const createProps: Factory = ({
+ imlicitlyDeletedOrganizations = [],
+ isDeletingAccount = false,
+ organizationsBlockingAccountDeletion = [],
+} = {}) => ({
+ imlicitlyDeletedOrganizations,
+ isDeletingAccount,
+ organizationsBlockingAccountDeletion,
+});
+
+describe("DangerZone component", () => {
+ test("given: no implicitly deleted organizations and no organizations blocking account deletion, should: render danger zone with an enabled button and clicking it opens a menu that asks to confirm the deletion", async () => {
+ const user = userEvent.setup();
+ const path = "/settings/account";
+ const props = createProps();
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify heading and descriptions
+ expect(
+ screen.getByRole("heading", { level: 2, name: /danger zone/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /once you delete your account, there is no going back. please be certain/i,
+ ),
+ ).toBeInTheDocument();
+
+ // Click button to open the dialog
+ await user.click(screen.getByRole("button", { name: /delete account/i }));
+
+ // Verify dialog content
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 2, name: /delete account/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/are you sure you want to delete your account/i),
+ ).toBeInTheDocument();
+
+ // Verify form submission
+ const deleteButton = screen.getByRole("button", {
+ name: /delete this account/i,
+ });
+ expect(deleteButton).toHaveAttribute("name", "intent");
+ expect(deleteButton).toHaveAttribute("value", DELETE_USER_ACCOUNT_INTENT);
+ expect(deleteButton).toHaveAttribute("type", "submit");
+ });
+
+ test("given: organizations blocking account deletion, should: show warning and disable delete button", () => {
+ const blockingOrgs = createOrganizationNames(2);
+ const props = createProps({
+ organizationsBlockingAccountDeletion: blockingOrgs,
+ });
+ const path = "/settings/account";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Verify warning message
+ expect(
+ screen.getByText(
+ /your account is currently an owner in these organizations:/i,
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText(blockingOrgs.join(", "))).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /you must remove yourself, transfer ownership, or delete this organization before you can delete your user/i,
+ ),
+ ).toBeInTheDocument();
+
+ // Verify button is disabled
+ expect(
+ screen.getByRole("button", { name: /delete account/i }),
+ ).toBeDisabled();
+ });
+
+ test("given: organizations that will be implicitly deleted, should: show warning in confirmation dialog", async () => {
+ const user = userEvent.setup();
+ const implicitlyDeletedOrgs = createOrganizationNames(2);
+ const props = createProps({
+ imlicitlyDeletedOrganizations: implicitlyDeletedOrgs,
+ });
+ const path = "/settings/account";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Open dialog
+ await user.click(screen.getByRole("button", { name: /delete account/i }));
+
+ // Verify warning about implicit deletions
+ expect(
+ screen.getByText(/the following organizations will be deleted:/i),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(implicitlyDeletedOrgs.join(", ")),
+ ).toBeInTheDocument();
+ });
+
+ test("given: dialog is open and cancel is clicked, should: close the dialog", async () => {
+ const user = userEvent.setup();
+ const path = "/settings/account";
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Open dialog
+ await user.click(screen.getByRole("button", { name: /delete account/i }));
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+
+ // Close dialog
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ test("given: account is being deleted, should: show loading state and disable buttons", async () => {
+ const user = userEvent.setup();
+ const path = "/settings/account";
+ const props = createProps({ isDeletingAccount: true });
+ const RouterStub = createRoutesStub([
+ { Component: () => , path },
+ ]);
+
+ render( );
+
+ // Open dialog
+ await user.click(screen.getByRole("button", { name: /delete account/i }));
+
+ // Verify loading state
+ expect(screen.getByText(/deleting account/i)).toBeInTheDocument();
+
+ // Verify buttons are disabled
+ expect(screen.getByRole("button", { name: /cancel/i })).toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: /deleting account/i }),
+ ).toBeDisabled();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/user-accounts/settings/account/danger-zone.tsx b/apps/react-router/saas-template/app/features/user-accounts/settings/account/danger-zone.tsx
new file mode 100644
index 0000000..10e1711
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/settings/account/danger-zone.tsx
@@ -0,0 +1,185 @@
+import { Trans, useTranslation } from "react-i18next";
+import { Form } from "react-router";
+
+import { Button } from "~/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemTitle,
+} from "~/components/ui/item";
+import { Spinner } from "~/components/ui/spinner";
+import type { Organization } from "~/generated/browser";
+import { cn } from "~/lib/utils";
+
+export const DELETE_USER_ACCOUNT_INTENT = "delete-user-account";
+
+export type DangerZoneProps = {
+ imlicitlyDeletedOrganizations: Organization["name"][];
+ isDeletingAccount?: boolean;
+ organizationsBlockingAccountDeletion: Organization["name"][];
+};
+
+function Strong({
+ children,
+ className,
+}: {
+ children?: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function DeleteAccountDialogComponent({
+ imlicitlyDeletedOrganizations,
+ isDeletingAccount = false,
+ isDeleteBlocked,
+}: {
+ imlicitlyDeletedOrganizations: Organization["name"][];
+ isDeletingAccount: boolean;
+ isDeleteBlocked: boolean;
+}) {
+ const { t } = useTranslation("settings", {
+ keyPrefix: "userAccount.dangerZone",
+ });
+
+ const hasImplicitDeletions = imlicitlyDeletedOrganizations.length > 0;
+
+ return (
+
+ }
+ >
+ {t("deleteButton")}
+
+
+
+
+ {t("dialogTitle")}
+
+
{t("dialogDescription")}
+
+ {hasImplicitDeletions && (
+
+ }}
+ count={imlicitlyDeletedOrganizations.length}
+ i18nKey="userAccount.dangerZone.implicitlyDeletedOrganizations"
+ ns="settings"
+ shouldUnescape
+ values={{
+ organizations: imlicitlyDeletedOrganizations.join(", "),
+ }}
+ />
+
+ )}
+
+
+
+
+
+ }
+ >
+ {t("cancel")}
+
+
+
+
+ {isDeletingAccount ? (
+ <>
+
+ {t("deleting")}
+ >
+ ) : (
+ t("deleteConfirm")
+ )}
+
+
+
+
+
+ );
+}
+
+export function DangerZone({
+ imlicitlyDeletedOrganizations,
+ isDeletingAccount = false,
+ organizationsBlockingAccountDeletion,
+}: DangerZoneProps) {
+ const { t } = useTranslation("settings", {
+ keyPrefix: "userAccount.dangerZone",
+ });
+
+ const isDeleteBlocked = organizationsBlockingAccountDeletion.length > 0;
+
+ return (
+
+
+ {t("title")}
+
+ -
+
+ {t("deleteTitle")}
+
+ {isDeleteBlocked ? (
+
+ }}
+ count={organizationsBlockingAccountDeletion.length}
+ i18nKey="userAccount.dangerZone.blockingOrganizations"
+ ns="settings"
+ shouldUnescape
+ values={{
+ organizations:
+ organizationsBlockingAccountDeletion.join(", "),
+ }}
+ />{" "}
+ {t("blockingOrganizationsHelp")}
+
+ ) : (
+ t("deleteDescription")
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/user-accounts/user-account-constants.ts b/apps/react-router/saas-template/app/features/user-accounts/user-account-constants.ts
new file mode 100644
index 0000000..c23da84
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/user-account-constants.ts
@@ -0,0 +1,2 @@
+export const BUCKET_NAME = "app-images";
+export const AVATAR_PATH_PREFIX = "user-avatars";
diff --git a/apps/react-router/saas-template/app/features/user-accounts/user-accounts-factories.server.ts b/apps/react-router/saas-template/app/features/user-accounts/user-accounts-factories.server.ts
new file mode 100644
index 0000000..6ac77ce
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/user-accounts-factories.server.ts
@@ -0,0 +1,29 @@
+import { faker } from "@faker-js/faker";
+import { createId } from "@paralleldrive/cuid2";
+
+import type { UserAccount } from "~/generated/client";
+import type { Factory } from "~/utils/types";
+
+/**
+ * Creates a user account with populated values.
+ *
+ * @param userAccountParams - User account params to create user account with.
+ * @returns A populated user account with given params.
+ */
+export const createPopulatedUserAccount: Factory = ({
+ id = createId(),
+ supabaseUserId = faker.string.uuid(),
+ email = faker.internet.email(),
+ name = faker.person.fullName(),
+ updatedAt = faker.date.recent({ days: 10 }),
+ createdAt = faker.date.past({ refDate: updatedAt, years: 3 }),
+ imageUrl = faker.image.avatar(),
+} = {}) => ({
+ createdAt,
+ email,
+ id,
+ imageUrl,
+ name,
+ supabaseUserId,
+ updatedAt,
+});
diff --git a/apps/react-router/saas-template/app/features/user-accounts/user-accounts-helpers.server.spec.ts b/apps/react-router/saas-template/app/features/user-accounts/user-accounts-helpers.server.spec.ts
new file mode 100644
index 0000000..ab85b00
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/user-accounts-helpers.server.spec.ts
@@ -0,0 +1,42 @@
+import { faker } from "@faker-js/faker";
+import { describe, expect, test } from "vitest";
+
+import { createPopulatedUserAccount } from "./user-accounts-factories.server";
+import { throwIfUserAccountIsMissing } from "./user-accounts-helpers.server";
+import { supabaseHandlers } from "~/test/mocks/handlers/supabase";
+import { setupMockServerLifecycle } from "~/test/msw-test-utils";
+import { createAuthenticatedRequest } from "~/test/test-utils";
+
+setupMockServerLifecycle(...supabaseHandlers);
+
+describe("throwIfUserAccountIsMissing()", () => {
+ test("given: a request and a user account, should: return the user account", async () => {
+ const request = new Request(faker.internet.url());
+ const userAccount = createPopulatedUserAccount();
+
+ const actual = await throwIfUserAccountIsMissing(request, userAccount);
+ const expected = userAccount;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a request and null, should: throw a redirect to the login page and log the user out", async () => {
+ expect.assertions(3);
+ const request = await createAuthenticatedRequest({
+ url: faker.internet.url(),
+ user: createPopulatedUserAccount(),
+ });
+
+ try {
+ await throwIfUserAccountIsMissing(request, null);
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual("/login");
+ expect(error.headers.get("Set-Cookie")).toMatch(
+ /sb-.*-auth-token=; Max-Age=0; Path=\/; SameSite=Lax/,
+ );
+ }
+ }
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/user-accounts/user-accounts-helpers.server.ts b/apps/react-router/saas-template/app/features/user-accounts/user-accounts-helpers.server.ts
new file mode 100644
index 0000000..979634d
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/user-accounts-helpers.server.ts
@@ -0,0 +1,136 @@
+import type { RouterContextProvider } from "react-router";
+
+import { logout } from "../user-authentication/user-authentication-helpers.server";
+import { authContext } from "../user-authentication/user-authentication-middleware.server";
+import {
+ retrieveUserAccountFromDatabaseBySupabaseUserId,
+ retrieveUserAccountWithMembershipsAndMemberCountsAndSubscriptionsFromDatabaseBySupabaseUserId,
+ retrieveUserAccountWithMembershipsAndMemberCountsFromDatabaseBySupabaseUserId,
+} from "./user-accounts-model.server";
+import type { UserAccount } from "~/generated/client";
+
+/**
+ * Ensures that a user account is present.
+ *
+ * @param userAccount - The user account to check - possibly null or undefined.
+ * @returns The same user account if it exists.
+ * @throws Logs the user out if the user account is missing.
+ */
+export const throwIfUserAccountIsMissing = async (
+ request: Request,
+ userAccount: T | null,
+) => {
+ if (!userAccount) {
+ throw await logout(request, "/login");
+ }
+
+ return userAccount;
+};
+
+/**
+ * Ensures that a user account for the authenticated user exists.
+ *
+ * @param context - Router context provider containing authentication data.
+ * @param request - Request object used for logout if user account is missing.
+ * @returns The user account and authentication headers.
+ * @throws Logs the user out if the user account is missing.
+ */
+export const requireAuthenticatedUserExists = async ({
+ context,
+ request,
+}: {
+ context: Readonly;
+ request: Request;
+}) => {
+ const {
+ user: { id },
+ headers,
+ } = context.get(authContext);
+ const user = await retrieveUserAccountFromDatabaseBySupabaseUserId(id);
+ return { headers, user: await throwIfUserAccountIsMissing(request, user) };
+};
+
+/**
+ * Ensures that a user account for the authenticated user exists and also
+ * returns their memberships.
+ *
+ * IMPORTANT: This function does not check if the user is an active member of
+ * the current slug in the URL! For that use `requireUserIsMemberOfOrganization`
+ * instead.
+ *
+ * @param context - Router context provider containing authentication data.
+ * @param request - Request object used for logout if user account is missing.
+ * @returns The user account with memberships, authentication headers, and supabase client.
+ * @throws Logs the user out if the user account is missing.
+ */
+export const requireAuthenticatedUserWithMembershipsExists = async ({
+ context,
+ request,
+}: {
+ context: Readonly;
+ request: Request;
+}) => {
+ const {
+ user: { id },
+ headers,
+ supabase,
+ } = context.get(authContext);
+ const user =
+ await retrieveUserAccountWithMembershipsAndMemberCountsFromDatabaseBySupabaseUserId(
+ id,
+ );
+ return {
+ headers,
+ supabase,
+ user: await throwIfUserAccountIsMissing(request, user),
+ };
+};
+
+/**
+ * Ensures that a user account for the authenticated user exists and also
+ * returns their memberships and subscriptions.
+ *
+ * IMPORTANT: This function does not check if the user is an active member of
+ * the current slug in the URL! For that use `requireUserIsMemberOfOrganization`
+ * instead.
+ *
+ * @param context - Router context provider containing authentication data.
+ * @param request - Request object used for logout if user account is missing.
+ * @returns The user account with memberships and subscriptions, authentication headers, and supabase client.
+ * @throws Logs the user out if the user account is missing.
+ */
+export const requireAuthenticatedUserWithMembershipsAndSubscriptionsExists =
+ async ({
+ context,
+ request,
+ }: {
+ context: Readonly;
+ request: Request;
+ }) => {
+ const {
+ user: { id },
+ headers,
+ supabase,
+ } = context.get(authContext);
+ const user =
+ await retrieveUserAccountWithMembershipsAndMemberCountsAndSubscriptionsFromDatabaseBySupabaseUserId(
+ id,
+ );
+ return {
+ headers,
+ supabase,
+ user: await throwIfUserAccountIsMissing(request, user),
+ };
+ };
+
+/**
+ * Ensures that a user account for the provided supabase user id exists.
+ *
+ * @param request - The incoming request object.
+ * @param id - The supabase user id.
+ * @returns The user account.
+ */
+export async function requireSupabaseUserExists(request: Request, id: string) {
+ const user = await retrieveUserAccountFromDatabaseBySupabaseUserId(id);
+ return await throwIfUserAccountIsMissing(request, user);
+}
diff --git a/apps/react-router/saas-template/app/features/user-accounts/user-accounts-model.server.ts b/apps/react-router/saas-template/app/features/user-accounts/user-accounts-model.server.ts
new file mode 100644
index 0000000..eebfff9
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-accounts/user-accounts-model.server.ts
@@ -0,0 +1,290 @@
+import type { Prisma, UserAccount } from "~/generated/client";
+import { prisma } from "~/utils/database.server";
+
+/* CREATE */
+
+/**
+ * Saves a user account to the database.
+ *
+ * @param userAccount The user account to save.
+ * @returns The saved user account.
+ */
+export async function saveUserAccountToDatabase(
+ userAccount: Prisma.UserAccountCreateInput,
+) {
+ return prisma.userAccount.create({ data: userAccount });
+}
+
+/* READ */
+
+/**
+ * Retrieves a user account by its id.
+ *
+ * @param id The id of the user account.
+ * @returns The user account or null.
+ */
+export async function retrieveUserAccountFromDatabaseById(
+ id: UserAccount["id"],
+) {
+ return prisma.userAccount.findUnique({ where: { id } });
+}
+
+/**
+ * Retrieves a user account by its email.
+ *
+ * @param email The email of the user account.
+ * @returns The user account or null.
+ */
+export async function retrieveUserAccountFromDatabaseByEmail(
+ email: UserAccount["email"],
+) {
+ return prisma.userAccount.findUnique({ where: { email } });
+}
+
+/**
+ * Retrieve a user account with their active organization memberships by email.
+ *
+ * @param email The email of the user account.
+ * @returns The user account with active memberships or null.
+ */
+export async function retrieveUserAccountWithActiveMembershipsFromDatabaseByEmail(
+ email: UserAccount["email"],
+) {
+ return prisma.userAccount.findUnique({
+ include: {
+ memberships: {
+ where: {
+ OR: [{ deactivatedAt: null }, { deactivatedAt: { gt: new Date() } }],
+ },
+ },
+ },
+ where: { email },
+ });
+}
+
+/**
+ * Retrieves a user account by their Supabase ID.
+ *
+ * @param supabaseUserId The Supabase ID of the user account.
+ * @returns The user account or null.
+ */
+export async function retrieveUserAccountFromDatabaseBySupabaseUserId(
+ supabaseUserId: UserAccount["supabaseUserId"],
+) {
+ return prisma.userAccount.findUnique({ where: { supabaseUserId } });
+}
+
+/**
+ * Retrieves a user account and their active organization memberships by
+ * Supabase ID.
+ *
+ * @param supabaseUserId The Supabase ID of the user account.
+ * @returns The user account with active memberships or null. Active memberships
+ * are those that are either not deactivated or have a deactivation date in the
+ * future.
+ */
+export async function retrieveUserAccountWithMembershipsFromDatabaseBySupabaseUserId(
+ supabaseUserId: UserAccount["supabaseUserId"],
+) {
+ return prisma.userAccount.findUnique({
+ include: {
+ memberships: {
+ select: {
+ deactivatedAt: true,
+ organization: {
+ select: {
+ _count: {
+ select: {
+ memberships: {
+ where: {
+ OR: [
+ { deactivatedAt: null },
+ { deactivatedAt: { gt: new Date() } },
+ ],
+ },
+ },
+ },
+ },
+ billingEmail: true,
+ id: true,
+ imageUrl: true,
+ name: true,
+ slug: true,
+ stripeCustomerId: true,
+ stripeSubscriptions: {
+ include: {
+ items: { include: { price: { include: { product: true } } } },
+ schedule: {
+ include: {
+ phases: {
+ include: { price: true },
+ orderBy: { startDate: "asc" },
+ },
+ },
+ where: {
+ currentPhaseEnd: { not: null },
+ currentPhaseStart: { not: null },
+ },
+ },
+ },
+ orderBy: { created: "desc" },
+ take: 1,
+ },
+ trialEnd: true,
+ },
+ },
+ role: true,
+ },
+ where: {
+ OR: [{ deactivatedAt: null }, { deactivatedAt: { gt: new Date() } }],
+ },
+ },
+ },
+ where: { supabaseUserId },
+ });
+}
+
+/**
+ * Retrieves a user account, their active organization memberships, and the count
+ * of members in each organization by Supabase ID.
+ *
+ * @param supabaseUserId The Supabase ID of the user account.
+ * @returns The user account with active memberships and member counts or null.
+ * Active memberships are those that are either not deactivated or have a
+ * deactivation date in the future.
+ */
+export async function retrieveUserAccountWithMembershipsAndMemberCountsFromDatabaseBySupabaseUserId(
+ supabaseUserId: UserAccount["supabaseUserId"],
+) {
+ return prisma.userAccount.findUnique({
+ include: {
+ memberships: {
+ select: {
+ deactivatedAt: true,
+ organization: {
+ select: {
+ // Count active members in the organization
+ _count: {
+ select: {
+ memberships: {
+ where: {
+ OR: [
+ { deactivatedAt: null },
+ { deactivatedAt: { gt: new Date() } },
+ ],
+ },
+ },
+ },
+ },
+ id: true,
+ imageUrl: true,
+ name: true,
+ slug: true,
+ },
+ },
+ role: true,
+ },
+ where: {
+ OR: [{ deactivatedAt: null }, { deactivatedAt: { gt: new Date() } }],
+ },
+ },
+ },
+ where: { supabaseUserId },
+ });
+}
+
+/**
+ * Retrieves a user account, their active organization memberships, and the
+ * count of members in each organization by Supabase ID. Also includes the
+ * organization's subscription.
+ *
+ * @param supabaseUserId The Supabase ID of the user account.
+ * @returns The user account with active memberships and member counts or null.
+ * Active memberships are those that are either not deactivated or have a
+ * deactivation date in the future.
+ */
+export async function retrieveUserAccountWithMembershipsAndMemberCountsAndSubscriptionsFromDatabaseBySupabaseUserId(
+ supabaseUserId: UserAccount["supabaseUserId"],
+) {
+ return prisma.userAccount.findUnique({
+ include: {
+ memberships: {
+ select: {
+ deactivatedAt: true,
+ organization: {
+ select: {
+ // Count active members in the organization
+ _count: {
+ select: {
+ memberships: {
+ where: {
+ OR: [
+ { deactivatedAt: null },
+ { deactivatedAt: { gt: new Date() } },
+ ],
+ },
+ },
+ },
+ },
+ id: true,
+ imageUrl: true,
+ name: true,
+ slug: true,
+ stripeSubscriptions: {
+ include: {
+ items: { include: { price: true } },
+ schedule: {
+ include: {
+ phases: {
+ include: { price: true },
+ orderBy: { startDate: "asc" },
+ },
+ },
+ },
+ },
+ orderBy: { created: "desc" },
+ take: 1,
+ },
+ },
+ },
+ role: true,
+ },
+ where: {
+ OR: [{ deactivatedAt: null }, { deactivatedAt: { gt: new Date() } }],
+ },
+ },
+ },
+ where: { supabaseUserId },
+ });
+}
+
+/* UPDATE */
+
+/**
+ * Updates a user account by its id.
+ *
+ * @param id The id of the user account.
+ * @param data The new data for the user account.
+ * @returns The updated user account.
+ */
+export async function updateUserAccountInDatabaseById({
+ id,
+ user,
+}: {
+ id: UserAccount["id"];
+ user: Omit;
+}) {
+ return prisma.userAccount.update({ data: user, where: { id } });
+}
+
+/* DELETE */
+
+/**
+ * Deletes a user account from the database.
+ *
+ * @param id The id of the user account to delete.
+ * @returns The deleted user account.
+ */
+export async function deleteUserAccountFromDatabaseById(id: UserAccount["id"]) {
+ return prisma.userAccount.delete({ where: { id } });
+}
diff --git a/apps/react-router/saas-template/app/features/user-authentication/floating-paths.tsx b/apps/react-router/saas-template/app/features/user-authentication/floating-paths.tsx
new file mode 100644
index 0000000..39ba7ae
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/floating-paths.tsx
@@ -0,0 +1,66 @@
+import { motion } from "motion/react";
+import { useTranslation } from "react-i18next";
+
+import { usePrefersReducedMotion } from "~/hooks/use-prefers-reduced-motion";
+
+export function FloatingPaths({ position }: { position: number }) {
+ const { t } = useTranslation("userAuthentication", { keyPrefix: "layout" });
+ const prefersReducedMotion = usePrefersReducedMotion();
+
+ const paths = Array.from({ length: 36 }, (_, i) => ({
+ color: `rgba(15,23,42,${0.1 + i * 0.03})`,
+ d: `M-${380 - i * 5 * position} -${189 + i * 6}C-${
+ 380 - i * 5 * position
+ } -${189 + i * 6} -${312 - i * 5 * position} ${216 - i * 6} ${
+ 152 - i * 5 * position
+ } ${343 - i * 6}C${616 - i * 5 * position} ${470 - i * 6} ${
+ 684 - i * 5 * position
+ } ${875 - i * 6} ${684 - i * 5 * position} ${875 - i * 6}`,
+ id: i,
+ width: 0.5 + i * 0.03,
+ }));
+
+ return (
+
+
+ {t("backgroundPathsTitle")}
+ {paths.map((path) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/user-authentication/login/login-action.server.ts b/apps/react-router/saas-template/app/features/user-authentication/login/login-action.server.ts
new file mode 100644
index 0000000..e1ab79c
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/login/login-action.server.ts
@@ -0,0 +1,92 @@
+import { report } from "@conform-to/react/future";
+import { redirect } from "react-router";
+import { z } from "zod";
+
+import { anonymousContext } from "../user-authentication-middleware.server";
+import { loginWithEmailSchema, loginWithGoogleSchema } from "./login-schemas";
+import type { Route } from ".react-router/types/app/routes/_user-authentication+/_anonymous-routes+/+types/login";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { retrieveUserAccountFromDatabaseByEmail } from "~/features/user-accounts/user-accounts-model.server";
+import { getErrorMessage } from "~/utils/get-error-message";
+import { badRequest } from "~/utils/http-responses.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+const loginSchema = z.discriminatedUnion("intent", [
+ loginWithEmailSchema,
+ loginWithGoogleSchema,
+]);
+
+export async function loginAction({ request, context }: Route.ActionArgs) {
+ const { supabase, headers } = context.get(anonymousContext);
+ const i18n = getInstance(context);
+ const result = await validateFormData(request, loginSchema);
+
+ if (!result.success) {
+ return result.response;
+ }
+
+ const body = result.data;
+
+ switch (body.intent) {
+ case "loginWithEmail": {
+ const userAccount = await retrieveUserAccountFromDatabaseByEmail(
+ body.email,
+ );
+
+ if (!userAccount) {
+ return badRequest({
+ result: report(result.submission, {
+ error: {
+ fieldErrors: {
+ email: ["userAuthentication:login.form.userDoesntExist"],
+ },
+ formErrors: [],
+ },
+ }),
+ });
+ }
+
+ const { data, error } = await supabase.auth.signInWithOtp({
+ email: body.email,
+ options: {
+ data: { appName: i18n.t("translation:appName"), intent: body.intent },
+ shouldCreateUser: false,
+ },
+ });
+
+ if (error) {
+ const errorMessage = getErrorMessage(error);
+
+ // Error: For security purposes, you can only request this after 10 seconds.
+ if (errorMessage.includes("you can only request this after")) {
+ return badRequest({
+ result: report(result.submission, {
+ error: {
+ fieldErrors: {
+ email: ["userAuthentication:login.form.loginFailed"],
+ },
+ formErrors: [],
+ },
+ }),
+ });
+ }
+
+ throw new Error(errorMessage);
+ }
+
+ return { ...data, email: body.email, result: undefined };
+ }
+ case "loginWithGoogle": {
+ const { data, error } = await supabase.auth.signInWithOAuth({
+ options: { redirectTo: `${process.env.APP_URL}/auth/callback` },
+ provider: "google",
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return redirect(data.url, { headers });
+ }
+ }
+}
diff --git a/apps/react-router/saas-template/app/features/user-authentication/login/login-constants.ts b/apps/react-router/saas-template/app/features/user-authentication/login/login-constants.ts
new file mode 100644
index 0000000..9c9fd75
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/login/login-constants.ts
@@ -0,0 +1,4 @@
+export const LOGIN_INTENTS = {
+ loginWithEmail: "loginWithEmail",
+ loginWithGoogle: "loginWithGoogle",
+} as const;
diff --git a/apps/react-router/saas-template/app/features/user-authentication/login/login-schemas.ts b/apps/react-router/saas-template/app/features/user-authentication/login/login-schemas.ts
new file mode 100644
index 0000000..43d7ba3
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/login/login-schemas.ts
@@ -0,0 +1,14 @@
+import * as z from "zod";
+
+import { LOGIN_INTENTS } from "./login-constants";
+
+z.config({ jitless: true });
+
+export const loginWithEmailSchema = z.object({
+ email: z.email({ message: "userAuthentication:login.errors.invalidEmail" }),
+ intent: z.literal(LOGIN_INTENTS.loginWithEmail),
+});
+
+export const loginWithGoogleSchema = z.object({
+ intent: z.literal(LOGIN_INTENTS.loginWithGoogle),
+});
diff --git a/apps/react-router/saas-template/app/features/user-authentication/login/login-verification-awaiting.test.tsx b/apps/react-router/saas-template/app/features/user-authentication/login/login-verification-awaiting.test.tsx
new file mode 100644
index 0000000..a3de792
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/login/login-verification-awaiting.test.tsx
@@ -0,0 +1,154 @@
+import { faker } from "@faker-js/faker";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+import { loginIntents } from "../user-authentication-constants";
+import type { LoginVerificationAwaitingProps } from "./login-verification-awaiting";
+import { LoginVerificationAwaiting } from "./login-verification-awaiting";
+import { act, createRoutesStub, render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createProps: Factory = ({
+ email = faker.internet.email(),
+ isResending = false,
+ isSubmitting = false,
+} = {}) => ({
+ email,
+ isResending,
+ isSubmitting,
+});
+
+describe("LoginVerificationAwaiting Component", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ test("given: component renders with default props, should: display correct content, include email input, show resend button, disable fieldset when waiting and render an alert that the user should check their spam folder", () => {
+ const email = faker.internet.email();
+ const props = createProps({ email });
+ const path = "/login";
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // Verify the card title and description are displayed
+ expect(screen.getByText(/check your email/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(/we've sent a secure login link to your email address/i),
+ ).toBeInTheDocument();
+
+ // Verify the countdown message is displayed (initial countdown is 60 seconds)
+ expect(
+ screen.getByText(/if you haven't received the email within 60 seconds/i),
+ ).toBeInTheDocument();
+
+ // Verify the hidden email input is present with the correct value
+ const hiddenInput = screen.getByDisplayValue(email);
+ expect(hiddenInput).toBeInTheDocument();
+ expect(hiddenInput).toHaveAttribute("type", "hidden");
+ expect(hiddenInput).toHaveAttribute("name", "email");
+
+ // Verify the resend button has the correct text and attributes
+ const button = screen.getByRole("button", {
+ name: /request new login link/i,
+ });
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveAttribute("name", "intent");
+ expect(button).toHaveAttribute("value", loginIntents.loginWithEmail);
+
+ // Verify the alert is displayed
+ expect(
+ screen.getByText(/remember to check your spam folder/i),
+ ).toBeInTheDocument();
+ });
+
+ test("given: countdown reaches zero after 60 seconds, should: enable the form", () => {
+ const props = createProps();
+ const path = "/login";
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // Initially, the fieldset should be disabled
+ const submitButton = screen.getByRole("button", {
+ name: /request new login link/i,
+ });
+ expect(submitButton).toBeDisabled();
+
+ // Advance time to make the countdown reach zero
+ act(() => {
+ vi.advanceTimersByTime(60_000); // 60 seconds
+ });
+
+ // Now the fieldset should be enabled
+ expect(submitButton).toBeEnabled();
+
+ // The countdown message should now show the zero state
+ expect(
+ screen.getByText(
+ /if you haven't received the email, you may request another login link now/i,
+ ),
+ ).toBeInTheDocument();
+ });
+
+ test("given: isResending is true, should: display loading state and disable the button", () => {
+ const props = createProps({ isResending: true });
+ const path = "/login";
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // Advance time to make the countdown reach zero.
+ act(() => {
+ vi.advanceTimersByTime(60_000); // 60 seconds
+ });
+
+ // Get the button with the loading text.
+ const button = screen.getByRole("button", { name: /sending/i });
+ expect(button).toBeDisabled();
+ });
+
+ test("given: isSubmitting is true, should: disable the form regardless of countdown", () => {
+ const props = createProps({ isSubmitting: true });
+ const path = "/login";
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // Advance time to make the countdown reach zero
+ act(() => {
+ vi.advanceTimersByTime(60_000); // 60 seconds
+ });
+
+ // The submit button should still be disabled due to isSubmitting, even
+ // though countdown is at zero.
+ const submitButton = screen.getByRole("button", {
+ name: /request new login link/i,
+ });
+ expect(submitButton).toBeDisabled();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/user-authentication/login/login-verification-awaiting.tsx b/apps/react-router/saas-template/app/features/user-authentication/login/login-verification-awaiting.tsx
new file mode 100644
index 0000000..85dddea
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/login/login-verification-awaiting.tsx
@@ -0,0 +1,89 @@
+import { IconAlertTriangle } from "@tabler/icons-react";
+import { Trans, useTranslation } from "react-i18next";
+import { Form } from "react-router";
+
+import { useCountdown } from "../use-countdown";
+import { loginIntents } from "../user-authentication-constants";
+import { Alert, AlertDescription } from "~/components/ui/alert";
+import { Button } from "~/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { Spinner } from "~/components/ui/spinner";
+
+export type LoginVerificationAwaitingProps = {
+ email: string;
+ isResending?: boolean;
+ isSubmitting?: boolean;
+};
+
+export function LoginVerificationAwaiting({
+ email,
+ isResending = false,
+ isSubmitting = false,
+}: LoginVerificationAwaitingProps) {
+ const { t } = useTranslation("userAuthentication", {
+ keyPrefix: "login.magicLink",
+ });
+
+ const { secondsLeft, reset } = useCountdown(60);
+
+ const waitingToResend = secondsLeft !== 0;
+
+ return (
+
+
+ {t("cardTitle")}
+
+
+ {t("cardDescription")}
+
+
+
+
+
+
+ }}
+ count={secondsLeft}
+ i18nKey="login.magicLink.countdownMessage"
+ ns="userAuthentication"
+ />
+
+
+
reset()}>
+
+
+
+
+ {isResending ? (
+ <>
+
+ {t("resendButtonLoading")}
+ >
+ ) : (
+ t("resendButton")
+ )}
+
+
+
+
+
+
+
+ {t("alertDescription")}
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/user-authentication/registration/register-action.server.ts b/apps/react-router/saas-template/app/features/user-authentication/registration/register-action.server.ts
new file mode 100644
index 0000000..3761d91
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/registration/register-action.server.ts
@@ -0,0 +1,96 @@
+import { report } from "@conform-to/react/future";
+import { redirect } from "react-router";
+import { z } from "zod";
+
+import { anonymousContext } from "../user-authentication-middleware.server";
+import {
+ registerWithEmailSchema,
+ registerWithGoogleSchema,
+} from "./registration-schemas";
+import type { Route } from ".react-router/types/app/routes/_user-authentication+/_anonymous-routes+/+types/register";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { retrieveUserAccountFromDatabaseByEmail } from "~/features/user-accounts/user-accounts-model.server";
+import { getErrorMessage } from "~/utils/get-error-message";
+import { badRequest } from "~/utils/http-responses.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+const registerSchema = z.discriminatedUnion("intent", [
+ registerWithEmailSchema,
+ registerWithGoogleSchema,
+]);
+
+export async function registerAction({ request, context }: Route.ActionArgs) {
+ const { supabase, headers } = context.get(anonymousContext);
+ const i18n = getInstance(context);
+ const result = await validateFormData(request, registerSchema);
+
+ if (!result.success) {
+ return result.response;
+ }
+
+ const body = result.data;
+
+ switch (body.intent) {
+ case "registerWithEmail": {
+ const userAccount = await retrieveUserAccountFromDatabaseByEmail(
+ body.email,
+ );
+
+ if (userAccount) {
+ return badRequest({
+ result: report(result.submission, {
+ error: {
+ fieldErrors: {
+ email: ["userAuthentication:register.form.userAlreadyExists"],
+ },
+ formErrors: [],
+ },
+ }),
+ });
+ }
+
+ const { data, error } = await supabase.auth.signInWithOtp({
+ email: body.email,
+ options: {
+ data: { appName: i18n.t("translation:appName"), intent: body.intent },
+ shouldCreateUser: true,
+ },
+ });
+
+ if (error) {
+ const errorMessage = getErrorMessage(error);
+
+ if (errorMessage.includes("you can only request this after")) {
+ return badRequest({
+ result: report(result.submission, {
+ error: {
+ fieldErrors: {
+ email: [
+ "userAuthentication:register.form.registrationFailed",
+ ],
+ },
+ formErrors: [],
+ },
+ }),
+ });
+ }
+
+ throw error;
+ }
+
+ return { ...data, email: body.email, result: undefined };
+ }
+ case "registerWithGoogle": {
+ const { data, error } = await supabase.auth.signInWithOAuth({
+ options: { redirectTo: `${process.env.APP_URL}/auth/callback` },
+ provider: "google",
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return redirect(data.url, { headers });
+ }
+ }
+}
diff --git a/apps/react-router/saas-template/app/features/user-authentication/registration/registration-constants.ts b/apps/react-router/saas-template/app/features/user-authentication/registration/registration-constants.ts
new file mode 100644
index 0000000..74e22c4
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/registration/registration-constants.ts
@@ -0,0 +1,4 @@
+export const registerIntents = {
+ registerWithEmail: "registerWithEmail",
+ registerWithGoogle: "registerWithGoogle",
+} as const;
diff --git a/apps/react-router/saas-template/app/features/user-authentication/registration/registration-schemas.ts b/apps/react-router/saas-template/app/features/user-authentication/registration/registration-schemas.ts
new file mode 100644
index 0000000..73f4ecf
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/registration/registration-schemas.ts
@@ -0,0 +1,16 @@
+import * as z from "zod";
+
+import { registerIntents } from "./registration-constants";
+
+z.config({ jitless: true });
+
+export const registerWithEmailSchema = z.object({
+ email: z.email({
+ message: "userAuthentication:register.errors.invalidEmail",
+ }),
+ intent: z.literal(registerIntents.registerWithEmail),
+});
+
+export const registerWithGoogleSchema = z.object({
+ intent: z.literal(registerIntents.registerWithGoogle),
+});
diff --git a/apps/react-router/saas-template/app/features/user-authentication/registration/registration-verification-awaiting.test.tsx b/apps/react-router/saas-template/app/features/user-authentication/registration/registration-verification-awaiting.test.tsx
new file mode 100644
index 0000000..3b65136
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/registration/registration-verification-awaiting.test.tsx
@@ -0,0 +1,154 @@
+import { faker } from "@faker-js/faker";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+import { registerIntents } from "../user-authentication-constants";
+import type { RegistrationVerificationAwaitingProps } from "./registration-verification-awaiting";
+import { RegistrationVerificationAwaiting } from "./registration-verification-awaiting";
+import { act, createRoutesStub, render, screen } from "~/test/react-test-utils";
+import type { Factory } from "~/utils/types";
+
+const createProps: Factory = ({
+ email = faker.internet.email(),
+ isResending = false,
+ isSubmitting = false,
+} = {}) => ({
+ email,
+ isResending,
+ isSubmitting,
+});
+
+describe("RegistrationVerificationAwaiting Component", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ test("given: component renders with default props, should: display correct content, include email input, show resend button, disable fieldset when waiting and render an alert that the user should check their spam folder", () => {
+ const email = faker.internet.email();
+ const props = createProps({ email });
+ const path = "/register";
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // Verify the card title and description are displayed
+ expect(screen.getByText(/verify your email/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(/we've sent a verification link to your email address/i),
+ ).toBeInTheDocument();
+
+ // Verify the countdown message is displayed (initial countdown is 60 seconds)
+ expect(
+ screen.getByText(/if you haven't received the email within 60 seconds/i),
+ ).toBeInTheDocument();
+
+ // Verify the hidden email input is present with the correct value
+ const hiddenInput = screen.getByDisplayValue(email);
+ expect(hiddenInput).toBeInTheDocument();
+ expect(hiddenInput).toHaveAttribute("type", "hidden");
+ expect(hiddenInput).toHaveAttribute("name", "email");
+
+ // Verify the resend button has the correct text and attributes
+ const button = screen.getByRole("button", {
+ name: /request new verification link/i,
+ });
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveAttribute("name", "intent");
+ expect(button).toHaveAttribute("value", registerIntents.registerWithEmail);
+
+ // Verify the alert is displayed
+ expect(
+ screen.getByText(/remember to check your spam folder/i),
+ ).toBeInTheDocument();
+ });
+
+ test("given: countdown reaches zero after 60 seconds, should: enable the form", () => {
+ const props = createProps();
+ const path = "/register";
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // Initially, the fieldset should be disabled
+ const submitButton = screen.getByRole("button", {
+ name: /request new verification link/i,
+ });
+ expect(submitButton).toBeDisabled();
+
+ // Advance time to make the countdown reach zero
+ act(() => {
+ vi.advanceTimersByTime(60_000); // 60 seconds
+ });
+
+ // Now the fieldset should be enabled
+ expect(submitButton).toBeEnabled();
+
+ // The countdown message should now show the zero state
+ expect(
+ screen.getByText(
+ /if you haven't received the email, you may request another verification link now/i,
+ ),
+ ).toBeInTheDocument();
+ });
+
+ test("given: isResending is true, should: display loading state and disable the button", () => {
+ const props = createProps({ isResending: true });
+ const path = "/register";
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // Advance time to make the countdown reach zero.
+ act(() => {
+ vi.advanceTimersByTime(60_000); // 60 seconds
+ });
+
+ // Get the button with the loading text.
+ const button = screen.getByRole("button", { name: /sending/i });
+ expect(button).toBeDisabled();
+ });
+
+ test("given: isSubmitting is true, should: disable the form regardless of countdown", () => {
+ const props = createProps({ isSubmitting: true });
+ const path = "/register";
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path,
+ },
+ ]);
+
+ render( );
+
+ // Advance time to make the countdown reach zero
+ act(() => {
+ vi.advanceTimersByTime(60_000); // 60 seconds
+ });
+
+ // The submit button should still be disabled due to isSubmitting, even
+ // though countdown is at zero.
+ const submitButton = screen.getByRole("button", {
+ name: /request new verification link/i,
+ });
+ expect(submitButton).toBeDisabled();
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/user-authentication/registration/registration-verification-awaiting.tsx b/apps/react-router/saas-template/app/features/user-authentication/registration/registration-verification-awaiting.tsx
new file mode 100644
index 0000000..065a75d
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/registration/registration-verification-awaiting.tsx
@@ -0,0 +1,89 @@
+import { IconAlertTriangle } from "@tabler/icons-react";
+import { Trans, useTranslation } from "react-i18next";
+import { Form } from "react-router";
+
+import { useCountdown } from "../use-countdown";
+import { registerIntents } from "../user-authentication-constants";
+import { Alert, AlertDescription } from "~/components/ui/alert";
+import { Button } from "~/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { Spinner } from "~/components/ui/spinner";
+
+export type RegistrationVerificationAwaitingProps = {
+ email: string;
+ isResending?: boolean;
+ isSubmitting?: boolean;
+};
+
+export function RegistrationVerificationAwaiting({
+ email,
+ isResending = false,
+ isSubmitting = false,
+}: RegistrationVerificationAwaitingProps) {
+ const { t } = useTranslation("userAuthentication", {
+ keyPrefix: "register.magicLink",
+ });
+
+ const { secondsLeft, reset } = useCountdown(60);
+
+ const waitingToResend = secondsLeft !== 0;
+
+ return (
+
+
+ {t("cardTitle")}
+
+
+ {t("cardDescription")}
+
+
+
+
+
+
+ }}
+ count={secondsLeft}
+ i18nKey="register.magicLink.countdownMessage"
+ ns="userAuthentication"
+ />
+
+
+
reset()}>
+
+
+
+
+ {isResending ? (
+ <>
+
+ {t("resendButtonLoading")}
+ >
+ ) : (
+ t("resendButton")
+ )}
+
+
+
+
+
+
+
+ {t("alertDescription")}
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/user-authentication/supabase.server.ts b/apps/react-router/saas-template/app/features/user-authentication/supabase.server.ts
new file mode 100644
index 0000000..c76a88a
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/supabase.server.ts
@@ -0,0 +1,40 @@
+import {
+ createServerClient,
+ parseCookieHeader,
+ serializeCookieHeader,
+} from "@supabase/ssr";
+import { createClient } from "@supabase/supabase-js";
+
+export function createSupabaseServerClient({ request }: { request: Request }) {
+ const headers = new Headers();
+
+ const supabase = createServerClient(
+ process.env.VITE_SUPABASE_URL,
+ process.env.VITE_SUPABASE_ANON_KEY,
+ {
+ auth: { flowType: "pkce" },
+ cookies: {
+ getAll() {
+ return parseCookieHeader(request.headers.get("Cookie") ?? "") as {
+ name: string;
+ value: string;
+ }[];
+ },
+ setAll(cookiesToSet) {
+ for (const { name, value, options } of cookiesToSet)
+ headers.append(
+ "Set-Cookie",
+ serializeCookieHeader(name, value, options),
+ );
+ },
+ },
+ },
+ );
+
+ return { headers, supabase };
+}
+
+export const supabaseAdminClient = createClient(
+ process.env.VITE_SUPABASE_URL,
+ process.env.SUPABASE_SERVICE_ROLE_KEY,
+);
diff --git a/apps/react-router/saas-template/app/features/user-authentication/use-countdown.test.tsx b/apps/react-router/saas-template/app/features/user-authentication/use-countdown.test.tsx
new file mode 100644
index 0000000..296bbe9
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/use-countdown.test.tsx
@@ -0,0 +1,141 @@
+// src/hooks/useCountdown.test.ts
+
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+import { useCountdown } from "./use-countdown";
+import { act, renderHook } from "~/test/react-test-utils";
+
+describe("useCountdown()", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ test("given: an initial time in seconds, should: initialize with that value", () => {
+ const { result } = renderHook(() => useCountdown(60));
+ expect(result.current.secondsLeft).toEqual(60);
+ });
+
+ test("given: an initial time greater than zero, should: count down every second until zero", () => {
+ const { result } = renderHook(() => useCountdown(3));
+
+ expect(result.current.secondsLeft).toEqual(3);
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.secondsLeft).toEqual(2);
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.secondsLeft).toEqual(1);
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.secondsLeft).toEqual(0);
+ });
+
+ test("given: a countdown reaching zero, should: stop at zero and not continue", () => {
+ const { result } = renderHook(() => useCountdown(2));
+
+ expect(result.current.secondsLeft).toEqual(2);
+
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+ expect(result.current.secondsLeft).toEqual(0);
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(result.current.secondsLeft).toEqual(0);
+ });
+
+ test("given: a new initial time value, should: reset the countdown to the new value", () => {
+ const { result, rerender } = renderHook(
+ ({ initialSeconds }) => useCountdown(initialSeconds),
+ { initialProps: { initialSeconds: 5 } },
+ );
+
+ expect(result.current.secondsLeft).toEqual(5);
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(result.current.secondsLeft).toEqual(3);
+
+ rerender({ initialSeconds: 10 });
+ expect(result.current.secondsLeft).toEqual(10);
+ });
+
+ test("given: zero or negative initial values, should: remain at the initial value without counting", () => {
+ const { result: zeroResult } = renderHook(() => useCountdown(0));
+ expect(zeroResult.current.secondsLeft).toEqual(0);
+
+ const { result: negativeResult } = renderHook(() => useCountdown(-5));
+ expect(negativeResult.current.secondsLeft).toEqual(-5);
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(zeroResult.current.secondsLeft).toEqual(0);
+ expect(negativeResult.current.secondsLeft).toEqual(-5);
+ });
+
+ test("given: component unmounting, should: stop counting and maintain last value", () => {
+ const { result, unmount } = renderHook(() => useCountdown(5));
+
+ expect(result.current.secondsLeft).toEqual(5);
+
+ unmount();
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(result.current.secondsLeft).toEqual(5);
+ });
+
+ test("given: reset function is called, should: reset the timer back to initialSeconds", () => {
+ const { result } = renderHook(() => useCountdown(10));
+
+ expect(result.current.secondsLeft).toEqual(10);
+
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+ expect(result.current.secondsLeft).toEqual(7);
+
+ act(() => {
+ result.current.reset();
+ });
+ expect(result.current.secondsLeft).toEqual(10);
+ });
+
+ test("given: reset function is called after countdown reaches zero, should: restart the countdown", () => {
+ const { result } = renderHook(() => useCountdown(3));
+
+ expect(result.current.secondsLeft).toEqual(3);
+
+ act(() => {
+ vi.advanceTimersByTime(4000);
+ });
+ expect(result.current.secondsLeft).toEqual(0);
+
+ act(() => {
+ result.current.reset();
+ });
+ expect(result.current.secondsLeft).toEqual(3);
+
+ // Verify countdown continues after reset
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.secondsLeft).toEqual(2);
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/user-authentication/use-countdown.ts b/apps/react-router/saas-template/app/features/user-authentication/use-countdown.ts
new file mode 100644
index 0000000..3869ee8
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/use-countdown.ts
@@ -0,0 +1,68 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+/**
+ * A React hook that provides a countdown timer functionality.
+ *
+ * @param initialSeconds - The initial number of seconds for the countdown.
+ *
+ * @returns An object containing:
+ * - `secondsLeft`: The current number of seconds left in the countdown.
+ * - `reset`: A function to reset the countdown to the initial seconds.
+ */
+export function useCountdown(initialSeconds: number) {
+ const [secondsLeft, setSecondsLeft] = useState(initialSeconds);
+ const intervalIdReference = useRef(undefined);
+
+ // Function to start or restart the countdown.
+ const startCountdown = useCallback(() => {
+ // Clear any existing interval.
+ if (intervalIdReference.current) {
+ clearInterval(intervalIdReference.current);
+ intervalIdReference.current = undefined;
+ }
+
+ // Don't start interval if value is 0 or negative.
+ if (secondsLeft <= 0) return;
+
+ intervalIdReference.current = setInterval(() => {
+ setSecondsLeft((previous) => {
+ if (previous <= 1) {
+ if (intervalIdReference.current) {
+ clearInterval(intervalIdReference.current);
+ intervalIdReference.current = undefined;
+ }
+ return 0;
+ }
+ return previous - 1;
+ });
+ }, 1000);
+ }, [secondsLeft]);
+
+ // Reset function to set the timer back to initialSeconds.
+ const reset = useCallback(() => {
+ setSecondsLeft(initialSeconds);
+ // We'll start the countdown in the useEffect that watches secondsLeft.
+ }, [initialSeconds]);
+
+ // Effect to handle initialSeconds changes.
+ useEffect(() => {
+ // Reset the countdown when initialSeconds changes.
+ setSecondsLeft(initialSeconds);
+ // We'll start the countdown in the useEffect that watches secondsLeft.
+ }, [initialSeconds]);
+
+ // Effect to start/restart countdown when secondsLeft changes.
+ useEffect(() => {
+ startCountdown();
+
+ // Cleanup interval on unmount or when secondsLeft changes.
+ return () => {
+ if (intervalIdReference.current) {
+ clearInterval(intervalIdReference.current);
+ intervalIdReference.current = undefined;
+ }
+ };
+ }, [startCountdown]);
+
+ return { reset, secondsLeft };
+}
diff --git a/apps/react-router/saas-template/app/features/user-authentication/user-authentication-constants.ts b/apps/react-router/saas-template/app/features/user-authentication/user-authentication-constants.ts
new file mode 100644
index 0000000..51f1e2c
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/user-authentication-constants.ts
@@ -0,0 +1,9 @@
+export const registerIntents = {
+ registerWithEmail: "registerWithEmail",
+ registerWithGoogle: "registerWithGoogle",
+} as const;
+
+export const loginIntents = {
+ loginWithEmail: "loginWithEmail",
+ loginWithGoogle: "loginWithGoogle",
+} as const;
diff --git a/apps/react-router/saas-template/app/features/user-authentication/user-authentication-factories.ts b/apps/react-router/saas-template/app/features/user-authentication/user-authentication-factories.ts
new file mode 100644
index 0000000..a6e7121
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/user-authentication-factories.ts
@@ -0,0 +1,65 @@
+import { faker } from "@faker-js/faker";
+import { createId } from "@paralleldrive/cuid2";
+import type { Session, User } from "@supabase/supabase-js";
+
+import type { Factory } from "~/utils/types";
+
+/**
+ * Creates a populated Supabase user with default values.
+ *
+ * @param userParams - User params to create Supabase user with.
+ * @returns A populated Supabase user with given params.
+ */
+export const createPopulatedSupabaseUser: Factory = ({
+ id = faker.string.uuid(),
+ email = faker.internet.email(),
+ app_metadata = {},
+ user_metadata = {},
+ aud = "authenticated",
+ updated_at = faker.date.recent({ days: 10 }).toISOString(),
+ created_at = faker.date.past({ refDate: updated_at, years: 1 }).toISOString(),
+ role = "authenticated",
+ phone,
+ confirmed_at = faker.date
+ .past({ refDate: updated_at, years: 0.9 })
+ .toISOString(),
+ last_sign_in_at = faker.date
+ .recent({ days: 5, refDate: updated_at })
+ .toISOString(),
+ factors,
+} = {}) => ({
+ app_metadata,
+ aud,
+ confirmed_at,
+ created_at,
+ email,
+ factors,
+ id,
+ last_sign_in_at,
+ phone,
+ role,
+ updated_at,
+ user_metadata,
+});
+
+/**
+ * Creates a populated Supabase session with default values.
+ *
+ * @param sessionParams - Session params to create Supabase session with.
+ * @returns A populated Supabase session with given params.
+ */
+export const createPopulatedSupabaseSession: Factory = ({
+ access_token = `access_token_${createId()}`,
+ refresh_token = `refresh_token_${createId()}`,
+ expires_in = 3600,
+ expires_at = Math.floor(Date.now() / 1000) + 3600,
+ token_type = "bearer",
+ user = createPopulatedSupabaseUser(),
+} = {}) => ({
+ access_token,
+ expires_at,
+ expires_in,
+ refresh_token,
+ token_type,
+ user,
+});
diff --git a/apps/react-router/saas-template/app/features/user-authentication/user-authentication-helpers.server.ts b/apps/react-router/saas-template/app/features/user-authentication/user-authentication-helpers.server.ts
new file mode 100644
index 0000000..04bef89
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/user-authentication-helpers.server.ts
@@ -0,0 +1,17 @@
+import { redirect } from "react-router";
+import { safeRedirect } from "remix-utils/safe-redirect";
+
+import { createSupabaseServerClient } from "./supabase.server";
+
+/**
+ * Logs out the current user by signing them out of Supabase auth.
+ *
+ * @param request - The incoming request object.
+ * @param redirectTo - The path to redirect to after logout (defaults to root).
+ * @returns Redirect response to the login page.
+ */
+export async function logout(request: Request, redirectTo = "/") {
+ const { supabase, headers } = createSupabaseServerClient({ request });
+ await supabase.auth.signOut();
+ return redirect(safeRedirect(redirectTo), { headers });
+}
diff --git a/apps/react-router/saas-template/app/features/user-authentication/user-authentication-helpers.test.ts b/apps/react-router/saas-template/app/features/user-authentication/user-authentication-helpers.test.ts
new file mode 100644
index 0000000..255fb0b
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/user-authentication-helpers.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, test } from "vitest";
+
+import { getIsAwaitingEmailConfirmation } from "./user-authentication-helpers";
+
+describe("getIsAwaitingEmailConfirmation()", () => {
+ test("given: a valid AuthOtpResponse data with email, should: return true", () => {
+ const data = { email: "test@example.com", session: null, user: null };
+
+ const actual = getIsAwaitingEmailConfirmation(data);
+ const expected = true;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an object missing session property, should: return false", () => {
+ const data = { email: "test@example.com", user: null };
+
+ const actual = getIsAwaitingEmailConfirmation(data);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an object missing user property, should: return false", () => {
+ const data = { email: "test@example.com", session: null };
+
+ const actual = getIsAwaitingEmailConfirmation(data);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an object missing email property, should: return false", () => {
+ const data = { session: null, user: null };
+
+ const actual = getIsAwaitingEmailConfirmation(data);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an object with non-null session, should: return false", () => {
+ const data = { email: "test@example.com", session: {}, user: null };
+
+ const actual = getIsAwaitingEmailConfirmation(data);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an object with non-null user, should: return false", () => {
+ const data = {
+ email: "test@example.com",
+ session: null,
+ user: {},
+ };
+
+ const actual = getIsAwaitingEmailConfirmation(data);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: an object with non-string email, should: return false", () => {
+ const data = { email: 123, session: null, user: null };
+
+ const actual = getIsAwaitingEmailConfirmation(data);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: null, should: return false", () => {
+ const data = null;
+
+ const actual = getIsAwaitingEmailConfirmation(data);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: undefined, should: return false", () => {
+ const data = undefined;
+
+ const actual = getIsAwaitingEmailConfirmation(data);
+ const expected = false;
+
+ expect(actual).toEqual(expected);
+ });
+});
diff --git a/apps/react-router/saas-template/app/features/user-authentication/user-authentication-helpers.ts b/apps/react-router/saas-template/app/features/user-authentication/user-authentication-helpers.ts
new file mode 100644
index 0000000..9200f36
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/user-authentication-helpers.ts
@@ -0,0 +1,18 @@
+import type { AuthOtpResponse } from "@supabase/supabase-js";
+
+export function getIsAwaitingEmailConfirmation(
+ data: unknown,
+): data is AuthOtpResponse["data"] & { email: string } {
+ if (typeof data !== "object" || data === null) {
+ return false;
+ }
+
+ return (
+ "session" in data &&
+ "user" in data &&
+ "email" in data &&
+ data.session === null &&
+ data.user === null &&
+ typeof data.email === "string"
+ );
+}
diff --git a/apps/react-router/saas-template/app/features/user-authentication/user-authentication-middleware.server.ts b/apps/react-router/saas-template/app/features/user-authentication/user-authentication-middleware.server.ts
new file mode 100644
index 0000000..a50e8bb
--- /dev/null
+++ b/apps/react-router/saas-template/app/features/user-authentication/user-authentication-middleware.server.ts
@@ -0,0 +1,83 @@
+import type { Session, SupabaseClient, User } from "@supabase/supabase-js";
+import type { MiddlewareFunction } from "react-router";
+import { createContext, href, redirect } from "react-router";
+import { safeRedirect } from "remix-utils/safe-redirect";
+
+import { createSupabaseServerClient } from "./supabase.server";
+
+export const authContext = createContext<{
+ supabase: SupabaseClient;
+ user: User;
+ headers: Headers;
+}>();
+
+const EXP_BUFFER_SEC = 60;
+
+function isSessionFresh(
+ session: Session | null | undefined,
+): session is Session {
+ if (!session) return false;
+ const now = Math.floor(Date.now() / 1000);
+ // prefer expires_at, else compute from expires_in if you persisted it
+ const exp = session.expires_at ?? now + (session.expires_in ?? 0);
+ return exp > now + EXP_BUFFER_SEC;
+}
+
+export const authMiddleware: MiddlewareFunction = async (
+ { request, context },
+ next,
+) => {
+ const { supabase, headers } = createSupabaseServerClient({ request });
+
+ if (request.method === "GET" || request.method === "HEAD") {
+ const {
+ data: { session },
+ } = await supabase.auth.getSession();
+
+ if (isSessionFresh(session)) {
+ context.set(authContext, { headers, supabase, user: session.user });
+ return await next();
+ }
+ }
+
+ const {
+ data: { user },
+ error,
+ } = await supabase.auth.getUser();
+
+ if (error || !user) {
+ const redirectTo = new URL(request.url).pathname;
+ const searchParameters = new URLSearchParams([["redirectTo", redirectTo]]);
+ throw redirect(safeRedirect(`/login?${searchParameters.toString()}`), {
+ headers,
+ });
+ }
+
+ context.set(authContext, { headers, supabase, user });
+
+ return await next();
+};
+
+export const anonymousContext = createContext<{
+ supabase: SupabaseClient;
+ headers: Headers;
+}>();
+
+export const anonymousMiddleware: MiddlewareFunction = async (
+ { request, context },
+ next,
+) => {
+ const { supabase, headers } = createSupabaseServerClient({ request });
+ const {
+ data: { user },
+ error,
+ } = await supabase.auth.getUser();
+
+ if (!error && user) {
+ throw redirect(href("/organizations"), { headers });
+ }
+
+ context.set(anonymousContext, { headers, supabase });
+
+ return await next();
+};
diff --git a/apps/react-router/saas-template/app/hooks/use-media-query.ts b/apps/react-router/saas-template/app/hooks/use-media-query.ts
new file mode 100644
index 0000000..ffda23b
--- /dev/null
+++ b/apps/react-router/saas-template/app/hooks/use-media-query.ts
@@ -0,0 +1,19 @@
+import { useEffect, useState } from "react";
+
+export function useMediaQuery(query: string) {
+ const [value, setValue] = useState(false);
+
+ useEffect(() => {
+ function onChange(event: MediaQueryListEvent) {
+ setValue(event.matches);
+ }
+
+ const result = matchMedia(query);
+ result.addEventListener("change", onChange);
+ setValue(result.matches);
+
+ return () => result.removeEventListener("change", onChange);
+ }, [query]);
+
+ return value;
+}
diff --git a/apps/react-router/saas-template/app/hooks/use-mobile.ts b/apps/react-router/saas-template/app/hooks/use-mobile.ts
new file mode 100644
index 0000000..ab2a3bd
--- /dev/null
+++ b/apps/react-router/saas-template/app/hooks/use-mobile.ts
@@ -0,0 +1,21 @@
+import { useEffect, useState } from "react";
+
+const MOBILE_BREAKPOINT = 768;
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = useState();
+
+ useEffect(() => {
+ const mql = globalThis.matchMedia(
+ `(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
+ );
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ };
+ mql.addEventListener("change", onChange);
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ return () => mql.removeEventListener("change", onChange);
+ }, []);
+
+ return !!isMobile;
+}
diff --git a/apps/react-router/saas-template/app/hooks/use-prefers-reduced-motion.ts b/apps/react-router/saas-template/app/hooks/use-prefers-reduced-motion.ts
new file mode 100644
index 0000000..2346dea
--- /dev/null
+++ b/apps/react-router/saas-template/app/hooks/use-prefers-reduced-motion.ts
@@ -0,0 +1,25 @@
+import { useEffect, useState } from "react";
+
+/**
+ * Hook to detect if the user prefers reduced motion.
+ * Respects the system preference and updates when it changes.
+ *
+ * @returns boolean indicating if the user prefers reduced motion
+ */
+export function usePrefersReducedMotion(): boolean {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
+ setPrefersReducedMotion(mediaQuery.matches);
+
+ const handler = (event: MediaQueryListEvent) =>
+ setPrefersReducedMotion(event.matches);
+
+ mediaQuery.addEventListener("change", handler);
+
+ return () => mediaQuery.removeEventListener("change", handler);
+ }, []);
+
+ return prefersReducedMotion;
+}
diff --git a/apps/react-router/saas-template/app/hooks/use-preview-url.test.tsx b/apps/react-router/saas-template/app/hooks/use-preview-url.test.tsx
new file mode 100644
index 0000000..ed106c1
--- /dev/null
+++ b/apps/react-router/saas-template/app/hooks/use-preview-url.test.tsx
@@ -0,0 +1,149 @@
+import { renderHook } from "@testing-library/react";
+// Make sure to import afterEach if you use it, though clearAllMocks in beforeEach is often sufficient
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { usePreviewUrl } from "./use-preview-url";
+
+type PreviewUrlProps = {
+ file: File | undefined;
+ initialUrl: string | undefined;
+};
+
+const renderPreviewUrlHook = (props: PreviewUrlProps) => {
+ return renderHook(
+ (hookProps: PreviewUrlProps) =>
+ usePreviewUrl(hookProps.file, hookProps.initialUrl),
+ { initialProps: props },
+ );
+};
+
+describe("usePreviewUrl Hook", () => {
+ let urlCounter = 0;
+ const createObjectURLSpy = vi.spyOn(URL, "createObjectURL");
+ const revokeObjectURLSpy = vi.spyOn(URL, "revokeObjectURL");
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ urlCounter = 0;
+ createObjectURLSpy.mockImplementation(() => {
+ urlCounter++;
+ return `blob:test-url-${urlCounter}`; // Return unique URLs
+ });
+ });
+
+ test("given: no file and no initial URL, should: return undefined", () => {
+ const { result } = renderPreviewUrlHook({
+ file: undefined,
+ initialUrl: undefined,
+ });
+
+ expect(result.current).toBeUndefined();
+ expect(createObjectURLSpy).not.toHaveBeenCalled();
+ });
+
+ test("given: no file but initial URL provided, should: return initial URL", () => {
+ const initialUrl = "https://example.com/image.jpg";
+ const { result } = renderPreviewUrlHook({
+ file: undefined,
+ initialUrl,
+ });
+
+ expect(result.current).toEqual(initialUrl);
+ expect(createObjectURLSpy).not.toHaveBeenCalled();
+ });
+
+ test("given: file provided, should: create and return object URL", () => {
+ const file = new File(["test"], "test.jpg", { type: "image/jpeg" });
+ const { result } = renderPreviewUrlHook({
+ file,
+ initialUrl: undefined,
+ });
+
+ const expectedUrl = "blob:test-url-1";
+ expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
+ expect(createObjectURLSpy).toHaveBeenCalledExactlyOnceWith(file);
+ expect(result.current).toEqual(expectedUrl);
+ expect(revokeObjectURLSpy).not.toHaveBeenCalled();
+ });
+
+ test("given: file changes, should: revoke old URL and create new one", () => {
+ const file1 = new File(["test1"], "test1.jpg", { type: "image/jpeg" });
+ const file2 = new File(["test2"], "test2.jpg", { type: "image/jpeg" });
+
+ const { result, rerender } = renderPreviewUrlHook({
+ file: file1,
+ initialUrl: undefined,
+ });
+
+ const initialMockUrl = "blob:test-url-1";
+ expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
+ expect(createObjectURLSpy).toHaveBeenCalledExactlyOnceWith(file1);
+ expect(result.current).toEqual(initialMockUrl);
+ expect(revokeObjectURLSpy).not.toHaveBeenCalled();
+
+ // --- Rerender with new file ---
+ rerender({ file: file2, initialUrl: undefined });
+
+ // 1. Check revocation of the *old* URL
+ expect(revokeObjectURLSpy).toHaveBeenCalledTimes(1);
+ expect(revokeObjectURLSpy).toHaveBeenCalledExactlyOnceWith(initialMockUrl);
+
+ // 2. Check creation of the *new* URL
+ const newMockUrl = "blob:test-url-2";
+ expect(createObjectURLSpy).toHaveBeenCalledTimes(2);
+ expect(createObjectURLSpy).toHaveBeenNthCalledWith(2, file2);
+
+ // 3. Check the current URL is the new one
+ expect(result.current).toEqual(newMockUrl);
+ });
+
+ test("given: component unmounts, should: revoke object URL", () => {
+ const file = new File(["test"], "test.jpg", { type: "image/jpeg" });
+ const { unmount, result } = renderPreviewUrlHook({
+ file,
+ initialUrl: undefined,
+ });
+
+ const initialMockUrl = "blob:test-url-1";
+ expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
+ expect(createObjectURLSpy).toHaveBeenCalledExactlyOnceWith(file);
+ expect(result.current).toEqual(initialMockUrl);
+ expect(revokeObjectURLSpy).not.toHaveBeenCalled();
+
+ // --- Unmount ---
+ unmount();
+
+ // Check revocation happened on unmount
+ expect(revokeObjectURLSpy).toHaveBeenCalledTimes(1);
+ expect(revokeObjectURLSpy).toHaveBeenCalledExactlyOnceWith(initialMockUrl);
+ });
+
+ test("given: file is removed, should: revoke URL and return to initial URL", () => {
+ const file = new File(["test"], "test.jpg", { type: "image/jpeg" });
+ const initialUrl = "https://example.com/image.jpg";
+
+ const { result, rerender } = renderPreviewUrlHook({
+ file,
+ initialUrl,
+ });
+
+ const initialMockUrl = "blob:test-url-1";
+ expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
+ expect(createObjectURLSpy).toHaveBeenCalledExactlyOnceWith(file);
+ expect(result.current).toEqual(initialMockUrl);
+ expect(revokeObjectURLSpy).not.toHaveBeenCalled();
+
+ // --- Rerender with file removed ---
+ rerender({ file: undefined, initialUrl });
+
+ // 1. Check revocation of the *old* URL
+ expect(revokeObjectURLSpy).toHaveBeenCalledTimes(1);
+ expect(revokeObjectURLSpy).toHaveBeenCalledExactlyOnceWith(initialMockUrl);
+
+ // 2. Check createObjectURL was *not* called again
+ expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
+
+ // 3. Check the current URL is the initial fallback URL
+ expect(result.current).toEqual(initialUrl);
+ });
+});
diff --git a/apps/react-router/saas-template/app/hooks/use-preview-url.ts b/apps/react-router/saas-template/app/hooks/use-preview-url.ts
new file mode 100644
index 0000000..726791c
--- /dev/null
+++ b/apps/react-router/saas-template/app/hooks/use-preview-url.ts
@@ -0,0 +1,42 @@
+import { useEffect, useState } from "react";
+
+/**
+ * A hook that manages a preview URL for a file input.
+ * Creates and revokes object URLs for file previews, with automatic cleanup.
+ *
+ * @param file - The File object to create a preview URL for, or undefined if no
+ * file is selected
+ * @param initialUrl - The initial URL to fall back to when no file is selected
+ * @returns The current preview URL, either from the file or the initial URL
+ */
+export function usePreviewUrl(
+ file: File | undefined,
+ initialUrl: string | undefined,
+) {
+ const [previewUrl, setPreviewUrl] = useState(initialUrl);
+
+ // Effect to create/revoke Object URL for preview when file changes
+ useEffect(() => {
+ let objectUrl: string | undefined;
+
+ if (file instanceof File) {
+ // Create a temporary URL for the selected file
+ objectUrl = URL.createObjectURL(file);
+ setPreviewUrl(objectUrl);
+ } else {
+ // If the file is removed or invalid, fall back to the initial URL
+ setPreviewUrl(initialUrl);
+ }
+
+ // Cleanup function: Revoke the object URL when the component
+ // unmounts or when the file changes again to prevent memory leaks
+ return () => {
+ if (objectUrl) {
+ URL.revokeObjectURL(objectUrl);
+ }
+ };
+ // Re-run effect if file or initialUrl changes
+ }, [file, initialUrl]);
+
+ return previewUrl;
+}
diff --git a/apps/react-router/saas-template/app/hooks/use-toast.ts b/apps/react-router/saas-template/app/hooks/use-toast.ts
new file mode 100644
index 0000000..a3f7674
--- /dev/null
+++ b/apps/react-router/saas-template/app/hooks/use-toast.ts
@@ -0,0 +1,26 @@
+import { useEffect } from "react";
+import { toast as showToast } from "sonner";
+
+import type { Toast } from "~/utils/toast.server";
+
+/**
+ * Custom hook for displaying a toast notification.
+ * If a `toast` object is provided, it triggers a toast notification with the
+ * specified type, title, and description after a brief delay. The toast is
+ * displayed using the `showToast` function, which is based on the `toast.type`.
+ *
+ * @param toast - Optional. The toast object containing the type, title, and
+ * description of the notification. If null or undefined, no toast is shown.
+ */
+export function useToast(toast?: Toast | null) {
+ useEffect(() => {
+ if (toast) {
+ setTimeout(() => {
+ showToast[toast.type](toast.title, {
+ description: toast.description,
+ id: toast.id,
+ });
+ }, 0);
+ }
+ }, [toast]);
+}
diff --git a/apps/react-router/saas-template/app/lib/README.md b/apps/react-router/saas-template/app/lib/README.md
new file mode 100644
index 0000000..c350119
--- /dev/null
+++ b/apps/react-router/saas-template/app/lib/README.md
@@ -0,0 +1,5 @@
+# Lib
+
+**Important:** Do NOT add files to this directory. It's only here because Shadcn
+uses it. But it's always bad to have multiple "misc" folders and the `utils/`
+folder already contains all the files that don't fit in the other folders.
diff --git a/apps/react-router/saas-template/app/lib/supabase/client.ts b/apps/react-router/saas-template/app/lib/supabase/client.ts
new file mode 100644
index 0000000..8761210
--- /dev/null
+++ b/apps/react-router/saas-template/app/lib/supabase/client.ts
@@ -0,0 +1,8 @@
+import { createBrowserClient } from "@supabase/ssr";
+
+export function createClient() {
+ return createBrowserClient(
+ import.meta.env.VITE_SUPABASE_URL as string,
+ import.meta.env.VITE_SUPABASE_ANON_KEY as string,
+ );
+}
diff --git a/apps/react-router/saas-template/app/lib/utils.ts b/apps/react-router/saas-template/app/lib/utils.ts
new file mode 100644
index 0000000..88283f0
--- /dev/null
+++ b/apps/react-router/saas-template/app/lib/utils.ts
@@ -0,0 +1,7 @@
+import type { ClassValue } from "clsx";
+import { clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/react-router/saas-template/app/root.tsx b/apps/react-router/saas-template/app/root.tsx
new file mode 100644
index 0000000..7be99e0
--- /dev/null
+++ b/apps/react-router/saas-template/app/root.tsx
@@ -0,0 +1,227 @@
+import "./app.css";
+
+import { FormOptionsProvider } from "@conform-to/react/future";
+import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import type { ShouldRevalidateFunctionArgs } from "react-router";
+import {
+ data,
+ isRouteErrorResponse,
+ Links,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useMatches,
+ useRouteError,
+ useRouteLoaderData,
+} from "react-router";
+import { HoneypotProvider } from "remix-utils/honeypot/react";
+import { promiseHash } from "remix-utils/promise";
+import sonnerStyles from "sonner/dist/styles.css?url";
+
+import type { Route } from "./+types/root";
+import { NotFound } from "./components/not-found";
+import { Toaster } from "./components/ui/sonner";
+import { getColorScheme } from "./features/color-scheme/color-scheme.server";
+import { useColorScheme } from "./features/color-scheme/use-color-scheme";
+import {
+ getInstance,
+ getLocale,
+ i18nextMiddleware,
+ localeCookie,
+} from "./features/localization/i18next-middleware.server";
+import { useToast } from "./hooks/use-toast";
+import { cn } from "./lib/utils";
+import { ClientHintCheck, getHints } from "./utils/client-hints";
+import { combineHeaders } from "./utils/combine-headers.server";
+import { defineCustomMetadata } from "./utils/define-custom-metadata";
+import { getEnv } from "./utils/env.server";
+import { getDomainUrl } from "./utils/get-domain-url.server";
+import { honeypot } from "./utils/honeypot.server";
+import { useNonce } from "./utils/nonce-provider";
+import { securityMiddleware } from "./utils/security-middleware.server";
+import { getToast } from "./utils/toast.server";
+
+export const links: Route.LinksFunction = () => [
+ { href: sonnerStyles, rel: "stylesheet" },
+];
+
+/**
+ * By enabling single fetch, the loaders will no longer revalidate the data when the action status is in the 4xx range.
+ * This behavior will prevent toasts from being displayed for failed actions.
+ * so, we opt in to revalidate the root loader data when the action status is in the 4xx range.
+ */
+export const shouldRevalidate = ({
+ defaultShouldRevalidate,
+ actionStatus,
+}: ShouldRevalidateFunctionArgs) => {
+ if (actionStatus && actionStatus > 399 && actionStatus < 500) {
+ return true;
+ }
+
+ return defaultShouldRevalidate;
+};
+
+export const middleware = [securityMiddleware, i18nextMiddleware];
+
+export async function loader({ request, context }: Route.LoaderArgs) {
+ const { colorScheme, honeypotInputProps, toastData } = await promiseHash({
+ colorScheme: getColorScheme(request),
+ honeypotInputProps: honeypot.getInputProps(),
+ toastData: getToast(request),
+ });
+ const locale = getLocale(context);
+ const i18next = getInstance(context);
+ const title = i18next.t("appName");
+ const { toast, headers: toastHeaders } = toastData;
+ return data(
+ {
+ colorScheme,
+ ENV: getEnv(),
+ honeypotInputProps,
+ locale,
+ requestInfo: {
+ hints: getHints(request),
+ origin: getDomainUrl(request),
+ path: new URL(request.url).pathname,
+ userPrefs: { theme: colorScheme },
+ },
+ title,
+ toast,
+ },
+ {
+ headers: combineHeaders(
+ { "Set-Cookie": await localeCookie.serialize(locale) },
+ toastHeaders,
+ ),
+ },
+ );
+}
+
+export const meta: Route.MetaFunction = ({ loaderData }) => [
+ { title: loaderData?.title },
+];
+
+export function Layout({
+ children,
+}: { children: React.ReactNode } & Route.ComponentProps) {
+ const data = useRouteLoaderData("root");
+ const { i18n } = useTranslation();
+ const allowIndexing = data?.ENV.ALLOW_INDEXING !== "false";
+ const colorScheme = useColorScheme();
+ const error = useRouteError();
+ const isErrorFromRoute = isRouteErrorResponse(error);
+ const matches = useMatches();
+ const nonce = useNonce();
+ const hideOverflow = matches.some(
+ (match) =>
+ match.pathname.startsWith("/onboarding") ||
+ match.id === "routes/_user-authentication+/_user-authentication-layout",
+ );
+ useToast(data?.toast);
+
+ return (
+
+
+
+
+
+
+ {/* Prevent search engine indexing when ALLOW_INDEXING=false */}
+ {allowIndexing ? null : (
+
+ )}
+
+
+
+ {isErrorFromRoute && (
+ {`${error.status} ${error.statusText}`}
+ )}
+
+
+
+
+
+ {children}
+
+
+
+ {/* Add nonce to inline scripts */}
+
+
+ {/* React Router's built-in components accept nonce prop */}
+
+
+
+
+
+ );
+}
+
+export default function App({ loaderData: { locale } }: Route.ComponentProps) {
+ const { i18n } = useTranslation();
+
+ useEffect(() => {
+ if (i18n.language !== locale) {
+ i18n.changeLanguage(locale);
+ }
+ }, [i18n, locale]);
+
+ return ;
+}
+
+function BaseErrorBoundary({ error }: Route.ErrorBoundaryProps) {
+ let message = "Oops!";
+ let details = "An unexpected error occurred.";
+ let stack: string | undefined;
+
+ if (isRouteErrorResponse(error)) {
+ message = error.status === 404 ? "404" : "Error";
+ details =
+ error.status === 404
+ ? "The requested page could not be found."
+ : error.statusText || details;
+ } else if (import.meta.env.DEV && error && error instanceof Error) {
+ details = error.message;
+ stack = error.stack;
+ }
+
+ return (
+
+ {message}
+ {details}
+ {stack && (
+
+ {stack}
+
+ )}
+
+ );
+}
+
+export function ErrorBoundary({ error, ...props }: Route.ErrorBoundaryProps) {
+ if (isRouteErrorResponse(error) && error.status === 404) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/apps/react-router/saas-template/app/routes.ts b/apps/react-router/saas-template/app/routes.ts
new file mode 100644
index 0000000..2413343
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes.ts
@@ -0,0 +1,17 @@
+import { remixRoutesOptionAdapter } from "@react-router/remix-routes-option-adapter";
+import { flatRoutes } from "remix-flat-routes";
+
+export default remixRoutesOptionAdapter((defineRoutes) => {
+ return flatRoutes("routes", defineRoutes, {
+ ignoredRouteFiles: [
+ "**/.*", // Ignore dotfiles like .DS_Store
+ "**/*.{test,spec}.{js,jsx,ts,tsx}",
+ // This is for server-side utilities you want to colocate next to your
+ // routes without making an additional directory. If you need a route that
+ // includes "server" or "client" in the filename, use the escape brackets
+ // like: my-route.[server].tsx.
+ "**/*.server.*",
+ "**/*.client.*",
+ ],
+ });
+});
diff --git a/apps/react-router/saas-template/app/routes/$.tsx b/apps/react-router/saas-template/app/routes/$.tsx
new file mode 100644
index 0000000..80aa29f
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/$.tsx
@@ -0,0 +1,24 @@
+import type { Route } from "./+types/$";
+import { NotFound } from "~/components/not-found";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { notFound } from "~/utils/http-responses.server";
+
+export async function loader({ context }: Route.LoaderArgs) {
+ const i18next = getInstance(context);
+ const t = i18next.getFixedT(null, "translation", "notFound");
+
+ return notFound({ title: t("title") });
+}
+
+export const meta: Route.MetaFunction = ({ data }) => {
+ return [{ title: data.title }];
+};
+
+export default function CatchAllRoute() {
+ return (
+ <>
+ 404
+
+ >
+ );
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/_authenticated-routes-layout.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/_authenticated-routes-layout.tsx
new file mode 100644
index 0000000..8e48d6d
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/_authenticated-routes-layout.tsx
@@ -0,0 +1,9 @@
+import { Outlet } from "react-router";
+
+import { authMiddleware } from "~/features/user-authentication/user-authentication-middleware.server";
+
+export const middleware = [authMiddleware];
+
+export default function AuthenticatedRoutesLayout() {
+ return ;
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/_index.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/_index.tsx
new file mode 100644
index 0000000..67be5fb
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/_index.tsx
@@ -0,0 +1,5 @@
+import { href, redirect } from "react-router";
+
+export function loader() {
+ return redirect(href("/onboarding/user-account"));
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/_onboarding-layout.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/_onboarding-layout.tsx
new file mode 100644
index 0000000..fca6c77
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/_onboarding-layout.tsx
@@ -0,0 +1,75 @@
+import { IconLayoutList } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { Outlet, useMatch } from "react-router";
+
+import type { Route } from "./+types/_onboarding-layout";
+import { TalentMap } from "~/features/onboarding/talent-map";
+import { authMiddleware } from "~/features/user-authentication/user-authentication-middleware.server";
+import { cn } from "~/lib/utils";
+
+export const middleware = [authMiddleware];
+
+/**
+ * Loader for the onboarding layout.
+ * Determines whether to show animations based on the environment.
+ * We disable animations in test mode to significantly speed up Playwright tests
+ * and prevent WebGL-related GPU stalls in CI environments.
+ */
+export async function loader() {
+ return {
+ shouldShowAnimations: process.env.NODE_ENV !== "test",
+ };
+}
+
+export default function OnboardingLayout({ loaderData }: Route.ComponentProps) {
+ const { t } = useTranslation("onboarding", { keyPrefix: "layout" });
+ const isUserRoute = useMatch("/onboarding/user-account");
+ const { shouldShowAnimations } = loaderData;
+
+ return (
+
+ {/* Left side */}
+
+
+
+
+
+ {shouldShowAnimations && }
+
+
+ {/* Right side */}
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/organization.spec.ts b/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/organization.spec.ts
new file mode 100644
index 0000000..0970d9e
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/organization.spec.ts
@@ -0,0 +1,362 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: test code */
+import { describe, expect, onTestFinished, test } from "vitest";
+
+import { action } from "./organization";
+import { ONBOARDING_ORGANIZATION_INTENT } from "~/features/onboarding/organization/onboarding-organization-consants";
+import { createPopulatedOrganization } from "~/features/organizations/organizations-factories.server";
+import {
+ deleteOrganizationFromDatabaseById,
+ retrieveOrganizationWithMembershipsFromDatabaseBySlug,
+ saveOrganizationToDatabase,
+ saveOrganizationWithOwnerToDatabase,
+} from "~/features/organizations/organizations-model.server";
+import { createPopulatedUserAccount } from "~/features/user-accounts/user-accounts-factories.server";
+import {
+ deleteUserAccountFromDatabaseById,
+ saveUserAccountToDatabase,
+} from "~/features/user-accounts/user-accounts-model.server";
+import { supabaseHandlers } from "~/test/mocks/handlers/supabase";
+import { setupMockServerLifecycle } from "~/test/msw-test-utils";
+import {
+ createAuthenticatedRequest,
+ createAuthTestContextProvider,
+} from "~/test/test-utils";
+import { slugify } from "~/utils/slugify.server";
+import { toFormData } from "~/utils/to-form-data";
+
+const createUrl = () => `http://localhost:3000/onboarding/organization`;
+
+const pattern = "/onboarding/organization";
+
+async function sendAuthenticatedRequest({
+ userAccount,
+ formData,
+}: {
+ userAccount: ReturnType;
+ formData: FormData;
+}) {
+ const request = await createAuthenticatedRequest({
+ formData,
+ method: "POST",
+ url: createUrl(),
+ user: userAccount,
+ });
+ const params = {};
+
+ return await action({
+ context: await createAuthTestContextProvider({ params, pattern, request }),
+ params,
+ request,
+ unstable_pattern: pattern,
+ });
+}
+
+async function setup(userAccount = createPopulatedUserAccount()) {
+ await saveUserAccountToDatabase(userAccount);
+ onTestFinished(async () => {
+ await deleteUserAccountFromDatabaseById(userAccount.id);
+ });
+
+ return { userAccount };
+}
+
+setupMockServerLifecycle(...supabaseHandlers);
+
+describe("/onboarding/organization route action", () => {
+ test("given: an unauthenticated request, should: throw a redirect to the login page", async () => {
+ expect.assertions(2);
+
+ const request = new Request(createUrl(), {
+ body: toFormData({}),
+ method: "POST",
+ });
+ const params = {};
+
+ try {
+ await action({
+ context: await createAuthTestContextProvider({
+ params,
+ pattern,
+ request,
+ }),
+ params,
+ request,
+ unstable_pattern: pattern,
+ });
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual(
+ `/login?redirectTo=%2Fonboarding%2Forganization`,
+ );
+ }
+ }
+ });
+
+ test("given: a user who has completed onboarding, should: redirect to the organizations page", async () => {
+ expect.assertions(2);
+
+ const { userAccount } = await setup();
+ const organization = await saveOrganizationWithOwnerToDatabase({
+ organization: createPopulatedOrganization(),
+ userId: userAccount.id,
+ });
+ onTestFinished(async () => {
+ await deleteOrganizationFromDatabaseById(organization.id);
+ });
+
+ try {
+ await sendAuthenticatedRequest({ formData: toFormData({}), userAccount });
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual(
+ `/organizations/${organization.slug}`,
+ );
+ }
+ }
+ });
+
+ describe(`${ONBOARDING_ORGANIZATION_INTENT} intent`, () => {
+ const intent = ONBOARDING_ORGANIZATION_INTENT;
+
+ test("given: a valid name for an organization, should: create organization and redirect to organization page", async () => {
+ const { userAccount } = await setup();
+ const organization = createPopulatedOrganization();
+ const formData = toFormData({
+ intent,
+ name: organization.name,
+ });
+
+ const response = (await sendAuthenticatedRequest({
+ formData,
+ userAccount,
+ })) as Response;
+
+ expect(response.status).toEqual(302);
+ const slug = slugify(organization.name);
+ expect(response.headers.get("Location")).toEqual(
+ `/organizations/${slug}`,
+ );
+
+ // Verify organization was created with correct data
+ const createdOrganization =
+ await retrieveOrganizationWithMembershipsFromDatabaseBySlug(slug);
+ expect(createdOrganization).toMatchObject({
+ name: organization.name,
+ });
+ expect(createdOrganization?.memberships[0]?.member.id).toEqual(
+ userAccount.id,
+ );
+ expect(createdOrganization?.memberships[0]?.role).toEqual("owner");
+
+ await deleteOrganizationFromDatabaseById(createdOrganization!.id);
+ });
+
+ test("given: an organization name that already exists, should: create organization with unique slug", async () => {
+ const { userAccount } = await setup();
+
+ // Create first organization
+ const firstOrg = createPopulatedOrganization();
+ await saveOrganizationToDatabase(firstOrg);
+ onTestFinished(async () => {
+ await deleteOrganizationFromDatabaseById(firstOrg.id);
+ });
+
+ // Try to create second organization with same name
+ const formData = toFormData({ intent, name: firstOrg.name });
+
+ const response = (await sendAuthenticatedRequest({
+ formData,
+ userAccount,
+ })) as Response;
+
+ expect(response.status).toEqual(302);
+ const locationHeader = response.headers.get("Location");
+ expect(locationHeader).toMatch(
+ new RegExp(String.raw`^/organizations/${firstOrg.slug}-[\da-z]{8}$`),
+ );
+
+ // Extract slug from redirect URL and verify organization
+ const slug = locationHeader!.split("/").pop()!;
+ const secondOrg =
+ await retrieveOrganizationWithMembershipsFromDatabaseBySlug(slug);
+ expect(secondOrg).toBeTruthy();
+ expect(secondOrg!.name).toEqual(firstOrg.name);
+ expect(secondOrg!.slug).not.toEqual(firstOrg.slug);
+ expect(secondOrg!.memberships).toHaveLength(1);
+ expect(secondOrg!.memberships[0]!.member.id).toEqual(userAccount.id);
+ expect(secondOrg!.memberships[0]!.role).toEqual("owner");
+
+ await deleteOrganizationFromDatabaseById(secondOrg!.id);
+ });
+
+ test("given: an organization name that would create a reserved slug, should: create organization with unique slug", async () => {
+ const { userAccount } = await setup();
+
+ const formData = toFormData({
+ intent,
+ name: "New", // This would create slug "new" which is reserved.
+ });
+
+ const response = (await sendAuthenticatedRequest({
+ formData,
+ userAccount,
+ })) as Response;
+
+ expect(response.status).toEqual(302);
+ const locationHeader = response.headers.get("Location");
+ expect(locationHeader).toMatch(/^\/organizations\/new-[\da-z]{8}$/);
+
+ // Extract slug from redirect URL and verify organization.
+ const slug = locationHeader!.split("/").pop()!;
+ const organization =
+ await retrieveOrganizationWithMembershipsFromDatabaseBySlug(slug);
+ expect(organization).toBeTruthy();
+ expect(organization!.name).toEqual("New");
+ expect(organization!.slug).not.toEqual("new");
+ expect(organization!.memberships).toHaveLength(1);
+ expect(organization!.memberships[0]!.member.id).toEqual(userAccount.id);
+ expect(organization!.memberships[0]!.role).toEqual("owner");
+
+ await deleteOrganizationFromDatabaseById(organization!.id);
+ });
+
+ test.each([
+ {
+ body: { intent } as const,
+ expected: {
+ data: {
+ result: {
+ error: {
+ fieldErrors: {
+ name: ["Invalid input: expected string, received undefined"],
+ },
+ },
+ },
+ },
+ init: { status: 400 },
+ },
+ given: "no name provided",
+ },
+ {
+ body: { intent, name: "ab" } as const,
+ expected: {
+ data: {
+ result: {
+ error: {
+ fieldErrors: {
+ name: ["onboarding:organization.errors.nameMin"],
+ },
+ },
+ },
+ },
+ init: { status: 400 },
+ },
+ given: "a name that is too short (2 characters)",
+ },
+ {
+ body: { intent, name: "a".repeat(73) } as const,
+ expected: {
+ data: {
+ result: {
+ error: {
+ fieldErrors: {
+ name: ["onboarding:organization.errors.nameMax"],
+ },
+ },
+ },
+ },
+ init: { status: 400 },
+ },
+ given: "a name that is too long (73 characters)",
+ },
+ {
+ body: { intent, name: " " },
+ expected: {
+ data: {
+ result: {
+ error: {
+ fieldErrors: {
+ name: ["onboarding:organization.errors.nameMin"],
+ },
+ },
+ },
+ },
+ init: { status: 400 },
+ },
+ given: "a name with only whitespace",
+ },
+ {
+ body: { intent, name: " a " },
+ expected: {
+ data: {
+ result: {
+ error: {
+ fieldErrors: {
+ name: ["onboarding:organization.errors.nameMin"],
+ },
+ },
+ },
+ },
+ init: { status: 400 },
+ },
+ given: "a too short name with whitespace",
+ },
+ ])("given: $given, should: return a 400 status code with an error message", async ({
+ body,
+ expected,
+ }) => {
+ const { userAccount } = await setup();
+
+ const formData = toFormData(body);
+ const response = await sendAuthenticatedRequest({
+ formData,
+ userAccount,
+ });
+
+ expect(response).toMatchObject(expected);
+ });
+
+ test("given: a valid name, should: create organization", async () => {
+ const { userAccount } = await setup();
+ const organization = createPopulatedOrganization();
+
+ const formData = toFormData({
+ intent,
+ name: organization.name,
+ });
+
+ const response = (await sendAuthenticatedRequest({
+ formData,
+ userAccount,
+ })) as Response;
+
+ // Assert redirect
+ expect(response.status).toEqual(302);
+ const slug = slugify(organization.name);
+ expect(response.headers.get("Location")).toEqual(
+ `/organizations/${slug}`,
+ );
+
+ // Verify organization was created
+ const createdOrganization =
+ await retrieveOrganizationWithMembershipsFromDatabaseBySlug(slug);
+
+ expect(createdOrganization).toBeTruthy();
+ expect(createdOrganization).toMatchObject({
+ imageUrl: "", // No logo uploaded
+ name: organization.name,
+ slug: slug,
+ });
+ expect(createdOrganization!.memberships).toHaveLength(1);
+ expect(createdOrganization!.memberships[0]!.member.id).toEqual(
+ userAccount.id,
+ );
+ expect(createdOrganization!.memberships[0]!.role).toEqual("owner");
+
+ // Cleanup
+ await deleteOrganizationFromDatabaseById(createdOrganization!.id);
+ });
+ });
+});
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/organization.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/organization.tsx
new file mode 100644
index 0000000..cc96629
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/organization.tsx
@@ -0,0 +1,412 @@
+import { useForm } from "@conform-to/react/future";
+import { coerceFormValue } from "@conform-to/zod/v4/future";
+import { IconBuilding } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { data, Form, useNavigation } from "react-router";
+
+import type { Route } from "./+types/organization";
+import {
+ AvatarUpload,
+ AvatarUploadDescription,
+ AvatarUploadInput,
+ AvatarUploadPreviewImage,
+} from "~/components/avatar-upload";
+import { GeneralErrorBoundary } from "~/components/general-error-boundary";
+import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
+import { Avatar, AvatarFallback } from "~/components/ui/avatar";
+import { Button } from "~/components/ui/button";
+import { Checkbox } from "~/components/ui/checkbox";
+import {
+ Field,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSet,
+ FieldTitle,
+} from "~/components/ui/field";
+import { Input } from "~/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import { Spinner } from "~/components/ui/spinner";
+import { Textarea } from "~/components/ui/textarea";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { requireUserNeedsOnboarding } from "~/features/onboarding/onboarding-helpers.server";
+import { onboardingOrganizationAction } from "~/features/onboarding/organization/onboarding-organization-action.server";
+import { ONBOARDING_ORGANIZATION_INTENT } from "~/features/onboarding/organization/onboarding-organization-consants";
+import {
+ COMPANY_SIZE_OPTIONS,
+ COMPANY_TYPE_OPTIONS,
+ onboardingOrganizationSchema,
+ REFERRAL_SOURCE_OPTIONS,
+} from "~/features/onboarding/organization/onboarding-organization-schemas";
+import { getPageTitle } from "~/utils/get-page-title.server";
+
+export async function loader({ request, context }: Route.LoaderArgs) {
+ const auth = await requireUserNeedsOnboarding({
+ context,
+ request,
+ });
+ const i18n = getInstance(context);
+
+ return data(
+ {
+ pageTitle: getPageTitle(
+ i18n.t.bind(i18n),
+ "onboarding:organization.title",
+ ),
+ },
+ { headers: auth.headers },
+ );
+}
+
+export const meta: Route.MetaFunction = ({ loaderData }) => [
+ { title: loaderData?.pageTitle },
+];
+
+export async function action(args: Route.ActionArgs) {
+ return await onboardingOrganizationAction(args);
+}
+
+const ONE_MB = 1_000_000;
+
+export default function OrganizationOnboardingRoute({
+ actionData,
+}: Route.ComponentProps) {
+ const { t } = useTranslation("onboarding", { keyPrefix: "organization" });
+ const { form, fields } = useForm(
+ coerceFormValue(onboardingOrganizationSchema),
+ {
+ lastResult: actionData?.result,
+ },
+ );
+ const navigation = useNavigation();
+ const isSubmitting = navigation.state === "submitting";
+
+ return (
+ 0
+ ? `${form.descriptionId} ${form.errorId}`
+ : form.descriptionId
+ }
+ aria-invalid={form.errors && form.errors.length > 0 ? true : undefined}
+ >
+
+
+
+
{t("heading")}
+
+ {t("subtitle")}
+
+
+
+ {form.errors && form.errors.length > 0 && (
+
+
+ {t("errors.createOrganizationFailedTitle")}
+
+
+ {t("errors.createOrganizationFailedDescription")}
+
+
+ )}
+
+ {/* Organization Name */}
+
+ {t("nameLabel")}
+
+ {t("nameDescription")}
+
+
+
+
+
+ {/* Logo Upload */}
+
+ {({ error }) => (
+ <>
+
+
+ {t("logoLabel")}
+
+
+ {t("logoDescription")}
+
+
+
+
+
+
+
+
+
+
+
+ {t("logoFormats")}
+
+
+
+
+
+ >
+ )}
+
+
+ {/* Company Website */}
+
+
+ {t("companyWebsiteLabel")}
+
+
+ {t("companyWebsiteDescription")}
+
+
+
+
+
+ {/* How did you hear about us */}
+
+
+
+ {t("referralSourcesLabel")}
+
+
+ {t("referralSourcesDescription")}
+
+
+ {REFERRAL_SOURCE_OPTIONS.map((option) => {
+ const labelId = `referral-source-${option}-label`;
+ return (
+
+
+
+
+ {t(`referralSource.${option}`)}
+
+
+
+ );
+ })}
+
+
+
+
+
+ {/* What type of company */}
+
+
+
+ {t("companyTypesLabel")}
+
+
+ {t("companyTypesDescription")}
+
+
+ {COMPANY_TYPE_OPTIONS.map((option) => {
+ const labelId = `company-type-label-${option}`;
+ return (
+
+
+
+ {t(`companyType.${option}`)}
+
+
+ );
+ })}
+
+
+
+
+
+ {/* How big is your team */}
+
+
+ {t("companySizeLabel")}
+
+
+ {t("companySizeDescription")}
+
+
+
+
+
+
+ {COMPANY_SIZE_OPTIONS.map((option) => (
+
+ {t(`companySize.${option}`)}
+
+ ))}
+
+
+
+
+
+ {/* Recruiting pain point */}
+
+
+ {t("recruitingPainPointLabel")}
+
+
+ {t("recruitingPainPointDescription")}
+
+
+
+
+
+ {/* Early access opt-in */}
+
+
+ {t("earlyAccessLabel")}
+
+
+
+
+ {t("earlyAccessTitle")}
+
+
+ {t("earlyAccessDescription")}
+
+
+
+
+
+
+
+
+
+ {isSubmitting ? (
+ <>
+ {t("saving")}
+ >
+ ) : (
+ t("save")
+ )}
+
+
+
+
+
+ );
+}
+
+export function ErrorBoundary() {
+ return ;
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/user-account.spec.ts b/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/user-account.spec.ts
new file mode 100644
index 0000000..60a11a0
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/user-account.spec.ts
@@ -0,0 +1,400 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: test code */
+import { describe, expect, onTestFinished, test } from "vitest";
+
+import { action } from "./user-account";
+import { ONBOARDING_USER_ACCOUNT_INTENT } from "~/features/onboarding/user-account/onboarding-user-account-constants";
+import { createEmailInviteInfoCookie } from "~/features/organizations/accept-email-invite/accept-email-invite-session.server";
+import { createInviteLinkInfoCookie } from "~/features/organizations/accept-invite-link/accept-invite-link-session.server";
+import { saveOrganizationEmailInviteLinkToDatabase } from "~/features/organizations/organizations-email-invite-link-model.server";
+import {
+ createPopulatedOrganization,
+ createPopulatedOrganizationEmailInviteLink,
+ createPopulatedOrganizationInviteLink,
+} from "~/features/organizations/organizations-factories.server";
+import { saveOrganizationInviteLinkToDatabase } from "~/features/organizations/organizations-invite-link-model.server";
+import {
+ addMembersToOrganizationInDatabaseById,
+ deleteOrganizationFromDatabaseById,
+ saveOrganizationToDatabase,
+ saveOrganizationWithOwnerToDatabase,
+} from "~/features/organizations/organizations-model.server";
+import { createPopulatedUserAccount } from "~/features/user-accounts/user-accounts-factories.server";
+import {
+ deleteUserAccountFromDatabaseById,
+ saveUserAccountToDatabase,
+} from "~/features/user-accounts/user-accounts-model.server";
+import { supabaseHandlers } from "~/test/mocks/handlers/supabase";
+import { setupMockServerLifecycle } from "~/test/msw-test-utils";
+import {
+ createAuthenticatedRequest,
+ createAuthTestContextProvider,
+} from "~/test/test-utils";
+import { toFormData } from "~/utils/to-form-data";
+import { getToast } from "~/utils/toast.server";
+
+const createUrl = () => `http://localhost:3000/onboarding/user-account`;
+
+const pattern = "/onboarding/user-account";
+
+async function sendAuthenticatedRequest({
+ userAccount,
+ formData,
+ headers,
+}: {
+ userAccount: ReturnType;
+ formData: FormData;
+ headers?: Headers;
+}) {
+ const request = await createAuthenticatedRequest({
+ formData,
+ headers,
+ method: "POST",
+ url: createUrl(),
+ user: userAccount,
+ });
+ const params = {};
+
+ return await action({
+ context: await createAuthTestContextProvider({ params, pattern, request }),
+ params,
+ request,
+ unstable_pattern: pattern,
+ });
+}
+
+async function setup(userAccount = createPopulatedUserAccount()) {
+ await saveUserAccountToDatabase(userAccount);
+ onTestFinished(async () => {
+ await deleteUserAccountFromDatabaseById(userAccount.id);
+ });
+
+ return { userAccount };
+}
+
+setupMockServerLifecycle(...supabaseHandlers);
+
+describe("/onboarding/user-account route action", () => {
+ test("given: an unauthenticated request, should: throw a redirect to the login page", async () => {
+ expect.assertions(2);
+
+ const request = new Request(createUrl(), {
+ body: toFormData({}),
+ method: "POST",
+ });
+ const params = {};
+
+ try {
+ await action({
+ context: await createAuthTestContextProvider({
+ params,
+ pattern,
+ request,
+ }),
+ params,
+ request,
+ unstable_pattern: pattern,
+ });
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual(
+ `/login?redirectTo=%2Fonboarding%2Fuser-account`,
+ );
+ }
+ }
+ });
+
+ test("given: a user who has completed onboarding, should: redirect to organizations page", async () => {
+ expect.assertions(2);
+
+ const { userAccount } = await setup();
+ const organization = await saveOrganizationWithOwnerToDatabase({
+ organization: createPopulatedOrganization(),
+ userId: userAccount.id,
+ });
+ onTestFinished(async () => {
+ await deleteOrganizationFromDatabaseById(organization.id);
+ });
+
+ try {
+ await sendAuthenticatedRequest({ formData: toFormData({}), userAccount });
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual(
+ `/organizations/${organization.slug}`,
+ );
+ }
+ }
+ });
+
+ describe(`${ONBOARDING_USER_ACCOUNT_INTENT} intent`, () => {
+ const intent = ONBOARDING_USER_ACCOUNT_INTENT;
+
+ test("given: a valid name for a user without organizations, should: update name and redirect to organization onboarding", async () => {
+ const userAccount = createPopulatedUserAccount({ name: "" });
+ await saveUserAccountToDatabase(userAccount);
+ onTestFinished(async () => {
+ await deleteUserAccountFromDatabaseById(userAccount.id);
+ });
+
+ const formData = toFormData({ intent, name: "Test User" });
+
+ const response = (await sendAuthenticatedRequest({
+ formData,
+ userAccount,
+ })) as Response;
+
+ expect(response.status).toEqual(302);
+ expect(response.headers.get("Location")).toEqual(
+ "/onboarding/organization",
+ );
+ });
+
+ test("given: a valid name for a user without organizations, should: update name and redirect to organization onboarding", async () => {
+ const userAccount = createPopulatedUserAccount({
+ imageUrl: "",
+ name: "",
+ });
+ await saveUserAccountToDatabase(userAccount);
+ onTestFinished(async () => {
+ await deleteUserAccountFromDatabaseById(userAccount.id);
+ });
+
+ const { name } = createPopulatedUserAccount();
+ const formData = toFormData({ intent, name });
+
+ const response = (await sendAuthenticatedRequest({
+ formData,
+ userAccount,
+ })) as Response;
+
+ expect(response.status).toEqual(302);
+ expect(response.headers.get("Location")).toEqual(
+ "/onboarding/organization",
+ );
+ });
+
+ test.each([
+ {
+ body: { intent },
+ expected: {
+ data: {
+ result: {
+ error: {
+ fieldErrors: {
+ name: ["Invalid input: expected string, received undefined"],
+ },
+ },
+ },
+ },
+ init: { status: 400 },
+ },
+ given: "no name provided",
+ },
+ {
+ body: { intent, name: "a" },
+ expected: {
+ data: {
+ result: {
+ error: {
+ fieldErrors: {
+ name: ["onboarding:userAccount.errors.nameMin"],
+ },
+ },
+ },
+ },
+ init: { status: 400 },
+ },
+ given: "a name that is too short (1 character)",
+ },
+ {
+ body: { intent, name: "a".repeat(129) },
+ expected: {
+ data: {
+ result: {
+ error: {
+ fieldErrors: {
+ name: ["onboarding:userAccount.errors.nameMax"],
+ },
+ },
+ },
+ },
+ init: { status: 400 },
+ },
+ given: "a name that is too long (129 characters)",
+ },
+ {
+ body: { intent, name: " " },
+ expected: {
+ data: {
+ result: {
+ error: {
+ fieldErrors: {
+ name: ["onboarding:userAccount.errors.nameMin"],
+ },
+ },
+ },
+ },
+ init: { status: 400 },
+ },
+ given: "a name with only whitespace",
+ },
+ {
+ body: { intent, name: " a " },
+ expected: {
+ data: {
+ result: {
+ error: {
+ fieldErrors: {
+ name: ["onboarding:userAccount.errors.nameMin"],
+ },
+ },
+ },
+ },
+ init: { status: 400 },
+ },
+ given: "a too short name with whitespace",
+ },
+ ])("given: $given, should: return a 400 status code with an error message", async ({
+ body,
+ expected,
+ }) => {
+ const userAccount = createPopulatedUserAccount({ name: "" });
+ await saveUserAccountToDatabase(userAccount);
+ onTestFinished(async () => {
+ await deleteUserAccountFromDatabaseById(userAccount.id);
+ });
+
+ const formData = toFormData(body);
+
+ const actual = await sendAuthenticatedRequest({
+ formData,
+ userAccount,
+ });
+
+ expect(actual).toMatchObject(expected);
+ });
+
+ test("given: a user who needs onboarding with a invite link session info in the request, should: redirect to the organizations dashboard page and show a toast", async () => {
+ // The user who was invited and just picked their name.
+ const { userAccount } = await setup(
+ createPopulatedUserAccount({ name: "" }),
+ );
+ // The user who created the invite link.
+ const { userAccount: invitingUser } = await setup();
+ // The organization that the user was invited to.
+ const organization = createPopulatedOrganization();
+ await saveOrganizationToDatabase(organization);
+ onTestFinished(async () => {
+ await deleteOrganizationFromDatabaseById(organization.id);
+ });
+ // Add the users as members of the organization.
+ await addMembersToOrganizationInDatabaseById({
+ id: organization.id,
+ members: [userAccount.id, invitingUser.id],
+ });
+ // The invite link that was used to invite the user.
+ const inviteLink = createPopulatedOrganizationInviteLink({
+ creatorId: invitingUser.id,
+ organizationId: organization.id,
+ });
+ await saveOrganizationInviteLinkToDatabase(inviteLink);
+ const cookie = await createInviteLinkInfoCookie({
+ expiresAt: inviteLink.expiresAt,
+ inviteLinkToken: inviteLink.token,
+ });
+ const headers = new Headers({ Cookie: cookie });
+
+ const formData = toFormData({ intent, name: "Test User" });
+
+ const response = (await sendAuthenticatedRequest({
+ formData,
+ headers,
+ userAccount,
+ })) as Response;
+
+ expect(response.status).toEqual(302);
+ expect(response.headers.get("Location")).toEqual(
+ `/organizations/${organization.slug}/dashboard`,
+ );
+
+ const setCookie = response.headers.get("Set-Cookie")!;
+ const toastMatch = /__toast=[^;]+/.exec(setCookie);
+ const maybeToast = toastMatch?.[0] ?? "";
+ const { toast } = await getToast(
+ new Request(createUrl(), {
+ headers: { cookie: maybeToast ?? "" },
+ }),
+ );
+ expect(toast).toMatchObject({
+ description: `You are now a member of ${organization.name}`,
+ id: expect.any(String) as string,
+ title: "Successfully joined organization",
+ type: "success",
+ });
+ });
+
+ test("given: a user who needs onboarding with an email invite session info in the request, should: redirect to the organizations dashboard page and show a toast", async () => {
+ // The invited user who just picked their name
+ const { userAccount } = await setup(
+ createPopulatedUserAccount({ name: "" }),
+ );
+ // The user who created the email invite
+ const { userAccount: invitingUser } = await setup();
+ // Create and save the organization
+ const organization = createPopulatedOrganization();
+ await saveOrganizationToDatabase(organization);
+ onTestFinished(async () => {
+ await deleteOrganizationFromDatabaseById(organization.id);
+ });
+ // Add both users as members (inviter is owner by default)
+ await addMembersToOrganizationInDatabaseById({
+ id: organization.id,
+ members: [invitingUser.id, userAccount.id],
+ });
+ // Create and save the email invite
+ const emailInvite = createPopulatedOrganizationEmailInviteLink({
+ invitedById: invitingUser.id,
+ organizationId: organization.id,
+ });
+ await saveOrganizationEmailInviteLinkToDatabase(emailInvite);
+ // Generate the Set-Cookie header for the email invite session
+ const cookie = await createEmailInviteInfoCookie({
+ emailInviteToken: emailInvite.token,
+ expiresAt: emailInvite.expiresAt,
+ });
+ const headers = new Headers({ Cookie: cookie });
+
+ // Form data with intent and name filled
+ const formData = toFormData({ intent, name: "Test User" });
+
+ const response = (await sendAuthenticatedRequest({
+ formData,
+ headers,
+ userAccount,
+ })) as Response;
+
+ expect(response.status).toEqual(302);
+ expect(response.headers.get("Location")).toEqual(
+ `/organizations/${organization.slug}/dashboard`,
+ );
+
+ // Extract the toast cookie
+ const setCookie = response.headers.get("Set-Cookie")!;
+ const toastMatch = /__toast=[^;]+/.exec(setCookie);
+ const maybeToast = toastMatch?.[0] ?? "";
+ const { toast } = await getToast(
+ new Request(createUrl(), {
+ headers: { cookie: maybeToast ?? "" },
+ }),
+ );
+ expect(toast).toMatchObject({
+ description: `You are now a member of ${organization.name}`,
+ id: expect.any(String) as string,
+ title: "Successfully joined organization",
+ type: "success",
+ });
+ });
+ });
+});
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/user-account.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/user-account.tsx
new file mode 100644
index 0000000..2de895e
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/onboarding+/user-account.tsx
@@ -0,0 +1,171 @@
+import { useForm } from "@conform-to/react/future";
+import { coerceFormValue } from "@conform-to/zod/v4/future";
+import { IconUser } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { data, Form, useNavigation } from "react-router";
+
+import type { Route } from "./+types/user-account";
+import {
+ AvatarUpload,
+ AvatarUploadDescription,
+ AvatarUploadInput,
+ AvatarUploadPreviewImage,
+} from "~/components/avatar-upload";
+import { GeneralErrorBoundary } from "~/components/general-error-boundary";
+import { Avatar, AvatarFallback } from "~/components/ui/avatar";
+import { Button } from "~/components/ui/button";
+import {
+ Field,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+ FieldSet,
+} from "~/components/ui/field";
+import { Input } from "~/components/ui/input";
+import { Spinner } from "~/components/ui/spinner";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { requireUserNeedsOnboarding } from "~/features/onboarding/onboarding-helpers.server";
+import { onboardingUserAccountAction } from "~/features/onboarding/user-account/onboarding-user-account-action.server";
+import { ONBOARDING_USER_ACCOUNT_INTENT } from "~/features/onboarding/user-account/onboarding-user-account-constants";
+import { onboardingUserAccountSchema } from "~/features/onboarding/user-account/onboarding-user-account-schemas";
+import { getPageTitle } from "~/utils/get-page-title.server";
+
+export async function loader({ request, context }: Route.LoaderArgs) {
+ const auth = await requireUserNeedsOnboarding({
+ context,
+ request,
+ });
+ const i18n = getInstance(context);
+
+ return data(
+ {
+ pageTitle: getPageTitle(
+ i18n.t.bind(i18n),
+ "onboarding:userAccount.title",
+ ),
+ user: auth.user,
+ },
+ { headers: auth.headers },
+ );
+}
+
+export const meta: Route.MetaFunction = ({ loaderData }) => [
+ { title: loaderData?.pageTitle },
+];
+
+export async function action(args: Route.ActionArgs) {
+ return await onboardingUserAccountAction(args);
+}
+
+const ONE_MB = 1_000_000;
+
+export default function UserAccountOnboardingRoute({
+ actionData,
+ loaderData,
+}: Route.ComponentProps) {
+ const { t } = useTranslation("onboarding", { keyPrefix: "userAccount" });
+ const { form, fields } = useForm(
+ coerceFormValue(onboardingUserAccountSchema),
+ {
+ lastResult: actionData?.result,
+ },
+ );
+ const navigation = useNavigation();
+ const isSubmitting = navigation.state === "submitting";
+
+ return (
+
+
+
+
+
{t("heading")}
+
+ {t("subtitle")}
+
+
+
+
+ {t("nameLabel")}
+
+ {t("nameDescription")}
+
+
+
+
+
+
+ {({ error }) => (
+ <>
+
+
+ {t("profilePhotoLabel")}
+
+
+ {t("profilePhotoDescription")}
+
+
+
+
+
+
+
+
+
+
+
+ {t("profilePhotoFormats")}
+
+
+
+
+
+ >
+ )}
+
+
+
+
+ {isSubmitting ? (
+ <>
+ {t("saving")}
+ >
+ ) : (
+ t("save")
+ )}
+
+
+
+
+
+ );
+}
+
+export function ErrorBoundary() {
+ return ;
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/_sidebar-layout.spec.ts b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/_sidebar-layout.spec.ts
new file mode 100644
index 0000000..96b105b
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/_sidebar-layout.spec.ts
@@ -0,0 +1,647 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: test code */
+
+import { data, href } from "react-router";
+import { describe, expect, onTestFinished, test } from "vitest";
+
+import { action } from "./_sidebar-layout";
+import {
+ OPEN_CHECKOUT_SESSION_INTENT,
+ priceLookupKeysByTierAndInterval,
+} from "~/features/billing/billing-constants";
+import { getRandomLookupKey } from "~/features/billing/billing-factories.server";
+import {
+ MARK_ALL_NOTIFICATIONS_AS_READ_INTENT,
+ MARK_ONE_NOTIFICATION_AS_READ_INTENT,
+ NOTIFICATION_PANEL_OPENED_INTENT,
+} from "~/features/notifications/notification-constants";
+import {
+ createPopulatedNotification,
+ createPopulatedNotificationRecipient,
+} from "~/features/notifications/notifications-factories.server";
+import {
+ retrieveNotificationPanelForUserAndOrganizationFromDatabaseById,
+ retrieveNotificationRecipientsForUserAndOrganizationFromDatabase,
+ saveNotificationWithRecipientForUserAndOrganizationInDatabaseById,
+} from "~/features/notifications/notifications-model.server";
+import { SWITCH_ORGANIZATION_INTENT } from "~/features/organizations/layout/sidebar-layout-constants";
+import { createPopulatedOrganization } from "~/features/organizations/organizations-factories.server";
+import {
+ addMembersToOrganizationInDatabaseById,
+ saveOrganizationToDatabase,
+} from "~/features/organizations/organizations-model.server";
+import { createPopulatedUserAccount } from "~/features/user-accounts/user-accounts-factories.server";
+import {
+ deleteUserAccountFromDatabaseById,
+ saveUserAccountToDatabase,
+} from "~/features/user-accounts/user-accounts-model.server";
+import type { Organization, UserAccount } from "~/generated/client";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { stripeHandlers } from "~/test/mocks/handlers/stripe";
+import { supabaseHandlers } from "~/test/mocks/handlers/supabase";
+import { setupMockServerLifecycle } from "~/test/msw-test-utils";
+import {
+ setupUserWithOrgAndAddAsMember,
+ setupUserWithTrialOrgAndAddAsMember,
+} from "~/test/server-test-utils";
+import {
+ createAuthenticatedRequest,
+ createOrganizationMembershipTestContextProvider,
+} from "~/test/test-utils";
+import type { DataWithResponseInit } from "~/utils/http-responses.server";
+import {
+ badRequest,
+ conflict,
+ forbidden,
+ notFound,
+} from "~/utils/http-responses.server";
+import { toFormData } from "~/utils/to-form-data";
+
+const createUrl = (organizationSlug: string) =>
+ `http://localhost:3000/organizations/${organizationSlug}`;
+
+const pattern = "/organizations/:organizationSlug";
+
+async function sendAuthenticatedRequest({
+ formData,
+ organizationSlug,
+ user,
+}: {
+ formData: FormData;
+ organizationSlug: Organization["slug"];
+ user: UserAccount;
+}) {
+ const request = await createAuthenticatedRequest({
+ formData,
+ method: "POST",
+ url: createUrl(organizationSlug),
+ user,
+ });
+ const params = { organizationSlug };
+
+ return await action({
+ context: await createOrganizationMembershipTestContextProvider({
+ params,
+ pattern,
+ request,
+ }),
+ params,
+ request,
+ unstable_pattern: pattern,
+ });
+}
+
+const server = setupMockServerLifecycle(...supabaseHandlers, ...stripeHandlers);
+
+/**
+ * Seed `count` notifications (each with one recipient) into the test database
+ * for the given user and organization.
+ */
+async function setupNotificationsForUserAndOrganization({
+ user,
+ organization,
+ count = 1,
+}: {
+ user: UserAccount;
+ organization: Organization;
+ count?: number;
+}) {
+ const notifications = Array.from({ length: count }).map(() =>
+ createPopulatedNotification({ organizationId: organization.id }),
+ );
+ const notificationsWithRecipients = await Promise.all(
+ notifications.map((notification) => {
+ const { notificationId: _, ...recipient } =
+ createPopulatedNotificationRecipient({
+ notificationId: notification.id,
+ readAt: null,
+ userId: user.id,
+ });
+
+ return saveNotificationWithRecipientForUserAndOrganizationInDatabaseById({
+ notification,
+ recipient,
+ });
+ }),
+ );
+
+ return {
+ notifications,
+ recipients: notificationsWithRecipients.map(
+ ({ recipients }) => recipients[0],
+ ),
+ };
+}
+
+describe("/organizations/:organizationSlug route action", () => {
+ test("given: an unauthenticated request, should: throw a redirect to the login page", async () => {
+ expect.assertions(2);
+
+ const organization = createPopulatedOrganization();
+ const request = new Request(createUrl(organization.slug), {
+ body: toFormData({}),
+ method: "POST",
+ });
+ const params = { organizationSlug: organization.slug };
+
+ try {
+ await action({
+ context: await createOrganizationMembershipTestContextProvider({
+ params,
+ pattern,
+ request,
+ }),
+ params,
+ request,
+ unstable_pattern: pattern,
+ });
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual(
+ `/login?redirectTo=%2Forganizations%2F${organization.slug}`,
+ );
+ }
+ }
+ });
+
+ test("given: a user who is not a member of the organization, should: throw a 404", async () => {
+ expect.assertions(1);
+ // Create a user with an organization.
+ const { user } = await setupUserWithOrgAndAddAsMember();
+ // Creates a user and another organization.
+ const { organization } = await setupUserWithOrgAndAddAsMember();
+
+ try {
+ await sendAuthenticatedRequest({
+ formData: toFormData({}),
+ organizationSlug: organization.slug,
+ user,
+ });
+ } catch (error) {
+ const expected = notFound();
+
+ expect(error).toEqual(expected);
+ }
+ });
+
+ describe(`${SWITCH_ORGANIZATION_INTENT} intent`, () => {
+ const intent = SWITCH_ORGANIZATION_INTENT;
+
+ const createBody = ({
+ currentPath = href("/organizations/:organizationSlug/settings/general", {
+ organizationSlug: createPopulatedOrganization().slug,
+ }),
+ organizationId = createPopulatedOrganization().id,
+ }: Partial<{
+ currentPath: string;
+ organizationId: string;
+ }>) => toFormData({ currentPath, intent, organizationId });
+
+ test("given: a valid organization switch request, should: redirect to the new organization's same route with updated cookie", async () => {
+ const { user, organization: currentOrg } =
+ await setupUserWithOrgAndAddAsMember();
+ const targetOrg = createPopulatedOrganization();
+ await saveOrganizationToDatabase(targetOrg);
+ await addMembersToOrganizationInDatabaseById({
+ id: targetOrg.id,
+ members: [user.id],
+ role: "member",
+ });
+
+ const formData = createBody({
+ currentPath: href("/organizations/:organizationSlug/settings/general", {
+ organizationSlug: currentOrg.slug,
+ }),
+ organizationId: targetOrg.id,
+ });
+
+ const response = (await sendAuthenticatedRequest({
+ formData,
+ organizationSlug: currentOrg.slug,
+ user,
+ })) as Response;
+
+ expect(response.status).toEqual(302);
+ expect(response.headers.get("Location")).toEqual(
+ `/organizations/${targetOrg.slug}/settings/general`,
+ );
+
+ // Verify cookie is set correctly
+ const cookie = response.headers.get("Set-Cookie");
+ expect(cookie).toContain(`__organization_switcher=ey`);
+ });
+
+ test("given: an invalid organization ID of a non-existent organization, should: return a 404 with validation errors", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember();
+
+ const formData = createBody({
+ currentPath: href("/organizations/:organizationSlug/settings/general", {
+ organizationSlug: organization.slug,
+ }),
+ organizationId: "invalid-id",
+ });
+
+ const actual = await sendAuthenticatedRequest({
+ formData,
+ organizationSlug: organization.slug,
+ user,
+ });
+ const expected = notFound();
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a request to switch to an organization the user is not a member of, should: return a 404", async () => {
+ const { user, organization: currentOrg } =
+ await setupUserWithOrgAndAddAsMember();
+ const { organization: targetOrg } =
+ await setupUserWithOrgAndAddAsMember();
+
+ const formData = createBody({
+ currentPath: href("/organizations/:organizationSlug/settings/general", {
+ organizationSlug: currentOrg.slug,
+ }),
+ organizationId: targetOrg.id,
+ });
+
+ const actual = await sendAuthenticatedRequest({
+ formData,
+ organizationSlug: currentOrg.slug,
+ user,
+ });
+ const expected = notFound();
+
+ expect(actual).toEqual(expected);
+ });
+
+ test("given: a request without an intent, should: return a 400 with validation errors", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember();
+
+ const formData = createBody({
+ currentPath: href("/organizations/:organizationSlug/settings/general", {
+ organizationSlug: organization.slug,
+ }),
+ organizationId: organization.id,
+ });
+ formData.delete("intent");
+
+ const actual = await sendAuthenticatedRequest({
+ formData,
+ organizationSlug: organization.slug,
+ user,
+ });
+ const expected = badRequest({
+ result: {
+ error: {
+ fieldErrors: {
+ intent: expect.arrayContaining(["Invalid input"]),
+ },
+ },
+ },
+ });
+
+ expect(actual).toMatchObject(expected);
+ });
+
+ test("given: a request with an invalid intent, should: return a 400 with validation errors", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember();
+
+ const formData = createBody({
+ currentPath: href("/organizations/:organizationSlug/settings/general", {
+ organizationSlug: organization.slug,
+ }),
+ organizationId: organization.id,
+ });
+ formData.delete("intent");
+ formData.append("intent", "invalidIntent");
+
+ const actual = await sendAuthenticatedRequest({
+ formData,
+ organizationSlug: organization.slug,
+ user,
+ });
+ const expected = badRequest({
+ result: {
+ error: {
+ fieldErrors: {
+ intent: expect.arrayContaining(["Invalid input"]),
+ },
+ },
+ },
+ });
+
+ expect(actual).toMatchObject(expected);
+ });
+
+ test("given: no organization ID, should: return a 400 with validation errors", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember();
+
+ const formData = createBody({});
+ formData.delete("organizationId");
+
+ const actual = await sendAuthenticatedRequest({
+ formData,
+ organizationSlug: organization.slug,
+ user,
+ });
+ const expected = badRequest({
+ result: {
+ error: {
+ fieldErrors: {
+ organizationId: expect.arrayContaining([
+ expect.stringContaining("expected string, received undefined"),
+ ]),
+ },
+ },
+ },
+ });
+
+ expect(actual).toMatchObject(expected);
+ });
+
+ test("given: no current path, should: return a 400 with validation errors", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember();
+
+ const formData = createBody({ organizationId: organization.id });
+ formData.delete("currentPath");
+
+ const actual = await sendAuthenticatedRequest({
+ formData,
+ organizationSlug: organization.slug,
+ user,
+ });
+ const expected = badRequest({
+ result: {
+ error: {
+ fieldErrors: {
+ currentPath: expect.arrayContaining([
+ expect.stringContaining("expected string, received undefined"),
+ ]),
+ },
+ },
+ },
+ });
+
+ expect(actual).toMatchObject(expected);
+ });
+ });
+
+ describe(`${MARK_ALL_NOTIFICATIONS_AS_READ_INTENT} intent`, () => {
+ const intent = MARK_ALL_NOTIFICATIONS_AS_READ_INTENT;
+
+ test("given: a valid request, should: mark all notifications as read", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember();
+ await setupNotificationsForUserAndOrganization({
+ count: 3,
+ organization,
+ user,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+ const expected = data({});
+
+ expect(actual.init?.status).toEqual(expected.init?.status);
+
+ const updatedRecipients =
+ await retrieveNotificationRecipientsForUserAndOrganizationFromDatabase({
+ organizationId: organization.id,
+ userId: user.id,
+ });
+
+ expect(updatedRecipients.length).toEqual(3);
+ expect(
+ updatedRecipients.every((recipient) => recipient.readAt !== null),
+ ).toEqual(true);
+ });
+ });
+
+ describe(`${MARK_ONE_NOTIFICATION_AS_READ_INTENT} intent`, () => {
+ const intent = MARK_ONE_NOTIFICATION_AS_READ_INTENT;
+
+ test("given: a valid request, should: mark the specified notification as read", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember();
+ const { recipients } = await setupNotificationsForUserAndOrganization({
+ count: 2,
+ organization,
+ user,
+ });
+ const [recipient] = recipients;
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent, recipientId: recipient!.id }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+ const expected = data({});
+
+ expect(actual.init?.status).toEqual(expected.init?.status);
+
+ const updatedRecipients =
+ await retrieveNotificationRecipientsForUserAndOrganizationFromDatabase({
+ organizationId: organization.id,
+ userId: user.id,
+ });
+
+ expect(updatedRecipients.length).toEqual(2);
+ expect(
+ updatedRecipients.find((r) => r.id === recipient!.id)?.readAt,
+ ).not.toBeNull();
+ });
+
+ test("given: no recipientId, should: return a 400 with validation errors", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember();
+ await setupNotificationsForUserAndOrganization({
+ count: 1,
+ organization,
+ user,
+ });
+
+ const actual = await sendAuthenticatedRequest({
+ formData: toFormData({ intent }),
+ organizationSlug: organization.slug,
+ user,
+ });
+ const expected = badRequest({
+ result: {
+ error: {
+ fieldErrors: {
+ recipientId: expect.arrayContaining([
+ expect.stringContaining("expected string, received undefined"),
+ ]),
+ },
+ },
+ },
+ });
+
+ expect(actual).toMatchObject(expected);
+ });
+
+ test("given: a recipient belonging to another user, should: return a 404", async () => {
+ const { user: userA, organization } =
+ await setupUserWithOrgAndAddAsMember();
+ // seed one for A
+ await setupNotificationsForUserAndOrganization({
+ count: 1,
+ organization,
+ user: userA,
+ });
+
+ // create B in same org
+ const { user: userB } = await setupUserWithOrgAndAddAsMember();
+ await addMembersToOrganizationInDatabaseById({
+ id: organization.id,
+ members: [userB.id],
+ });
+ // seed one for B
+ const { recipients: recipientsB } =
+ await setupNotificationsForUserAndOrganization({
+ count: 1,
+ organization,
+ user: userB,
+ });
+ const [recipientB] = recipientsB;
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent, recipientId: recipientB!.id }),
+ organizationSlug: organization.slug,
+ user: userA,
+ })) as DataWithResponseInit<{ message: string }>;
+ const expected = notFound();
+
+ expect(actual.init?.status).toEqual(expected.init?.status);
+ });
+ });
+
+ describe(`${NOTIFICATION_PANEL_OPENED_INTENT} intent`, () => {
+ const intent = NOTIFICATION_PANEL_OPENED_INTENT;
+
+ test("given: a valid request, should: return a 200 and mark the notification panel as opened", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember();
+
+ const panelBefore =
+ await retrieveNotificationPanelForUserAndOrganizationFromDatabaseById({
+ organizationId: organization.id,
+ userId: user.id,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+ const expected = data({});
+
+ expect(actual.init?.status).toEqual(expected.init?.status);
+
+ const panelAfter =
+ await retrieveNotificationPanelForUserAndOrganizationFromDatabaseById({
+ organizationId: organization.id,
+ userId: user.id,
+ });
+ expect(panelAfter?.lastOpenedAt).not.toEqual(panelBefore?.lastOpenedAt);
+ });
+ });
+
+ describe(`${OPEN_CHECKOUT_SESSION_INTENT} intent`, () => {
+ const intent = OPEN_CHECKOUT_SESSION_INTENT;
+
+ test("given: a valid request from a member, should: return a 403", async () => {
+ const { user, organization } = await setupUserWithTrialOrgAndAddAsMember({
+ role: OrganizationMembershipRole.member,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent, lookupKey: getRandomLookupKey() }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+ const expected = forbidden();
+
+ expect(actual.init?.status).toEqual(expected.init?.status);
+ });
+
+ test.each([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: a valid request from a %s, but their organization already has a subscription, should: return a 409", async (role) => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ role,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent, lookupKey: getRandomLookupKey() }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+ const expected = conflict();
+
+ expect(actual).toEqual(expected);
+ });
+
+ test.each([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: a valid request from a %s, but their organization has too many members for the chosen plan, should: return a 409", async (role) => {
+ const { user, organization } = await setupUserWithTrialOrgAndAddAsMember({
+ role,
+ });
+ const otherUser = createPopulatedUserAccount();
+ await saveUserAccountToDatabase(otherUser);
+ await addMembersToOrganizationInDatabaseById({
+ id: organization.id,
+ members: [otherUser.id],
+ });
+ onTestFinished(async () => {
+ await deleteUserAccountFromDatabaseById(otherUser.id);
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({
+ intent,
+ lookupKey: priceLookupKeysByTierAndInterval.low.monthly,
+ }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+ const expected = conflict();
+
+ expect(actual).toEqual(expected);
+ });
+
+ test.each([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: a valid request from a %s, should: return a 302 and redirect to the customer portal", async (role) => {
+ let checkoutSessionCalled = false;
+ const checkoutListener = ({ request }: { request: Request }) => {
+ if (new URL(request.url).pathname === "/v1/checkout/sessions") {
+ checkoutSessionCalled = true;
+ }
+ };
+ server.events.on("response:mocked", checkoutListener);
+ onTestFinished(() => {
+ server.events.removeListener("response:mocked", checkoutListener);
+ });
+
+ const { user, organization } = await setupUserWithTrialOrgAndAddAsMember({
+ role,
+ });
+
+ const response = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent, lookupKey: getRandomLookupKey() }),
+ organizationSlug: organization.slug,
+ user,
+ })) as Response;
+
+ expect(response.status).toEqual(302);
+ expect(response.headers.get("Location")).toMatch(
+ /^https:\/\/checkout\.stripe\.com\/pay\/cs_[\dA-Za-z]+(?:\?.*)?$/,
+ );
+ expect(checkoutSessionCalled).toEqual(true);
+ });
+ });
+});
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/_sidebar-layout.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/_sidebar-layout.tsx
new file mode 100644
index 0000000..ed6a3f2
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/_sidebar-layout.tsx
@@ -0,0 +1,145 @@
+import type { ShouldRevalidateFunctionArgs, UIMatch } from "react-router";
+import { data, href, Outlet, redirect, useMatches } from "react-router";
+import { promiseHash } from "remix-utils/promise";
+
+import type { Route } from "./+types/_sidebar-layout";
+import { SidebarInset, SidebarProvider } from "~/components/ui/sidebar";
+import { allLookupKeys } from "~/features/billing/billing-constants";
+import { getCreateSubscriptionModalProps } from "~/features/billing/billing-helpers.server";
+import { retrieveProductsFromDatabaseByPriceLookupKeys } from "~/features/billing/stripe-product-model.server";
+import { mapInitialNotificationsDataToNotificationButtonProps } from "~/features/notifications/notifications-helpers.server";
+import { retrieveInitialNotificationsDataForUserAndOrganizationFromDatabaseById } from "~/features/notifications/notifications-model.server";
+import { AppHeader } from "~/features/organizations/layout/app-header";
+import { AppSidebar } from "~/features/organizations/layout/app-sidebar";
+import { findBreadcrumbs } from "~/features/organizations/layout/layout-helpers";
+import {
+ getSidebarState,
+ mapOnboardingUserToBillingSidebarCardProps,
+ mapOnboardingUserToOrganizationLayoutProps,
+} from "~/features/organizations/layout/layout-helpers.server";
+import { sidebarLayoutAction } from "~/features/organizations/layout/sidebar-layout-action.server";
+import {
+ organizationMembershipContext,
+ organizationMembershipMiddleware,
+} from "~/features/organizations/organizations-middleware.server";
+
+/**
+ * @see https://reactrouter.com/start/framework/route-module#shouldrevalidate
+ */
+export const shouldRevalidate = ({
+ currentParams,
+ nextParams,
+ defaultShouldRevalidate,
+}: ShouldRevalidateFunctionArgs) => {
+ if (currentParams.organizationSlug !== nextParams.organizationSlug) {
+ return true;
+ }
+ return defaultShouldRevalidate;
+};
+
+export const middleware = [organizationMembershipMiddleware];
+
+export async function loader({ request, params, context }: Route.LoaderArgs) {
+ if (
+ params.organizationSlug &&
+ request.url.endsWith(`/organizations/${params.organizationSlug}`)
+ ) {
+ return redirect(
+ href("/organizations/:organizationSlug/dashboard", {
+ organizationSlug: params.organizationSlug,
+ }),
+ );
+ }
+
+ const { user, organization, headers } = context.get(
+ organizationMembershipContext,
+ );
+
+ const { notificationData, products } = await promiseHash({
+ notificationData:
+ retrieveInitialNotificationsDataForUserAndOrganizationFromDatabaseById({
+ organizationId: organization.id,
+ userId: user.id,
+ }),
+ products: retrieveProductsFromDatabaseByPriceLookupKeys(
+ allLookupKeys as unknown as string[],
+ ),
+ });
+ const defaultSidebarOpen = getSidebarState(request);
+
+ return data(
+ {
+ defaultSidebarOpen,
+ ...mapOnboardingUserToOrganizationLayoutProps({
+ organizationSlug: params.organizationSlug,
+ user,
+ }),
+ ...mapInitialNotificationsDataToNotificationButtonProps(notificationData),
+ ...mapOnboardingUserToBillingSidebarCardProps({
+ now: new Date(),
+ organizationSlug: params.organizationSlug,
+ user,
+ }),
+ ...getCreateSubscriptionModalProps(organization, products),
+ },
+ { headers },
+ );
+}
+
+export async function action(args: Route.ActionArgs) {
+ return sidebarLayoutAction(args);
+}
+
+export default function OrganizationLayoutRoute({
+ loaderData,
+ params,
+ matches,
+}: Route.ComponentProps) {
+ const {
+ billingSidebarCardProps,
+ createSubscriptionModalProps,
+ defaultSidebarOpen,
+ navUserProps,
+ notificationButtonProps,
+ organizationSwitcherProps,
+ } = loaderData;
+ const breadcrumbs = findBreadcrumbs(
+ matches as UIMatch<{ breadcrumb?: { title: string; to: string } }>[],
+ );
+
+ // Check if we're on a paste detail page
+ const isPasteDetailPage = matches.some(
+ (match) => match.id?.includes("pastes.$pasteId"),
+ );
+
+ // If it's a paste detail page, render without sidebar
+ if (isPasteDetailPage) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/analytics.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/analytics.tsx
new file mode 100644
index 0000000..15cee7e
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/analytics.tsx
@@ -0,0 +1,38 @@
+import { href } from "react-router";
+
+import type { Route } from "./+types/analytics";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { getPageTitle } from "~/utils/get-page-title.server";
+
+export function loader({ params, context }: Route.LoaderArgs) {
+ const i18n = getInstance(context);
+ const t = i18n.t.bind(i18n);
+
+ return {
+ breadcrumb: {
+ title: t("organizations:analytics.breadcrumb"),
+ to: href("/organizations/:organizationSlug/analytics", {
+ organizationSlug: params.organizationSlug,
+ }),
+ },
+ pageTitle: getPageTitle(t, "organizations:analytics.pageTitle"),
+ };
+}
+
+export const meta: Route.MetaFunction = ({ loaderData }) => [
+ { title: loaderData?.pageTitle },
+];
+
+export default function AnalyticsRoute() {
+ return (
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/dashboard.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/dashboard.tsx
new file mode 100644
index 0000000..92e0330
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/dashboard.tsx
@@ -0,0 +1,104 @@
+import { data, href, Link } from "react-router";
+
+import type { Route } from "./+types/dashboard";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { getPageTitle } from "~/utils/get-page-title.server";
+import { organizationMembershipContext } from "~/features/organizations/organizations-middleware.server";
+import { prisma } from "~/utils/database.server";
+import { canCreatePaste } from "~/features/pastebin/paste-helpers.server";
+
+export async function loader({ params, context }: Route.LoaderArgs) {
+ const i18n = getInstance(context);
+ const t = i18n.t.bind(i18n);
+ const { organization, headers } = context.get(organizationMembershipContext);
+
+ const pasteCount = await prisma.paste.count({
+ where: { organizationId: organization.id },
+ });
+
+ const pasteLimits = await canCreatePaste(organization.id);
+
+ return data(
+ {
+ pasteCount,
+ pasteLimits,
+ breadcrumb: {
+ title: t("organizations:dashboard.breadcrumb"),
+ to: href("/organizations/:organizationSlug/dashboard", {
+ organizationSlug: params.organizationSlug,
+ }),
+ },
+ pageTitle: getPageTitle(t, "organizations:dashboard.pageTitle"),
+ },
+ { headers },
+ );
+}
+
+export const meta: Route.MetaFunction = ({ loaderData }) => [
+ { title: loaderData?.pageTitle },
+];
+
+export default function OrganizationDashboardRoute({ loaderData, params }: Route.ComponentProps) {
+ const { pasteCount, pasteLimits } = loaderData;
+
+ return (
+
+
+
+
+
+
Total Pastes
+
{pasteCount}
+
+
📋
+
+
+
+
+
+
Paste Limit
+
+ {pasteLimits.limit === Infinity ? "∞" : pasteLimits.limit}
+
+
+
🚀
+
+
+
+
+
+
Quick Action
+
Create New Paste
+
+
✨
+
+
+
+
+
+
Welcome to Your Pastebin SaaS! 🎉
+
+ You've created {pasteCount} pastes so far.
+ {pasteLimits.canCreate ? (
+ <> You can create {pasteLimits.limit === Infinity ? "unlimited" : `${pasteLimits.limit - pasteCount} more`} pastes with your current plan.>
+ ) : (
+ <> You've reached your limit! Upgrade your plan to create more.>
+ )}
+
+
+ Manage Pastes →
+
+
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/get-help.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/get-help.tsx
new file mode 100644
index 0000000..b6c6044
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/get-help.tsx
@@ -0,0 +1,38 @@
+import { href } from "react-router";
+
+import type { Route } from "./+types/get-help";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { getPageTitle } from "~/utils/get-page-title.server";
+
+export function loader({ params, context }: Route.LoaderArgs) {
+ const i18n = getInstance(context);
+ const t = i18n.t.bind(i18n);
+
+ return {
+ breadcrumb: {
+ title: t("organizations:getHelp.breadcrumb"),
+ to: href("/organizations/:organizationSlug/get-help", {
+ organizationSlug: params.organizationSlug,
+ }),
+ },
+ pageTitle: getPageTitle(t, "organizations:getHelp.pageTitle"),
+ };
+}
+
+export const meta: Route.MetaFunction = ({ loaderData }) => [
+ { title: loaderData?.pageTitle },
+];
+
+export default function GetHelpRoute() {
+ return (
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/pastes.$pasteId.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/pastes.$pasteId.tsx
new file mode 100644
index 0000000..de1d6c3
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/pastes.$pasteId.tsx
@@ -0,0 +1,71 @@
+import { data, href, redirect } from "react-router";
+
+import type { Route } from "./+types/pastes.$pasteId";
+import { organizationMembershipContext } from "~/features/organizations/organizations-middleware.server";
+import { prisma } from "~/utils/database.server";
+
+export async function loader({ params, context }: Route.LoaderArgs) {
+ const { organizationSlug, pasteId } = params;
+
+ if (!pasteId) {
+ throw new Response("Paste ID required", { status: 400 });
+ }
+
+ const { organization, headers } = context.get(organizationMembershipContext);
+
+ if (organization.slug !== organizationSlug) {
+ throw redirect(
+ href("/organizations/:organizationSlug/pastes/:pasteId", {
+ organizationSlug: organization.slug,
+ pasteId,
+ }),
+ );
+ }
+
+ const paste = await prisma.paste.findFirst({
+ where: {
+ id: pasteId,
+ organizationId: organization.id,
+ },
+ });
+
+ if (!paste) {
+ throw new Response("Paste not found", { status: 404 });
+ }
+
+ await prisma.paste.update({
+ where: { id: pasteId },
+ data: { viewCount: { increment: 1 } },
+ });
+
+ return data(
+ {
+ paste: {
+ ...paste,
+ viewCount: paste.viewCount + 1,
+ },
+ pageTitle: paste.title,
+ },
+ { headers },
+ );
+}
+
+export const meta: Route.MetaFunction = ({ loaderData }) => [
+ { title: loaderData?.pageTitle || "View Paste" },
+];
+
+export default function ViewPasteRoute({ loaderData }: Route.ComponentProps) {
+ const { paste } = loaderData;
+
+ return (
+
+
+
{paste.title}
+
+ {paste.content}
+
+
+
+ );
+}
+
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/pastes.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/pastes.tsx
new file mode 100644
index 0000000..4d91963
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/pastes.tsx
@@ -0,0 +1,317 @@
+import { data, Form, href, Link, redirect } from "react-router";
+import { z } from "zod";
+
+import type { Route } from "./+types/pastes";
+import { canCreatePaste } from "~/features/pastebin/paste-helpers.server";
+import { organizationMembershipContext } from "~/features/organizations/organizations-middleware.server";
+import { prisma } from "~/utils/database.server";
+import { validateFormData } from "~/utils/validate-form-data.server";
+
+const createPasteSchema = z.object({
+ intent: z.literal("create"),
+ title: z.string().min(1).max(200),
+ content: z.string().min(1).max(100000), // 100KB max
+ language: z.string().optional(),
+ isPublic: z.string().optional().transform((val) => val === "on"),
+});
+
+const deletePasteSchema = z.object({
+ intent: z.literal("delete"),
+ pasteId: z.string().min(1),
+});
+
+const pasteActionSchema = z.discriminatedUnion("intent", [
+ createPasteSchema,
+ deletePasteSchema,
+]);
+
+export async function loader({ params, request, context }: Route.LoaderArgs) {
+ const { organizationSlug } = params;
+
+ // If there's a pasteId in the URL, this route shouldn't handle it
+ const url = new URL(request.url);
+ const pathParts = url.pathname.split('/');
+ const pasteIndex = pathParts.indexOf('pastes');
+ if (pasteIndex !== -1 && pathParts[pasteIndex + 1] && pathParts[pasteIndex + 1] !== '') {
+ // There's a pasteId, let the detail route handle it
+ throw new Response("", { status: 404 });
+ }
+ const { user, organization, headers } = context.get(organizationMembershipContext);
+
+ if (organization.slug !== organizationSlug) {
+ throw redirect(href("/organizations/:organizationSlug/pastes", { organizationSlug: organization.slug }));
+ }
+
+ const pastes = await prisma.paste.findMany({
+ where: {
+ organizationId: organization.id,
+ },
+ include: {
+ createdBy: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ imageUrl: true,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ take: 100, // Limit to 100 most recent
+ });
+
+ const pasteLimits = await canCreatePaste(organization.id);
+
+ // Format dates consistently on the server to avoid hydration mismatches
+ const pastesWithFormattedDates = pastes.map((paste) => ({
+ ...paste,
+ formattedCreatedAt: new Intl.DateTimeFormat("en-US", {
+ month: "numeric",
+ day: "numeric",
+ year: "numeric",
+ }).format(new Date(paste.createdAt)),
+ }));
+
+ return data(
+ {
+ pastes: pastesWithFormattedDates,
+ pasteLimits,
+ breadcrumb: {
+ title: "Pastes",
+ to: href("/organizations/:organizationSlug/pastes", {
+ organizationSlug: organization.slug,
+ }),
+ },
+ pageTitle: "Pastes",
+ },
+ { headers },
+ );
+}
+
+export async function action({ params, request, context }: Route.ActionArgs) {
+ const { organizationSlug } = params;
+ const { user, organization, headers } = context.get(organizationMembershipContext);
+
+ if (organization.slug !== organizationSlug) {
+ throw redirect(href("/organizations/:organizationSlug/pastes", { organizationSlug: organization.slug }));
+ }
+
+ const result = await validateFormData(request, pasteActionSchema);
+
+ if (!result.success) {
+ return result.response;
+ }
+
+ const { data: body } = result;
+
+ switch (body.intent) {
+ case "create": {
+
+ // Check if organization can create more pastes
+ const limits = await canCreatePaste(organization.id);
+ if (!limits.canCreate) {
+ return data(
+ {
+ errors: {
+ _form: [
+ `You've reached your paste limit (${limits.currentCount}/${limits.limit}). Upgrade your plan to create more pastes!`,
+ ],
+ },
+ },
+ { status: 403 },
+ );
+ }
+
+ const paste = await prisma.paste.create({
+ data: {
+ title: body.title,
+ content: body.content,
+ language: body.language || null,
+ isPublic: body.isPublic || false,
+ organizationId: organization.id,
+ createdById: user.id,
+ },
+ });
+
+ return redirect(
+ href("/organizations/:organizationSlug/pastes/:pasteId", {
+ organizationSlug: organization.slug,
+ pasteId: paste.id,
+ }),
+ { headers },
+ );
+ }
+
+ case "delete": {
+
+ // Verify paste belongs to organization
+ const paste = await prisma.paste.findFirst({
+ where: {
+ id: body.pasteId,
+ organizationId: organization.id,
+ },
+ });
+
+ if (!paste) {
+ return data({ errors: { _form: ["Paste not found"] } }, { status: 404 });
+ }
+
+ await prisma.paste.delete({
+ where: { id: body.pasteId },
+ });
+
+ return redirect(
+ href("/organizations/:organizationSlug/pastes", {
+ organizationSlug: organization.slug,
+ }),
+ { headers },
+ );
+ }
+ }
+}
+
+export const meta: Route.MetaFunction = ({ loaderData }) => [
+ { title: loaderData?.pageTitle || "Pastes" },
+];
+
+export default function PastesRoute({ loaderData, params }: Route.ComponentProps) {
+ const { pastes, pasteLimits } = loaderData;
+
+ return (
+
+
+
+
📋 Your Pastes
+
+ {pasteLimits.currentCount} / {pasteLimits.limit === Infinity ? "∞" : pasteLimits.limit} pastes used
+
+
+ {pasteLimits.canCreate ? (
+
+ + New Paste
+
+ ) : (
+
+ Upgrade to Create More
+
+ )}
+
+
+ {pasteLimits.currentCount === 0 ? (
+
+
+
No pastes yet!
+
+ Create your first paste to get started.
+
+
+
+ ) : (
+
+ {pastes.map((paste) => (
+
+
+
+
{paste.title}
+
+ {paste.content.substring(0, 100)}
+ {paste.content.length > 100 ? "..." : ""}
+
+
+
+
+ {paste.formattedCreatedAt}
+ {paste.viewCount} views
+ {paste.isPublic && Public }
+
+
+ ))}
+
+ )}
+
+ {/* Create Paste Form */}
+
+
Create New Paste
+
+
+
+
+ Title
+
+
+
+
+
+ Content
+
+
+
+
+
+
+
+ Make public
+
+
+
+ Create Paste
+
+
+ {!pasteLimits.canCreate && (
+
+ You've reached your paste limit.{" "}
+
+ Upgrade your plan
+ {" "}
+ to create more pastes!
+
+ )}
+
+
+
+ );
+}
+
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/projects.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/projects.tsx
new file mode 100644
index 0000000..c29bd72
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/projects.tsx
@@ -0,0 +1,38 @@
+import { href } from "react-router";
+
+import type { Route } from "./+types/projects";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { getPageTitle } from "~/utils/get-page-title.server";
+
+export function loader({ params, context }: Route.LoaderArgs) {
+ const i18n = getInstance(context);
+ const t = i18n.t.bind(i18n);
+
+ return {
+ breadcrumb: {
+ title: t("organizations:projects.breadcrumb"),
+ to: href("/organizations/:organizationSlug/projects", {
+ organizationSlug: params.organizationSlug,
+ }),
+ },
+ pageTitle: getPageTitle(t, "organizations:projects.pageTitle"),
+ };
+}
+
+export const meta: Route.MetaFunction = ({ loaderData }) => [
+ { title: loaderData?.pageTitle },
+];
+
+export default function ProjectsRoute() {
+ return (
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/projects_.active.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/projects_.active.tsx
new file mode 100644
index 0000000..e04cc93
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/projects_.active.tsx
@@ -0,0 +1,38 @@
+import { href } from "react-router";
+
+import type { Route } from "./+types/projects_.active";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { getPageTitle } from "~/utils/get-page-title.server";
+
+export function loader({ params, context }: Route.LoaderArgs) {
+ const i18n = getInstance(context);
+ const t = i18n.t.bind(i18n);
+
+ return {
+ breadcrumb: {
+ title: t("organizations:projectsActive.breadcrumb"),
+ to: href("/organizations/:organizationSlug/projects/active", {
+ organizationSlug: params.organizationSlug,
+ }),
+ },
+ pageTitle: getPageTitle(t, "organizations:projectsActive.pageTitle"),
+ };
+}
+
+export const meta: Route.MetaFunction = ({ loaderData }) => [
+ { title: loaderData?.pageTitle },
+];
+
+export default function ProjectsActiveRoute() {
+ return (
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/settings+/_index.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/settings+/_index.tsx
new file mode 100644
index 0000000..2a4644d
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/settings+/_index.tsx
@@ -0,0 +1,47 @@
+import { useLayoutEffect } from "react";
+import { href, redirect, useNavigate } from "react-router";
+
+import type { Route } from "./+types/_index";
+
+export function clientLoader({ params }: Route.ComponentProps) {
+ const mediaQuery = window.matchMedia("(max-width: 768px)");
+
+ if (mediaQuery.matches) {
+ return { mediaQuery };
+ }
+
+ return redirect(
+ href("/organizations/:organizationSlug/settings/general", {
+ organizationSlug: params.organizationSlug,
+ }),
+ );
+}
+
+export default function OrganizationSettingsIndexRoute({
+ loaderData,
+ params,
+}: Route.ComponentProps) {
+ const navigate = useNavigate();
+
+ useLayoutEffect(() => {
+ function listener(event: MediaQueryListEvent) {
+ if (event.matches) {
+ return;
+ }
+
+ navigate(
+ href("/organizations/:organizationSlug/settings/general", {
+ organizationSlug: params.organizationSlug,
+ }),
+ );
+ }
+
+ loaderData.mediaQuery.addEventListener("change", listener);
+
+ return () => {
+ loaderData.mediaQuery.removeEventListener("change", listener);
+ };
+ });
+
+ return null;
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/settings+/_organization-settings-layout.tsx b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/settings+/_organization-settings-layout.tsx
new file mode 100644
index 0000000..50713da
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/settings+/_organization-settings-layout.tsx
@@ -0,0 +1,48 @@
+import { data, href, Outlet } from "react-router";
+
+import type { Route } from "./+types/_organization-settings-layout";
+import { getInstance } from "~/features/localization/i18next-middleware.server";
+import { organizationMembershipContext } from "~/features/organizations/organizations-middleware.server";
+import { SettingsSidebar } from "~/features/organizations/settings/settings-sidebar";
+
+export async function loader({ params, context }: Route.LoaderArgs) {
+ const { role, headers } = context.get(organizationMembershipContext);
+ const i18next = getInstance(context);
+ const t = i18next.getFixedT(null, "organizations", "settings");
+
+ return data(
+ {
+ breadcrumb: {
+ title: t("breadcrumb"),
+ to: href("/organizations/:organizationSlug/settings", {
+ organizationSlug: params.organizationSlug,
+ }),
+ },
+ pageTitle: t("meta.title"),
+ role,
+ },
+ { headers },
+ );
+}
+
+export const meta: Route.MetaFunction = ({ loaderData }) => [
+ { title: loaderData.pageTitle },
+];
+
+export default function OrganizationSettingsLayout({
+ loaderData,
+ params,
+}: Route.ComponentProps) {
+ return (
+
+ );
+}
diff --git a/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/settings+/billing.spec.ts b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/settings+/billing.spec.ts
new file mode 100644
index 0000000..17f47f4
--- /dev/null
+++ b/apps/react-router/saas-template/app/routes/_authenticated-routes+/organizations_+/$organizationSlug+/settings+/billing.spec.ts
@@ -0,0 +1,773 @@
+/** biome-ignore-all lint/style/noNonNullAssertion: test code */
+
+import { describe, expect, onTestFinished, test } from "vitest";
+
+import { action } from "./billing";
+import {
+ CANCEL_SUBSCRIPTION_INTENT,
+ KEEP_CURRENT_SUBSCRIPTION_INTENT,
+ OPEN_CHECKOUT_SESSION_INTENT,
+ priceLookupKeysByTierAndInterval,
+ RESUME_SUBSCRIPTION_INTENT,
+ SWITCH_SUBSCRIPTION_INTENT,
+ UPDATE_BILLING_EMAIL_INTENT,
+ VIEW_INVOICES_INTENT,
+} from "~/features/billing/billing-constants";
+import {
+ createPopulatedStripeSubscriptionScheduleWithPhasesAndPrice,
+ createPopulatedStripeSubscriptionWithItemsAndPrice,
+ getRandomLookupKey,
+} from "~/features/billing/billing-factories.server";
+import { retrieveStripePriceFromDatabaseByLookupKey } from "~/features/billing/stripe-prices-model.server";
+import { retrieveStripeSubscriptionFromDatabaseById } from "~/features/billing/stripe-subscription-model.server";
+import {
+ retrieveStripeSubscriptionScheduleFromDatabaseById,
+ saveSubscriptionScheduleWithPhasesAndPriceToDatabase,
+} from "~/features/billing/stripe-subscription-schedule-model.server";
+import { createPopulatedOrganization } from "~/features/organizations/organizations-factories.server";
+import { addMembersToOrganizationInDatabaseById } from "~/features/organizations/organizations-model.server";
+import { createPopulatedUserAccount } from "~/features/user-accounts/user-accounts-factories.server";
+import {
+ deleteUserAccountFromDatabaseById,
+ saveUserAccountToDatabase,
+} from "~/features/user-accounts/user-accounts-model.server";
+import type { Organization, UserAccount } from "~/generated/client";
+import { OrganizationMembershipRole } from "~/generated/client";
+import { stripeHandlers } from "~/test/mocks/handlers/stripe";
+import { supabaseHandlers } from "~/test/mocks/handlers/supabase";
+import { setupMockServerLifecycle } from "~/test/msw-test-utils";
+import {
+ setupUserWithOrgAndAddAsMember,
+ setupUserWithTrialOrgAndAddAsMember,
+} from "~/test/server-test-utils";
+import {
+ createAuthenticatedRequest,
+ createOrganizationMembershipTestContextProvider,
+} from "~/test/test-utils";
+import type { DataWithResponseInit } from "~/utils/http-responses.server";
+import {
+ badRequest,
+ conflict,
+ forbidden,
+ notFound,
+} from "~/utils/http-responses.server";
+import { toFormData } from "~/utils/to-form-data";
+
+const createUrl = (organizationSlug: string) =>
+ `http://localhost:3000/organizations/${organizationSlug}/settings/billing`;
+
+const pattern = "/organizations/:organizationSlug/settings/billing";
+
+async function sendAuthenticatedRequest({
+ formData,
+ organizationSlug,
+ user,
+}: {
+ formData: FormData;
+ organizationSlug: Organization["slug"];
+ user: UserAccount;
+}) {
+ const request = await createAuthenticatedRequest({
+ formData,
+ method: "POST",
+ url: createUrl(organizationSlug),
+ user,
+ });
+ const params = { organizationSlug };
+
+ return await action({
+ context: await createOrganizationMembershipTestContextProvider({
+ params,
+ pattern,
+ request,
+ }),
+ params,
+ request,
+ unstable_pattern: pattern,
+ });
+}
+
+const server = setupMockServerLifecycle(...supabaseHandlers, ...stripeHandlers);
+
+describe("/organizations/:organizationSlug/settings/billing route action", () => {
+ test("given: an unauthenticated request, should: throw a redirect to the login page", async () => {
+ expect.assertions(2);
+
+ const organization = createPopulatedOrganization();
+ const request = new Request(createUrl(organization.slug), {
+ body: toFormData({}),
+ method: "POST",
+ });
+ const params = { organizationSlug: organization.slug };
+
+ try {
+ await action({
+ context: await createOrganizationMembershipTestContextProvider({
+ params,
+ pattern,
+ request,
+ }),
+ params,
+ request,
+ unstable_pattern: pattern,
+ });
+ } catch (error) {
+ if (error instanceof Response) {
+ expect(error.status).toEqual(302);
+ expect(error.headers.get("Location")).toEqual(
+ `/login?redirectTo=%2Forganizations%2F${organization.slug}%2Fsettings%2Fbilling`,
+ );
+ }
+ }
+ });
+
+ test("given: a user who is not a member of the organization, should: throw a 404", async () => {
+ expect.assertions(1);
+ // Create a user with an organization.
+ const { user } = await setupUserWithOrgAndAddAsMember();
+ // Creates a user and another organization.
+ const { organization } = await setupUserWithOrgAndAddAsMember();
+
+ try {
+ await sendAuthenticatedRequest({
+ formData: toFormData({}),
+ organizationSlug: organization.slug,
+ user,
+ });
+ } catch (error) {
+ const expected = notFound();
+
+ expect(error).toEqual(expected);
+ }
+ });
+
+ describe(`${CANCEL_SUBSCRIPTION_INTENT} intent`, () => {
+ const intent = CANCEL_SUBSCRIPTION_INTENT;
+
+ test("given: a valid request from a member, should: return a 403", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ role: OrganizationMembershipRole.member,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+ const expected = forbidden();
+
+ expect(actual.init?.status).toEqual(expected.init?.status);
+ });
+
+ test.each([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: a valid request from a %s, should: return a 302 and redirect to the customer portal", async (role) => {
+ // listen for the Stripe "cancel subscription" POST
+ let stripeCancelCalled = false;
+ const cancelListener = ({ request }: { request: Request }) => {
+ if (new URL(request.url).pathname === "/v1/billing_portal/sessions") {
+ stripeCancelCalled = true;
+ }
+ };
+ server.events.on("response:mocked", cancelListener);
+ onTestFinished(() => {
+ server.events.removeListener("response:mocked", cancelListener);
+ });
+
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ role,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent }),
+ organizationSlug: organization.slug,
+ user,
+ })) as Response;
+
+ expect(actual.status).toEqual(302);
+ expect(actual.headers.get("Location")).toMatch(
+ /^https:\/\/billing\.stripe\.com\/p\/session\/\w+(?:\?.*)?$/,
+ );
+ expect(stripeCancelCalled).toEqual(true);
+ });
+ });
+
+ describe(`${KEEP_CURRENT_SUBSCRIPTION_INTENT} intent`, () => {
+ const intent = KEEP_CURRENT_SUBSCRIPTION_INTENT;
+
+ test("given: a member role, should: return a 403", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ role: OrganizationMembershipRole.member,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+
+ expect(actual.init?.status).toEqual(forbidden().init?.status);
+ });
+
+ test.each([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: a %s role without pending schedule, should: return a 200 and NOT call the release endpoint", async (role) => {
+ let releaseCalled = false;
+ const listener = ({ request }: { request: Request }) => {
+ if (
+ /^\/v1\/subscription_schedules\/.+\/release$/.test(
+ new URL(request.url).pathname,
+ )
+ ) {
+ releaseCalled = true;
+ }
+ };
+ server.events.on("response:mocked", listener);
+ onTestFinished(() => {
+ server.events.removeListener("response:mocked", listener);
+ });
+
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ role,
+ });
+
+ const response = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+
+ expect(response.data).toEqual({});
+ expect(releaseCalled).toEqual(false);
+ });
+
+ test.each([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: a %s role with pending schedule, should: return a 200, call the release endpoint and delete the schedule from the database", async (role) => {
+ let releaseCalled = false;
+ const listener = ({ request }: { request: Request }) => {
+ if (
+ /^\/v1\/subscription_schedules\/.+\/release$/.test(
+ new URL(request.url).pathname,
+ )
+ ) {
+ releaseCalled = true;
+ }
+ };
+ server.events.on("response:mocked", listener);
+ onTestFinished(() => {
+ server.events.removeListener("response:mocked", listener);
+ });
+
+ const { user, organization, subscription } =
+ await setupUserWithOrgAndAddAsMember({
+ lookupKey: priceLookupKeysByTierAndInterval.mid.monthly,
+ role,
+ });
+ const price = await retrieveStripePriceFromDatabaseByLookupKey(
+ priceLookupKeysByTierAndInterval.low.monthly,
+ );
+ const subscriptionSchedule =
+ createPopulatedStripeSubscriptionScheduleWithPhasesAndPrice({
+ phases: [{ price: price! }],
+ subscriptionId: subscription.stripeId,
+ });
+ await saveSubscriptionScheduleWithPhasesAndPriceToDatabase(
+ subscriptionSchedule,
+ );
+
+ const response = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+
+ expect(response.data).toEqual({});
+ expect(releaseCalled).toEqual(true);
+ const schedule = await retrieveStripeSubscriptionScheduleFromDatabaseById(
+ subscriptionSchedule.stripeId,
+ );
+ expect(schedule).toEqual(null);
+ });
+ });
+
+ describe(`${OPEN_CHECKOUT_SESSION_INTENT} intent`, () => {
+ const intent = OPEN_CHECKOUT_SESSION_INTENT;
+
+ test("given: a valid request from a member, should: return a 403", async () => {
+ const { user, organization } = await setupUserWithTrialOrgAndAddAsMember({
+ role: OrganizationMembershipRole.member,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent, lookupKey: getRandomLookupKey() }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+ const expected = forbidden();
+
+ expect(actual.init?.status).toEqual(expected.init?.status);
+ });
+
+ test.each([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: a valid request from a %s, should: return a 302 and redirect to the customer portal", async (role) => {
+ let checkoutSessionCalled = false;
+ const checkoutListener = ({ request }: { request: Request }) => {
+ if (new URL(request.url).pathname === "/v1/checkout/sessions") {
+ checkoutSessionCalled = true;
+ }
+ };
+ server.events.on("response:mocked", checkoutListener);
+ onTestFinished(() => {
+ server.events.removeListener("response:mocked", checkoutListener);
+ });
+
+ const { user, organization } = await setupUserWithTrialOrgAndAddAsMember({
+ role,
+ });
+
+ const response = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent, lookupKey: getRandomLookupKey() }),
+ organizationSlug: organization.slug,
+ user,
+ })) as Response;
+
+ expect(response.status).toEqual(302);
+ expect(response.headers.get("Location")).toMatch(
+ /^https:\/\/checkout\.stripe\.com\/pay\/cs_[\dA-Za-z]+(?:\?.*)?$/,
+ );
+ expect(checkoutSessionCalled).toEqual(true);
+ });
+
+ test.each([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: a valid request from a %s, but their organization has too many members for the chosen plan, should: return a 409", async (role) => {
+ let checkoutSessionCalled = false;
+ const checkoutListener = ({ request }: { request: Request }) => {
+ if (new URL(request.url).pathname === "/v1/checkout/sessions") {
+ checkoutSessionCalled = true;
+ }
+ };
+ server.events.on("response:mocked", checkoutListener);
+ onTestFinished(() => {
+ server.events.removeListener("response:mocked", checkoutListener);
+ });
+
+ const { user, organization } = await setupUserWithTrialOrgAndAddAsMember({
+ role,
+ });
+ const otherUser = createPopulatedUserAccount();
+ await saveUserAccountToDatabase(otherUser);
+ await addMembersToOrganizationInDatabaseById({
+ id: organization.id,
+ members: [otherUser.id],
+ });
+ onTestFinished(async () => {
+ await deleteUserAccountFromDatabaseById(otherUser.id);
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({
+ intent,
+ lookupKey: priceLookupKeysByTierAndInterval.low.monthly,
+ }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit<{ message: string }>;
+ const expected = conflict();
+
+ expect(actual).toEqual(expected);
+ expect(checkoutSessionCalled).toEqual(false);
+ });
+
+ test.each([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: a valid request from a %s, but their organization already has a subscription, should: return a 409", async (role) => {
+ let checkoutSessionCalled = false;
+ const checkoutListener = ({ request }: { request: Request }) => {
+ if (new URL(request.url).pathname === "/v1/checkout/sessions") {
+ checkoutSessionCalled = true;
+ }
+ };
+ server.events.on("response:mocked", checkoutListener);
+ onTestFinished(() => {
+ server.events.removeListener("response:mocked", checkoutListener);
+ });
+
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ role,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({
+ intent,
+ lookupKey: priceLookupKeysByTierAndInterval.low.monthly,
+ }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit<{ message: string }>;
+ const expected = conflict();
+
+ expect(actual).toEqual(expected);
+ expect(checkoutSessionCalled).toEqual(false);
+ });
+ });
+
+ describe(`${RESUME_SUBSCRIPTION_INTENT} intent`, () => {
+ const intent = RESUME_SUBSCRIPTION_INTENT;
+
+ test("given: a valid request from a member, should: return a 403", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ role: OrganizationMembershipRole.member,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+ const expected = forbidden();
+
+ expect(actual.init?.status).toEqual(expected.init?.status);
+ });
+
+ test.each([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: a valid request from a %s and a subscription that is set to cancel at period end, should: return a 200 and call the update endpoint and update the subscription in the database", async (role) => {
+ let resumeCalled = false;
+ const listener = ({ request }: { request: Request }) => {
+ if (
+ new URL(request.url).pathname ===
+ `/v1/subscriptions/${subscription.stripeId}`
+ ) {
+ resumeCalled = true;
+ }
+ };
+ server.events.on("response:mocked", listener);
+ onTestFinished(() => {
+ server.events.removeListener("response:mocked", listener);
+ });
+
+ const { user, organization, subscription } =
+ await setupUserWithOrgAndAddAsMember({
+ role,
+ subscription: createPopulatedStripeSubscriptionWithItemsAndPrice({
+ cancelAtPeriodEnd: true,
+ }),
+ });
+
+ const response = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+
+ expect(response.data).toEqual({});
+ expect(resumeCalled).toEqual(true);
+
+ const updatedSubscription =
+ await retrieveStripeSubscriptionFromDatabaseById(subscription.stripeId);
+ expect(updatedSubscription?.cancelAtPeriodEnd).toEqual(false);
+ });
+ });
+
+ describe(`${SWITCH_SUBSCRIPTION_INTENT} intent`, () => {
+ const intent = SWITCH_SUBSCRIPTION_INTENT;
+
+ test("given: a valid request from a member, should: return a 403", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ role: OrganizationMembershipRole.member,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent, lookupKey: getRandomLookupKey() }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+ const expected = forbidden();
+
+ expect(actual.init?.status).toEqual(expected.init?.status);
+ });
+
+ test.each([
+ {
+ data: {},
+ expected: badRequest({
+ result: {
+ error: {
+ fieldErrors: {
+ lookupKey: [
+ "Invalid input: expected string, received undefined",
+ ],
+ },
+ },
+ },
+ }),
+ },
+ ])("given: invalid data $data, should: return validation errors", async ({
+ data,
+ expected,
+ }) => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ role: OrganizationMembershipRole.admin,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent, ...data }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+
+ expect(actual).toMatchObject(expected);
+ });
+
+ test("given: an invalid lookup key, should: return a bad request", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ role: OrganizationMembershipRole.admin,
+ });
+
+ const response = (await sendAuthenticatedRequest({
+ formData: toFormData({
+ intent,
+ lookupKey: "invalid_lookup_key",
+ }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+
+ expect(response.init?.status).toEqual(400);
+ expect(response.data).toEqual({ message: "Price not found" });
+ });
+
+ test.each([
+ OrganizationMembershipRole.admin,
+ OrganizationMembershipRole.owner,
+ ])("given: a valid request from a %s, should: return a 302 and redirect to the customer portal", async (role) => {
+ let switchSessionCalled = false;
+ const switchListener = ({ request }: { request: Request }) => {
+ if (new URL(request.url).pathname === "/v1/billing_portal/sessions") {
+ switchSessionCalled = true;
+ }
+ };
+ server.events.on("response:mocked", switchListener);
+ onTestFinished(() => {
+ server.events.removeListener("response:mocked", switchListener);
+ });
+
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ lookupKey: getRandomLookupKey(),
+ role,
+ });
+
+ const response = (await sendAuthenticatedRequest({
+ formData: toFormData({
+ intent,
+ lookupKey: priceLookupKeysByTierAndInterval.low.monthly,
+ }),
+ organizationSlug: organization.slug,
+ user,
+ })) as Response;
+
+ expect(response.status).toEqual(302);
+ expect(response.headers.get("Location")).toMatch(
+ /^https:\/\/billing\.stripe\.com\/p\/session\/\w+(?:\?.*)?$/,
+ );
+ expect(switchSessionCalled).toEqual(true);
+ });
+ });
+
+ describe(`${UPDATE_BILLING_EMAIL_INTENT} intent`, () => {
+ const intent = UPDATE_BILLING_EMAIL_INTENT;
+
+ test("given: a valid request from a member, should: return a 403", async () => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ role: OrganizationMembershipRole.member,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ billingEmail: "new@example.com", intent }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit;
+ const expected = forbidden();
+
+ expect(actual.init?.status).toEqual(expected.init?.status);
+ });
+
+ test.each([
+ {
+ data: {},
+ expected: badRequest({
+ result: {
+ error: {
+ fieldErrors: {
+ billingEmail: [
+ "billing:billingPage.updateBillingEmailModal.emailInvalid",
+ ],
+ },
+ },
+ },
+ }),
+ },
+ {
+ data: { billingEmail: "not-an-email" },
+ expected: badRequest({
+ result: {
+ error: {
+ fieldErrors: {
+ billingEmail: [
+ "billing:billingPage.updateBillingEmailModal.emailInvalid",
+ ],
+ },
+ },
+ },
+ }),
+ },
+ ])("given: invalid data $data, should: return validation errors", async ({
+ data,
+ expected,
+ }) => {
+ const { user, organization } = await setupUserWithOrgAndAddAsMember({
+ role: OrganizationMembershipRole.admin,
+ });
+
+ const actual = (await sendAuthenticatedRequest({
+ formData: toFormData({ intent, ...data }),
+ organizationSlug: organization.slug,
+ user,
+ })) as DataWithResponseInit