diff --git a/AGENTS.md b/AGENTS.md index cca652c..17a8a7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,18 +5,29 @@ When checking UI changes through a preview or browser, start the app with agent mode enabled: ```sh -PORT=18203 AGENT_MODE=true pnpm dev +PORT=xxxxx AGENT_MODE=true pnpm dev ``` -Use port 18203 or override the `PORT=` in the start command, but be sure to not use ports in the 3000-range -because those are probably used by local dev servers on the user's machine, and it may fail for EADDRINUSE. +Setting the `PORT=xxxxx` to an high enough port that is unlikely to hit +another service or conflict with another workflow in preview (min 10000). -Then open `/dashboard` directly. `AGENT_MODE` bypasses session and role authorization and disables the auth-based redirects between `/login` and `/dashboard`, so no real account or login flow is needed. +Then open `/dashboard` directly (if not working on a page outside dashboard). +`AGENT_MODE` bypasses session and role authorization and disables the auth-based +redirects between `/login` and `/dashboard`, so no real account or login flow is needed. +You can indipendently check /dashboard and /login for modifying those pages -Use this flag only for local agent-driven development and previews. Never enable it in a deployed environment, and always verify that normal behavior still works with `AGENT_MODE=false`. +Use this flag only for local agent-driven development and previews. +Never enable it in a deployed environment. -You can indipendently check /dashboard and /login for modifying those pages +When modifing auth-related code or redirects between authed and non-authed contexts, +always verify that normal behavior still works with `AGENT_MODE=false`. > [!IMPORTANT] > Do not run destructive actions across multiple rows, unless specific prompt indication or > ask for user confirmation ALWAYS. + +## Git + +Always do commits with conventional commits. +Set description when a commit includes lot of changes that cannot +be divided into multiple commits, to specify the most important changes of the commit. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 3ec092a..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,3 +0,0 @@ -# Claude instructions - -Follow the project guidance in [AGENTS.md](./AGENTS.md). In particular, use `AGENT_MODE=true` when launching the app for preview or browser-based verification of authenticated dashboard pages. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/components.json b/components.json index ae57d62..e02f8e2 100644 --- a/components.json +++ b/components.json @@ -12,6 +12,8 @@ }, "iconLibrary": "lucide", "rtl": false, + "menuColor": "default", + "menuAccent": "subtle", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -19,7 +21,8 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": {} + "registries": { + "@iconiq": "https://iconiqui.com/r/{name}.json", + "@reui": "https://reui.io/r/{style}/{name}.json" + } } diff --git a/package.json b/package.json index a92d29e..f9bbad8 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.4.0", "lucide-react": "^0.545.0", + "motion": "^12.42.2", "nitro": "latest", "react": "^19.2.0", "react-day-picker": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69c602f..f982a79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: lucide-react: specifier: ^0.545.0 version: 0.545.0(react@19.2.7) + motion: + specifier: ^12.42.2 + version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) nitro: specifier: latest version: 3.0.260610-beta(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0)(mongodb@7.1.0)(vite@8.1.4(@types/node@22.20.1)(jiti@2.7.0)) @@ -1940,6 +1943,20 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@12.42.2: + resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -2398,6 +2415,26 @@ packages: socks: optional: true + motion-dom@12.42.2: + resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.42.2: + resolution: {integrity: sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5066,6 +5103,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + motion-dom: 12.42.2 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + fresh@2.0.0: {} fs-extra@11.3.6: @@ -5386,6 +5432,20 @@ snapshots: bson: 7.2.0 mongodb-connection-string-url: 7.0.1 + motion-dom@12.42.2: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + framer-motion: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + ms@2.1.3: {} nanoid@3.3.12: {} diff --git a/src/components/app-mark.tsx b/src/components/app-mark.tsx index c30929c..62302ea 100644 --- a/src/components/app-mark.tsx +++ b/src/components/app-mark.tsx @@ -5,10 +5,12 @@ import { cn } from "@/lib/utils" export function AppMark({ compact = false, sidebarResponsive = false, + className, onClick, }: { compact?: boolean sidebarResponsive?: boolean + className?: string onClick?: React.MouseEventHandler }) { return ( @@ -16,7 +18,8 @@ export function AppMark({ to="/dashboard" className={cn( "flex w-max items-center gap-3 rounded-lg text-inherit outline-none focus-visible:ring-3 focus-visible:ring-current/25", - sidebarResponsive && "group-data-[collapsible=icon]:gap-0" + sidebarResponsive && "group-data-[collapsible=icon]:gap-0", + className )} aria-label="PoliNetwork Admin home" onClick={onClick} diff --git a/src/components/dashboard-navigation.ts b/src/components/dashboard-navigation.ts index 2e4fe1d..428c2ae 100644 --- a/src/components/dashboard-navigation.ts +++ b/src/components/dashboard-navigation.ts @@ -38,7 +38,7 @@ export const dashboardNavigation = [ items: [ { title: "Users", url: "/dashboard/telegram/users", icon: UsersRound }, { title: "Groups", url: "/dashboard/telegram/groups", icon: Database }, - { title: "Access grants", url: "/dashboard/telegram/grants", icon: ShieldCheck }, + { title: "Grants", url: "/dashboard/telegram/grants", icon: ShieldCheck }, ], }, { diff --git a/src/components/dashboard-sidebar.tsx b/src/components/dashboard-sidebar.tsx index a124e66..f6667c4 100644 --- a/src/components/dashboard-sidebar.tsx +++ b/src/components/dashboard-sidebar.tsx @@ -1,11 +1,19 @@ import { Link, useRouterState } from "@tanstack/react-router" import { ChevronRight, LogOut } from "lucide-react" import { useEffect, useState } from "react" +import { cn } from "@/lib/utils" import type { AdminSession } from "@/server/api.functions" import { AppMark } from "./app-mark" import { accountNavigation, dashboardNavigation, overviewNavigation } from "./dashboard-navigation" import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu" import { Sidebar, SidebarContent, @@ -24,6 +32,13 @@ import { const categoryStorageKey = "polinetwork-sidebar-categories" +// Shared height and visual treatment for every primary nav row. +const navItemClass = "h-10 gap-2.5 rounded-lg px-2.5" + +// The single strong "selected page" treatment: a controlled PoliNetwork-blue surface. +const selectedPageClass = + "data-active:bg-sidebar-primary/15 data-active:font-medium data-active:text-sidebar-accent-foreground data-active:hover:bg-sidebar-primary/20" + type DashboardSidebarProps = React.ComponentProps & { user: AdminSession["user"] | undefined loggingOut: boolean @@ -31,7 +46,9 @@ type DashboardSidebarProps = React.ComponentProps & { } export function DashboardSidebar({ user, loggingOut, onLogout, ...props }: DashboardSidebarProps) { - const pathname = useRouterState({ select: (state) => state.location.pathname }) + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }) const { setOpenMobile } = useSidebar() const [categoryState, setCategoryState] = useState>({}) @@ -56,21 +73,23 @@ export function DashboardSidebar({ user, loggingOut, onLogout, ...props }: Dashb }) } + const accountActive = isPathActive(pathname, accountNavigation.url) + return ( - - - setOpenMobile(false)} /> + + + setOpenMobile(false)} className="min-w-0" /> - - - + + + setOpenMobile(false)} />} + className={cn(navItemClass, selectedPageClass)} > {overviewNavigation.title} @@ -78,43 +97,97 @@ export function DashboardSidebar({ user, loggingOut, onLogout, ...props }: Dashb {dashboardNavigation.map((category) => { - const routeIsActive = category.items.some((item) => pathname.startsWith(item.url)) + const itemActiveStates = category.items.map((item) => isPathActive(pathname, item.url)) + const routeIsActive = itemActiveStates.some(Boolean) const open = categoryState[category.title] ?? routeIsActive + const connectorHeight = category.items.length * 38 + 2 + const itemCenters = category.items.map((_, index) => 20 + index * 38) + const lastItemIndex = category.items.length - 1 + const lastItemCenter = itemCenters[lastItemIndex] + const inactiveBranches = itemCenters + .map((center, index) => + !itemActiveStates[index] && index !== lastItemIndex ? `M 0 ${center} H 10` : "" + ) + .filter(Boolean) + .join(" ") + const railPath = [ + `M 0 2 V ${lastItemCenter}`, + !itemActiveStates[lastItemIndex] ? "H 10" : "", + inactiveBranches, + ] + .filter(Boolean) + .join(" ") return ( } open={open} onOpenChange={(nextOpen) => persistCategory(category.title, nextOpen)} className="group/collapsible" > - } - > - {category.iconSrc ? ( - - ) : ( - - )} - {category.title} - - - - - {category.items.map((item) => ( - - setOpenMobile(false)} />} - > - - {item.title} - - - ))} - - + + } + className={cn( + navItemClass, + "text-sidebar-foreground/80", + routeIsActive && "bg-sidebar-accent/40 text-sidebar-accent-foreground" + )} + > + {category.iconSrc ? ( + + ) : ( + + )} + {category.title} + + + + + + {category.items.map((item, index) => { + const itemIsActive = itemActiveStates[index] + + return ( + + setOpenMobile(false)} />} + className={cn( + "h-9 gap-2.5 rounded-lg pl-2.5 text-sidebar-foreground/70", + "data-active:bg-sidebar-primary/15 data-active:font-medium data-active:text-sidebar-accent-foreground data-active:hover:bg-sidebar-primary/20", + "[&>svg]:text-sidebar-foreground/50 data-active:[&>svg]:text-sidebar-primary" + )} + > + + {item.title} + + + ) + })} + + + ) })} @@ -122,36 +195,50 @@ export function DashboardSidebar({ user, loggingOut, onLogout, ...props }: Dashb - - + + - setOpenMobile(false)} />} - className="h-14 rounded-b-none" - > - - {user?.image && } - - {initials(user?.name, user?.email)} - - - - {user?.name || "PoliNetwork member"} - - {user?.email || "Open account settings"} - - - - - - - - - {loggingOut ? "Signing out…" : "Sign out"} - + + + } + > + + {user?.image && } + + {initials(user?.name, user?.email)} + + + + {user?.name || "PoliNetwork member"} + + + + + setOpenMobile(false)} />} + className="h-10 gap-2.5 px-2.5 py-0" + > + + Settings + + + + {loggingOut ? "Signing out…" : "Sign out"} + + + + @@ -168,3 +255,10 @@ function initials(name?: string | null, email?: string) { .map((part) => part[0]?.toUpperCase()) .join("") } + +function isPathActive(pathname: string, url: string, exact = false) { + const normalizedPathname = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname + const normalizedUrl = url.length > 1 ? url.replace(/\/+$/, "") : url + + return normalizedPathname === normalizedUrl || (!exact && normalizedPathname.startsWith(`${normalizedUrl}/`)) +} diff --git a/src/components/reui/stepper.tsx b/src/components/reui/stepper.tsx new file mode 100644 index 0000000..720f4fc --- /dev/null +++ b/src/components/reui/stepper.tsx @@ -0,0 +1,421 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { + Children, + createContext, + type HTMLAttributes, + isValidElement, + type ReactElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react" + +import { cn } from "@/lib/utils" + +// Types +type StepperOrientation = "horizontal" | "vertical" +type StepState = "active" | "completed" | "inactive" | "loading" +type StepIndicators = { + active?: React.ReactNode + completed?: React.ReactNode + inactive?: React.ReactNode + loading?: React.ReactNode +} + +interface StepperContextValue { + activeStep: number + setActiveStep: (step: number) => void + stepsCount: number + orientation: StepperOrientation + registerTrigger: (node: HTMLButtonElement) => void + unregisterTrigger: (node: HTMLButtonElement) => void + triggerNodes: HTMLButtonElement[] + focusNext: (currentIdx: number) => void + focusPrev: (currentIdx: number) => void + focusFirst: () => void + focusLast: () => void + indicators: StepIndicators +} + +interface StepItemContextValue { + step: number + state: StepState + isDisabled: boolean + isLoading: boolean +} + +const StepperContext = createContext(undefined) +const StepItemContext = createContext(undefined) + +function useStepper() { + const ctx = useContext(StepperContext) + if (!ctx) throw new Error("useStepper must be used within a Stepper") + return ctx +} + +function useStepItem() { + const ctx = useContext(StepItemContext) + if (!ctx) throw new Error("useStepItem must be used within a StepperItem") + return ctx +} + +interface StepperProps extends HTMLAttributes { + defaultValue?: number + value?: number + onValueChange?: (value: number) => void + orientation?: StepperOrientation + indicators?: StepIndicators +} + +function Stepper({ + defaultValue = 1, + value, + onValueChange, + orientation = "horizontal", + className, + children, + indicators = {}, + ...props +}: StepperProps) { + const [activeStep, setActiveStep] = useState(defaultValue) + const [triggerNodes, setTriggerNodes] = useState([]) + + // Register/unregister triggers + const registerTrigger = useCallback((node: HTMLButtonElement) => { + setTriggerNodes((prev) => { + if (prev.includes(node)) return prev + return [...prev, node] + }) + }, []) + const unregisterTrigger = useCallback((node: HTMLButtonElement) => { + setTriggerNodes((prev) => prev.filter((trigger) => trigger !== node)) + }, []) + + const handleSetActiveStep = useCallback( + (step: number) => { + if (value === undefined) { + setActiveStep(step) + } + onValueChange?.(step) + }, + [value, onValueChange] + ) + + const currentStep = value ?? activeStep + + // Keyboard navigation logic + const focusTrigger = (idx: number) => { + if (triggerNodes[idx]) triggerNodes[idx].focus() + } + const focusNext = (currentIdx: number) => focusTrigger((currentIdx + 1) % triggerNodes.length) + const focusPrev = (currentIdx: number) => focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length) + const focusFirst = () => focusTrigger(0) + const focusLast = () => focusTrigger(triggerNodes.length - 1) + + // Context value + const contextValue = useMemo( + () => ({ + activeStep: currentStep, + setActiveStep: handleSetActiveStep, + stepsCount: Children.toArray(children).filter( + (child): child is ReactElement => + isValidElement(child) && (child.type as { displayName?: string }).displayName === "StepperItem" + ).length, + orientation, + registerTrigger, + unregisterTrigger, + focusNext, + focusPrev, + focusFirst, + focusLast, + triggerNodes, + indicators, + }), + [currentStep, handleSetActiveStep, children, orientation, registerTrigger, unregisterTrigger, triggerNodes] + ) + + return ( + +
+ {children} +
+
+ ) +} + +interface StepperItemProps extends React.HTMLAttributes { + step: number + completed?: boolean + disabled?: boolean + loading?: boolean +} + +function StepperItem({ + step, + completed = false, + disabled = false, + loading = false, + className, + children, + ...props +}: StepperItemProps) { + const { activeStep } = useStepper() + + const state: StepState = completed || step < activeStep ? "completed" : activeStep === step ? "active" : "inactive" + + const isLoading = loading && step === activeStep + + return ( + +
+ {children} +
+
+ ) +} + +type StepperTriggerProps = useRender.ComponentProps<"button"> + +function StepperTrigger({ className, children, tabIndex, render, ...props }: StepperTriggerProps) { + const { state, isLoading } = useStepItem() + const stepperCtx = useStepper() + const { + setActiveStep, + activeStep, + registerTrigger, + unregisterTrigger, + triggerNodes, + focusNext, + focusPrev, + focusFirst, + focusLast, + } = stepperCtx + const { step, isDisabled } = useStepItem() + const isSelected = activeStep === step + const id = `stepper-tab-${step}` + const panelId = `stepper-panel-${step}` + + // Register this trigger for keyboard navigation + const btnRef = useRef(null) + useEffect(() => { + const node = btnRef.current + if (!node) return + registerTrigger(node) + return () => unregisterTrigger(node) + }, [registerTrigger, unregisterTrigger]) + + // Find our index among triggers for navigation + const myIdx = useMemo(() => triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current), [triggerNodes]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault() + if (myIdx !== -1 && focusNext) focusNext(myIdx) + break + case "ArrowLeft": + case "ArrowUp": + e.preventDefault() + if (myIdx !== -1 && focusPrev) focusPrev(myIdx) + break + case "Home": + e.preventDefault() + if (focusFirst) focusFirst() + break + case "End": + e.preventDefault() + if (focusLast) focusLast() + break + case "Enter": + case " ": + e.preventDefault() + setActiveStep(step) + break + } + } + + const defaultProps = { + role: "tab", + id, + "aria-selected": isSelected, + "aria-controls": panelId, + tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1, + "data-slot": "stepper-trigger", + "data-state": state, + "data-loading": isLoading, + className: cn( + "focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60", + "gap-2.5 rounded-full", + className + ), + onClick: () => setActiveStep(step), + onKeyDown: handleKeyDown, + disabled: isDisabled, + children, + } + + return useRender({ + defaultTagName: "button", + render, + ref: btnRef, + props: mergeProps<"button">(defaultProps, props), + }) +} + +function StepperIndicator({ children, className }: React.ComponentProps<"div">) { + const { state, isLoading } = useStepItem() + const { indicators } = useStepper() + + return ( +
+
+ {indicators && + ((isLoading && indicators.loading) || + (state === "completed" && indicators.completed) || + (state === "active" && indicators.active) || + (state === "inactive" && indicators.inactive)) + ? (isLoading && indicators.loading) || + (state === "completed" && indicators.completed) || + (state === "active" && indicators.active) || + (state === "inactive" && indicators.inactive) + : children} +
+
+ ) +} + +function StepperSeparator({ className }: React.ComponentProps<"div">) { + const { state } = useStepItem() + + return ( +
+ ) +} + +function StepperTitle({ children, className }: React.ComponentProps<"h3">) { + const { state } = useStepItem() + + return ( +

+ {children} +

+ ) +} + +function StepperDescription({ children, className }: React.ComponentProps<"div">) { + const { state } = useStepItem() + + return ( +
+ {children} +
+ ) +} + +function StepperNav({ children, className }: React.ComponentProps<"nav">) { + const { activeStep, orientation } = useStepper() + + return ( + + ) +} + +function StepperPanel({ children, className }: React.ComponentProps<"div">) { + const { activeStep } = useStepper() + + return ( +
+ {children} +
+ ) +} + +interface StepperContentProps extends React.ComponentProps<"div"> { + value: number + forceMount?: boolean +} + +function StepperContent({ value, forceMount, children, className }: StepperContentProps) { + const { activeStep } = useStepper() + const isActive = value === activeStep + + if (!forceMount && !isActive) { + return null + } + + return ( + + ) +} + +export { + useStepper, + useStepItem, + Stepper, + StepperItem, + StepperTrigger, + StepperIndicator, + StepperSeparator, + StepperTitle, + StepperDescription, + StepperPanel, + StepperContent, + StepperNav, + type StepperProps, + type StepperItemProps, + type StepperTriggerProps, + type StepperContentProps, +} diff --git a/src/components/telegram/create-grant-dialog.tsx b/src/components/telegram/create-grant-dialog.tsx new file mode 100644 index 0000000..8755712 --- /dev/null +++ b/src/components/telegram/create-grant-dialog.tsx @@ -0,0 +1,445 @@ +import { useRouter } from "@tanstack/react-router" +import { ArrowLeft, CalendarPlus, LoaderCircle, Search, UserRound, X } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { + Stepper, + StepperIndicator, + StepperItem, + StepperNav, + StepperTitle, + StepperTrigger, +} from "@/components/reui/stepper" +import type { TgUser } from "@/lib/api/types" +import { createTelegramGrant, findTelegramUser } from "@/server/api.functions" +import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert" +import { Button } from "../ui/button" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "../ui/field" +import { Input } from "../ui/input" +import { Textarea } from "../ui/textarea" +import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group" +import { GrantDateTimeFields } from "./grant-date-time-fields" + +export type GrantDialogUser = Pick + +type CreateGrantDialogProps = { + user?: GrantDialogUser +} + +const DISCARD_TOAST_ID = "discard-grant-dialog-changes" + +function toDateTimeInput(date: Date) { + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000) + return local.toISOString().slice(0, 16) +} + +function earliestGrantStart() { + const start = new Date() + start.setHours(0, 0, 0, 0) + return start +} + +function displayName(user: GrantDialogUser) { + return [user.firstName, user.lastName].filter(Boolean).join(" ") || "Unnamed account" +} + +function grantMutationError(error: string) { + if (error === "UNAUTHORIZED") return "You do not have permission to create grants." + if (error === "ALREADY_EXISTING") return "This user already has an ongoing grant." + return "The grant could not be created." +} + +function UserInformation({ + user, + clearable, + onClear, +}: { + user: GrantDialogUser + clearable?: boolean + onClear?: () => void +}) { + return ( + + + {displayName(user)} + + {user.username ? `@${user.username}` : "No Telegram username"} · Telegram ID {user.id} + + {clearable && onClear && ( + + + + )} + + ) +} + +function GrantStepper({ + step, + canOpenDetails, + onStepChange, +}: { + step: "user" | "details" + canOpenDetails: boolean + onStepChange: (step: "user" | "details") => void +}) { + const steps = [ + { title: "Target user", value: 1 }, + { title: "Details", value: 2 }, + ] + + return ( + { + if (value === 1) onStepChange("user") + if (value === 2 && canOpenDetails) onStepChange("details") + }} + className="p-5" + aria-label="Grant creation progress" + > + + {steps.map((item) => ( + + + + Step {item.value} + + + {item.title} + + + + ))} + + + ) +} + +export function CreateGrantDialog({ user: fixedUser }: CreateGrantDialogProps) { + const [open, setOpen] = useState(false) + const [step, setStep] = useState<"user" | "details">(fixedUser ? "details" : "user") + const [lookupBy, setLookupBy] = useState<"username" | "id">("username") + const [query, setQuery] = useState("") + const [selectedUser, setSelectedUser] = useState(fixedUser ?? null) + const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "not-found" | "error">("idle") + const [lookupMessage, setLookupMessage] = useState("") + const [validSince, setValidSince] = useState("") + const [validUntil, setValidUntil] = useState("") + const [reason, setReason] = useState("") + const [dirty, setDirty] = useState(false) + const [pending, setPending] = useState(false) + const router = useRouter() + + const resetForm = () => { + setStep(fixedUser ? "details" : "user") + setLookupBy("username") + setQuery("") + setSelectedUser(fixedUser ?? null) + setLookupStatus("idle") + setLookupMessage("") + setValidSince("") + setValidUntil("") + setReason("") + setDirty(false) + } + + const closeAndReset = () => { + toast.dismiss(DISCARD_TOAST_ID) + setOpen(false) + resetForm() + } + + const requestClose = () => { + if (pending) return + if (!dirty) { + closeAndReset() + return + } + + toast.warning("Discard grant changes?", { + id: DISCARD_TOAST_ID, + description: "Your edits will be lost.", + position: "bottom-center", + duration: Number.POSITIVE_INFINITY, + className: "grant-discard-toast", + action: { + label: "Discard", + onClick: closeAndReset, + }, + cancel: { + label: "Keep editing", + onClick: () => undefined, + }, + }) + } + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + resetForm() + setOpen(true) + return + } + requestClose() + } + + const changeLookupBy = (value: string[]) => { + const next = value[0] + if (next !== "username" && next !== "id") return + setLookupBy(next) + setQuery("") + setSelectedUser(null) + setLookupStatus("idle") + setLookupMessage("") + setDirty(true) + } + + const lookup = async () => { + const normalized = query.trim() + const numericId = Number(normalized) + if (!normalized || (lookupBy === "id" && (!Number.isInteger(numericId) || numericId <= 0))) { + setLookupStatus("error") + setLookupMessage(lookupBy === "id" ? "Enter a positive numeric Telegram ID." : "Enter a Telegram username.") + return + } + + setSelectedUser(null) + setLookupStatus("loading") + setLookupMessage("") + try { + const result = await findTelegramUser({ + data: lookupBy === "username" ? { by: "username", username: normalized } : { by: "id", userId: numericId }, + }) + if (result.status === "found" && result.user) { + setSelectedUser(result.user) + setLookupStatus("idle") + } else { + setLookupStatus(result.status === "not-found" ? "not-found" : "error") + setLookupMessage( + result.status === "not-found" + ? `No Telegram user was found for this ${lookupBy === "id" ? "ID" : "username"}.` + : (result.message ?? "Telegram user lookup failed.") + ) + } + } catch (error) { + console.error(error) + setLookupStatus("error") + setLookupMessage("Telegram user lookup failed. Check your connection and try again.") + } + } + + const submit = async (event: React.FormEvent) => { + event.preventDefault() + const since = new Date(validSince) + const until = new Date(validUntil) + if ( + !selectedUser || + !validSince || + !validUntil || + Number.isNaN(since.getTime()) || + Number.isNaN(until.getTime()) || + since < earliestGrantStart() || + until <= since || + pending + ) + return + + setPending(true) + try { + const result = await createTelegramGrant({ + data: { userId: selectedUser.id, since, until, reason: reason.trim() || undefined }, + }) + if (result.error) { + toast.error(grantMutationError(result.error)) + return + } + toast.success(`Grant created for ${displayName(selectedUser)}.`) + closeAndReset() + await router.invalidate({ sync: true }) + } catch (error) { + console.error(error) + toast.error("The grant could not be created. Check your permissions and try again.") + } finally { + setPending(false) + } + } + + const sinceTime = new Date(validSince).getTime() + const untilTime = new Date(validUntil).getTime() + const minimumSince = toDateTimeInput(earliestGrantStart()) + const invalidStart = Boolean(validSince) && (Number.isNaN(sinceTime) || sinceTime < earliestGrantStart().getTime()) + const invalidEnd = Boolean(validUntil) && (Number.isNaN(untilTime) || (Boolean(validSince) && untilTime <= sinceTime)) + const detailsUser = fixedUser ?? selectedUser + + return ( + + }> + Add grant + + + + New Grant + + + {!fixedUser && } + + {step === "user" ? ( +
{ + event.preventDefault() + void lookup() + }} + > + + + Find Telegram user + + Username + Telegram ID + + + + + {lookupBy === "username" ? "Telegram username" : "Numeric Telegram ID"} + +
+ { + setQuery(event.target.value) + setSelectedUser(null) + setLookupStatus("idle") + setLookupMessage("") + setDirty(true) + }} + type={lookupBy === "id" ? "text" : "search"} + inputMode={lookupBy === "id" ? "numeric" : undefined} + placeholder={lookupBy === "username" ? "@username" : "123456789"} + autoComplete="off" + disabled={lookupStatus === "loading"} + aria-invalid={lookupStatus === "error"} + aria-describedby="grant-user-status" + /> + +
+ {lookupStatus === "error" && {lookupMessage}} +
+
+ {(lookupStatus === "not-found" || selectedUser) && ( +
+ {lookupStatus === "not-found" && ( + + + User not found + {lookupMessage} + + )} + {selectedUser && ( + { + setSelectedUser(null) + setQuery("") + setDirty(true) + }} + /> + )} +
+ )} + + + + +
+ ) : ( +
void submit(event)}> + {!fixedUser && ( + + )} + {detailsUser && } + { + setValidSince(value) + setDirty(true) + }} + onValidUntilChange={(value) => { + setValidUntil(value) + setDirty(true) + }} + minimumSince={minimumSince} + minimumUntil={validSince || minimumSince} + invalidStart={invalidStart} + invalidEnd={invalidEnd} + /> + + + Motivation (optional) + +