@carlonicora/nextjs-jsonapi 3.25.0 → 3.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/{BlockNoteEditor-HKS6AXDA.js → BlockNoteEditor-4EIKMNY3.js} +20 -10
  2. package/dist/BlockNoteEditor-4EIKMNY3.js.map +1 -0
  3. package/dist/{BlockNoteEditor-46O3JC4Z.mjs → BlockNoteEditor-AXYVDDXM.mjs} +13 -3
  4. package/dist/{BlockNoteEditor-46O3JC4Z.mjs.map → BlockNoteEditor-AXYVDDXM.mjs.map} +1 -1
  5. package/dist/billing/index.js +335 -335
  6. package/dist/billing/index.mjs +1 -1
  7. package/dist/{chunk-Y3OKEQGL.js → chunk-ABVTYQIJ.js} +578 -363
  8. package/dist/chunk-ABVTYQIJ.js.map +1 -0
  9. package/dist/{chunk-KLV6RLDE.js → chunk-GKDHYB2R.js} +2 -2
  10. package/dist/chunk-GKDHYB2R.js.map +1 -0
  11. package/dist/{chunk-XDYILJ2S.mjs → chunk-QWZAGQWJ.mjs} +546 -331
  12. package/dist/chunk-QWZAGQWJ.mjs.map +1 -0
  13. package/dist/{chunk-4Z2UONN2.mjs → chunk-U65JPKO3.mjs} +2 -2
  14. package/dist/chunk-U65JPKO3.mjs.map +1 -0
  15. package/dist/client/index.js +2 -2
  16. package/dist/client/index.mjs +1 -1
  17. package/dist/components/index.d.mts +16 -3
  18. package/dist/components/index.d.ts +16 -3
  19. package/dist/components/index.js +4 -2
  20. package/dist/components/index.js.map +1 -1
  21. package/dist/components/index.mjs +3 -1
  22. package/dist/contexts/index.js +2 -2
  23. package/dist/contexts/index.mjs +1 -1
  24. package/dist/features/analytics/index.js +100 -100
  25. package/dist/features/analytics/index.js.map +1 -1
  26. package/dist/features/analytics/index.mjs +5 -5
  27. package/dist/features/analytics/index.mjs.map +1 -1
  28. package/dist/features/help/index.js +36 -36
  29. package/dist/features/help/index.js.map +1 -1
  30. package/dist/features/help/index.mjs +6 -6
  31. package/dist/features/help/index.mjs.map +1 -1
  32. package/dist/features/tokenusage/index.js +103 -103
  33. package/dist/features/tokenusage/index.js.map +1 -1
  34. package/dist/features/tokenusage/index.mjs +4 -4
  35. package/dist/features/tokenusage/index.mjs.map +1 -1
  36. package/package.json +1 -1
  37. package/src/components/containers/PageContainer.tsx +1 -1
  38. package/src/components/containers/RoundPageContainer.tsx +60 -16
  39. package/src/components/containers/RoundPageContainerTitle.tsx +75 -35
  40. package/src/components/editors/BlockNoteEditor.tsx +13 -1
  41. package/src/components/forms/EntityMultiSelector.tsx +4 -4
  42. package/src/components/forms/EntitySelector.tsx +4 -4
  43. package/src/components/forms/FormBlockNote.tsx +21 -1
  44. package/src/components/forms/FormCheckbox.tsx +6 -2
  45. package/src/components/forms/__tests__/useEditorDialog.test.ts +47 -0
  46. package/src/components/forms/useEditorDialog.ts +22 -0
  47. package/src/components/navigations/Breadcrumb.tsx +45 -16
  48. package/src/components/navigations/Header.tsx +16 -3
  49. package/src/components/navigations/MobileNavigationBar.tsx +12 -0
  50. package/src/components/navigations/__tests__/MobileNavigationBar.spec.tsx +17 -0
  51. package/src/components/tables/ContentListTable.tsx +52 -32
  52. package/src/features/analytics/components/AnalyticsAdminFilterBar.tsx +9 -6
  53. package/src/features/analytics/components/AnalyticsBreakdownTable.tsx +2 -1
  54. package/src/features/assistant/components/parts/AssistantBlockNoteComposer.tsx +1 -1
  55. package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
  56. package/src/features/assistant-message/components/MessageItem.tsx +2 -2
  57. package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +23 -5
  58. package/src/features/auth/components/containers/AuthContainer.tsx +8 -1
  59. package/src/features/company/components/details/CompanyContent.tsx +3 -1
  60. package/src/features/company/components/details/CompanyDetails.tsx +9 -1
  61. package/src/features/company/components/details/TokenStatusIndicator.tsx +42 -13
  62. package/src/features/help/components/HelpHeader.tsx +7 -5
  63. package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +9 -6
  64. package/src/features/tokenusage/lib/__tests__/formatters.spec.ts +21 -0
  65. package/src/features/tokenusage/lib/formatters.ts +5 -1
  66. package/src/shadcnui/ui/button.tsx +1 -1
  67. package/src/shadcnui/ui/checkbox.tsx +1 -1
  68. package/src/shadcnui/ui/command.tsx +4 -4
  69. package/src/shadcnui/ui/dialog.tsx +2 -0
  70. package/src/shadcnui/ui/input.tsx +1 -1
  71. package/src/shadcnui/ui/popover.tsx +9 -0
  72. package/src/shadcnui/ui/select.tsx +1 -1
  73. package/src/shadcnui/ui/sheet.tsx +8 -1
  74. package/src/shadcnui/ui/table.tsx +2 -2
  75. package/src/shadcnui/ui/textarea.tsx +1 -1
  76. package/dist/BlockNoteEditor-HKS6AXDA.js.map +0 -1
  77. package/dist/chunk-4Z2UONN2.mjs.map +0 -1
  78. package/dist/chunk-KLV6RLDE.js.map +0 -1
  79. package/dist/chunk-XDYILJ2S.mjs.map +0 -1
  80. package/dist/chunk-Y3OKEQGL.js.map +0 -1
@@ -15,9 +15,22 @@ type HeaderProps = {
15
15
  /** Rendered before everything else, on mobile only — where the sidebar (and its logo) is off-canvas. */
16
16
  logo?: React.ReactNode;
17
17
  className?: string;
18
+ /**
19
+ * Drop the sidebar toggle on mobile. Set by page shells that render the mobile
20
+ * bottom bar, whose "Menu" slot opens the same drawer — two controls for one
21
+ * drawer only cost header room on a phone. Desktop always keeps the toggle.
22
+ */
23
+ hideSidebarTriggerOnMobile?: boolean;
18
24
  };
19
25
 
20
- export function Header({ children, mobileChildren, leftContent, logo, className }: HeaderProps) {
26
+ export function Header({
27
+ children,
28
+ mobileChildren,
29
+ leftContent,
30
+ logo,
31
+ className,
32
+ hideSidebarTriggerOnMobile,
33
+ }: HeaderProps) {
21
34
  const { breadcrumbs } = useSharedContext();
22
35
  const rootLabel = useHeaderRootLabel();
23
36
  const isRootHidden = useIsBreadcrumbRootHidden();
@@ -42,9 +55,9 @@ export function Header({ children, mobileChildren, leftContent, logo, className
42
55
  >
43
56
  <div className="bg-sidebar flex h-12 w-full flex-row items-center justify-between ps-2 pe-4">
44
57
  {isMobile && logo && <div className="flex shrink-0 flex-row items-center pe-1">{logo}</div>}
45
- {sidebar && <SidebarTrigger aria-label="Toggle sidebar" id="sidebar-trigger" />}
58
+ {sidebar && !(isMobile && hideSidebarTriggerOnMobile) && <SidebarTrigger aria-label="Toggle sidebar" id="sidebar-trigger" />}
46
59
  {leftContent}
47
- <div className="flex w-full flex-row items-center justify-start">
60
+ <div className="flex w-full flex-row items-center justify-start max-md:min-w-0">
48
61
  <BreadcrumbNavigation items={breadcrumbs} rootLabel={rootLabel ?? undefined} showRoot={!isRootHidden} />
49
62
  </div>
50
63
  {isMobile
@@ -84,6 +84,18 @@ export function MobileNavigationBar({ className }: { className?: string } = {})
84
84
  </>
85
85
  );
86
86
 
87
+ // A slot with neither href nor onClick is self-contained: its `render`
88
+ // brings its own control (e.g. an avatar that opens a user menu), so it
89
+ // is not wrapped in a Link/button — that would nest one interactive
90
+ // element inside another.
91
+ if (!item.href && !item.onClick) {
92
+ return (
93
+ <div key={item.key} data-testid={`mobile-nav-${item.key}`} className={cn(slotClass, tone)}>
94
+ <SlotContent item={item} />
95
+ </div>
96
+ );
97
+ }
98
+
87
99
  return item.href ? (
88
100
  <Link
89
101
  key={item.key}
@@ -109,6 +109,23 @@ describe("MobileNavigationBar", () => {
109
109
  expect(screen.getByTestId("avatar-stub")).toBeInTheDocument();
110
110
  });
111
111
 
112
+ it("renders a slot with neither href nor onClick as-is, without wrapping it in a link or button", () => {
113
+ renderBar([
114
+ {
115
+ key: "profile",
116
+ label: "Account",
117
+ render: (
118
+ <button type="button" data-testid="menu-trigger">
119
+ Account
120
+ </button>
121
+ ),
122
+ },
123
+ ]);
124
+ const trigger = screen.getByTestId("menu-trigger");
125
+ expect(trigger.parentElement?.closest("button, a")).toBeNull();
126
+ expect(screen.getAllByRole("button")).toHaveLength(1);
127
+ });
128
+
112
129
  // The load-bearing case: a bare startsWith would make href="/" match everything.
113
130
  it("marks only the exactly-matching slot active when one href is '/'", () => {
114
131
  pathnameRef.current = "/conversations";
@@ -10,6 +10,7 @@ import React, { ReactNode, memo, useMemo, useState } from "react";
10
10
  import { DataListRetriever, TableContent, useTableGenerator } from "../../hooks";
11
11
  import { ModuleWithPermissions } from "../../permissions";
12
12
  import { Button, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "../../shadcnui";
13
+ import { useIsMobile } from "../../utils/use-mobile";
13
14
  import { MicroLabel } from "../typography";
14
15
  import { ContentTableSearch } from "./ContentTableSearch";
15
16
 
@@ -110,6 +111,7 @@ function renderCell(cellDef: unknown, context: unknown): ReactNode {
110
111
  export const ContentListTable = memo(function ContentListTable(props: ContentListTableProps) {
111
112
  const { data, fields, checkedIds, toggleId, allowSearch, filters: _filters, fullWidth, onRowClick } = props;
112
113
  const t = useTranslations();
114
+ const isMobile = useIsMobile();
113
115
  const [expanded, setExpanded] = useState<ExpandedState>(
114
116
  props.defaultExpanded === true ? true : typeof props.defaultExpanded === "object" ? props.defaultExpanded : {},
115
117
  );
@@ -212,13 +214,60 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
212
214
  // 12px title is the mismatch the opt-in exists to avoid.
213
215
  const titleIsLarge = (props.titleSize ?? (fullWidth ? `lg` : `sm`)) === `lg`;
214
216
 
217
+ const titleLabel = (
218
+ <div
219
+ className={cn(
220
+ "text-muted-foreground flex items-center gap-x-2 font-light whitespace-nowrap",
221
+ titleIsLarge ? `text-lg` : `text-sm`,
222
+ )}
223
+ >
224
+ {props.titleActions}
225
+ {props.tableGeneratorType.icon && (
226
+ <props.tableGeneratorType.icon className={cn(`text-primary`, titleIsLarge ? `h-6 w-6` : `h-4 w-4`)} />
227
+ )}
228
+ {props.title}
229
+ </div>
230
+ );
231
+
232
+ const hasTitleControls = !!(props.functions || props.filters || allowSearch);
233
+ const titleControls = (
234
+ <>
235
+ {props.filters}
236
+ {props.functions}
237
+ {/* `allowSearch !== false`, not `allowSearch`: the search box has
238
+ always rendered here whenever `functions`/`filters` were set, and
239
+ most callers rely on that without passing the flag. Gating on the
240
+ flag alone (as the sibling ContentListGrid does) would silently
241
+ remove search from every such list. This only lets a caller whose
242
+ retriever ignores `search` — e.g. notifications, whose repository
243
+ has no search param and no fulltext index — opt out of a control
244
+ that would do nothing. */}
245
+ {allowSearch !== false && <ContentTableSearch data={data} />}
246
+ </>
247
+ );
248
+
215
249
  return (
216
250
  <div data-help="list.table" className="flex w-full flex-col">
217
251
  {/* <div className="overflow-clip rounded-md border"> */}
218
252
  <div className={cn(`overflow-clip`, fullWidth ? `` : `rounded-md border`)}>
253
+ {/* On a phone the title bar sits above the table instead of inside its
254
+ header: inside, it is as wide as the table, so on a table wider than
255
+ the screen "Create New", search and filters land off-screen. Above it,
256
+ it wraps within the card while the table scrolls sideways on its own. */}
257
+ {props.title && isMobile && (
258
+ <div
259
+ data-testid="content-list-table-title-bar"
260
+ className="bg-card text-primary flex flex-wrap items-center gap-2 border-b p-4 font-bold"
261
+ >
262
+ <div className="shrink-0">{titleLabel}</div>
263
+ {hasTitleControls && (
264
+ <div className="ms-auto flex flex-wrap items-center justify-end gap-2">{titleControls}</div>
265
+ )}
266
+ </div>
267
+ )}
219
268
  <Table>
220
269
  <TableHeader className="bg-muted font-semibold">
221
- {props.title && (
270
+ {props.title && !isMobile && (
222
271
  <TableRow>
223
272
  <TableHead
224
273
  className="bg-card rounded-t-lg text-primary p-4 text-start font-bold"
@@ -226,37 +275,8 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
226
275
  >
227
276
  <div className="flex w-full items-center justify-between gap-x-2">
228
277
  {/* <div className="w-full">{fullWidth ? `` : props.title}</div> */}
229
- <div className="w-full">
230
- <div
231
- className={cn(
232
- "text-muted-foreground flex items-center gap-x-2 font-light whitespace-nowrap",
233
- titleIsLarge ? `text-lg` : `text-sm`,
234
- )}
235
- >
236
- {props.titleActions}
237
- {props.tableGeneratorType.icon && (
238
- <props.tableGeneratorType.icon
239
- className={cn(`text-primary`, titleIsLarge ? `h-6 w-6` : `h-4 w-4`)}
240
- />
241
- )}
242
- {props.title}
243
- </div>
244
- </div>
245
- {(props.functions || props.filters || allowSearch) && (
246
- <>
247
- {props.filters}
248
- {props.functions}
249
- {/* `allowSearch !== false`, not `allowSearch`: the search box has
250
- always rendered here whenever `functions`/`filters` were set, and
251
- most callers rely on that without passing the flag. Gating on the
252
- flag alone (as the sibling ContentListGrid does) would silently
253
- remove search from every such list. This only lets a caller whose
254
- retriever ignores `search` — e.g. notifications, whose repository
255
- has no search param and no fulltext index — opt out of a control
256
- that would do nothing. */}
257
- {allowSearch !== false && <ContentTableSearch data={data} />}
258
- </>
259
- )}
278
+ <div className="w-full">{titleLabel}</div>
279
+ {hasTitleControls && titleControls}
260
280
  </div>
261
281
  </TableHead>
262
282
  </TableRow>
@@ -48,12 +48,15 @@ export function AnalyticsAdminFilterBar({ section, granularity, onChange }: Prop
48
48
 
49
49
  return (
50
50
  <div className="flex flex-wrap items-center gap-2">
51
- <DateRangeSelector
52
- onDateChange={(range) => {
53
- if (!range?.from || !range?.to) return;
54
- onChange({ from: range.from.toISOString(), to: range.to.toISOString() });
55
- }}
56
- />
51
+ {/* The date button is a fixed 300px; on a phone it takes the full row instead. */}
52
+ <div className="max-md:w-full max-md:[&_#date]:w-full">
53
+ <DateRangeSelector
54
+ onDateChange={(range) => {
55
+ if (!range?.from || !range?.to) return;
56
+ onChange({ from: range.from.toISOString(), to: range.to.toISOString() });
57
+ }}
58
+ />
59
+ </div>
57
60
 
58
61
  <Segmented
59
62
  ariaLabel={t("analytics.admin.filter.section")}
@@ -48,7 +48,8 @@ export function AnalyticsBreakdownTable({ breakdowns }: Props) {
48
48
 
49
49
  return (
50
50
  <Tabs defaultValue={ANALYTICS_DIMENSIONS[0]}>
51
- <TabsList>
51
+ {/* Six tabs are wider than a phone-width card: scroll them sideways there. */}
52
+ <TabsList className="max-md:w-full max-md:justify-start max-md:overflow-x-auto">
52
53
  {ANALYTICS_DIMENSIONS.map((dimension) => (
53
54
  <TabsTrigger key={dimension} value={dimension}>
54
55
  {labels[dimension]}
@@ -129,7 +129,7 @@ export function AssistantBlockNoteComposer({
129
129
  <ArrowUp className="me-1 h-4 w-4" /> {t("features.assistant.send")}
130
130
  </Button>
131
131
  </div>
132
- <div className="text-muted-foreground text-end text-xs">{t("features.assistant.keyboard_hint")}</div>
132
+ <div className="text-muted-foreground text-end text-xs max-md:hidden">{t("features.assistant.keyboard_hint")}</div>
133
133
  </div>
134
134
  );
135
135
  }
@@ -92,7 +92,7 @@ export function AssistantComposer({
92
92
  {sendButton}
93
93
  </div>
94
94
  )}
95
- <div className="text-muted-foreground text-end text-xs">{t("features.assistant.keyboard_hint")}</div>
95
+ <div className="text-muted-foreground text-end text-xs max-md:hidden">{t("features.assistant.keyboard_hint")}</div>
96
96
  </div>
97
97
  );
98
98
  }
@@ -82,7 +82,7 @@ export function MessageItem({
82
82
  renders invisible here, so force the on-primary colour and lean on
83
83
  weight for the affordance (links are never underlined in this UI).
84
84
  */}
85
- <div className="bg-primary text-primary-foreground max-w-[72%] rounded-2xl rounded-ee-sm px-3.5 py-2 text-sm [&_a]:text-primary-foreground [&_a]:font-semibold [&_p]:m-0">
85
+ <div className="bg-primary text-primary-foreground max-w-[72%] max-md:max-w-[92%] rounded-2xl rounded-ee-sm px-3.5 py-2 text-sm [&_a]:text-primary-foreground [&_a]:font-semibold [&_p]:m-0">
86
86
  <ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={mentionUrlTransform} components={markdownComponents}>
87
87
  {message.content}
88
88
  </ReactMarkdown>
@@ -103,7 +103,7 @@ export function MessageItem({
103
103
  const isApprovalRequest = message.messageType === "approval-request" && !!renderApprovalAction;
104
104
 
105
105
  return (
106
- <div className="flex min-w-0 max-w-[78%] flex-col gap-1.5">
106
+ <div className="flex min-w-0 max-w-[78%] max-md:max-w-[92%] flex-col gap-1.5">
107
107
  <div className="text-muted-foreground flex items-center gap-2 ps-1 text-xs">
108
108
  <span className="flex h-4 w-4 items-center justify-center rounded-full bg-gradient-to-br from-blue-400 to-violet-500 text-white">
109
109
  <Sparkles className="h-2.5 w-2.5" />
@@ -9,6 +9,8 @@ import type { ApiDataInterface } from "../../../../../core";
9
9
  import type { ChunkInterface, ChunkRelationshipMeta } from "../../../../chunk/data/ChunkInterface";
10
10
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../../../shadcnui/ui/table";
11
11
  import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../../shadcnui/ui/tooltip";
12
+ import { Popover, PopoverContent, PopoverTrigger } from "../../../../../shadcnui/ui/popover";
13
+ import { useIsMobile } from "../../../../../utils/use-mobile";
12
14
  import { cn } from "@/lib/utils";
13
15
  import { RelevanceMeter } from "../RelevanceMeter";
14
16
  import { useEntityLabel } from "./useEntityLabel";
@@ -26,6 +28,8 @@ interface Props {
26
28
  export function CitationsTab({ citations, sources }: Props) {
27
29
  const t = useTranslations();
28
30
  const entityLabel = useEntityLabel();
31
+ // A tooltip never opens on touch, so on a phone the reason is a tap popover.
32
+ const isMobile = useIsMobile();
29
33
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
30
34
  if (citations.length === 0) return null;
31
35
 
@@ -45,7 +49,7 @@ export function CitationsTab({ citations, sources }: Props) {
45
49
  <TableHeader>
46
50
  <TableRow>
47
51
  <TableHead>{t("features.assistant.message.sources.source")}</TableHead>
48
- <TableHead className="w-28 text-center">{t("features.assistant.message.sources.relevance")}</TableHead>
52
+ <TableHead className="w-28 text-center max-md:w-20">{t("features.assistant.message.sources.relevance")}</TableHead>
49
53
  </TableRow>
50
54
  </TableHeader>
51
55
  <TableBody>
@@ -76,9 +80,23 @@ export function CitationsTab({ citations, sources }: Props) {
76
80
  aria-expanded={isOpen}
77
81
  className="flex w-full cursor-pointer items-center justify-start gap-x-2"
78
82
  >
79
- <ChevronDown className={cn("h-4 w-4 transition-transform", !isOpen && "-rotate-90")} />
80
- <span className="font-semibold">{sourceName}</span>
81
- {chunk.reason && (
83
+ <ChevronDown
84
+ className={cn("h-4 w-4 transition-transform max-md:shrink-0", !isOpen && "-rotate-90")}
85
+ />
86
+ <span className="font-semibold max-md:min-w-0 max-md:truncate">{sourceName}</span>
87
+ {chunk.reason && isMobile && (
88
+ <Popover>
89
+ <PopoverTrigger
90
+ aria-label={chunk.reason}
91
+ onClick={(e) => e.stopPropagation()}
92
+ className="text-muted-foreground -m-2 inline-flex shrink-0 p-2"
93
+ >
94
+ <HelpCircle className="h-3.5 w-3.5" />
95
+ </PopoverTrigger>
96
+ <PopoverContent className="max-w-64 text-xs">{chunk.reason}</PopoverContent>
97
+ </Popover>
98
+ )}
99
+ {chunk.reason && !isMobile && (
82
100
  <Tooltip>
83
101
  <TooltipTrigger className="text-muted-foreground inline-flex">
84
102
  <HelpCircle className="h-3.5 w-3.5" />
@@ -89,7 +107,7 @@ export function CitationsTab({ citations, sources }: Props) {
89
107
  </div>
90
108
  </TableCell>
91
109
  <TableCell className="text-center">
92
- <RelevanceMeter value={chunk.relevance ?? 0} />
110
+ <RelevanceMeter value={chunk.relevance ?? 0} className="max-md:w-16" />
93
111
  </TableCell>
94
112
  </TableRow>
95
113
  {isOpen && (
@@ -28,5 +28,12 @@ function InnerAuthContainer() {
28
28
  </div>
29
29
  );
30
30
 
31
- return <Card className="w-full max-w-md">{activeComponent}</Card>;
31
+ // Phone: 40px buttons and inputs on the auth forms (login, register, reset…)
32
+ // so they are comfortable to tap. Input-group buttons (show password) carry
33
+ // data-size and keep their own size. md+ is unchanged.
34
+ return (
35
+ <Card className="w-full max-w-md max-md:[&_[data-slot=button]:not([data-size])]:h-10 max-md:[&_[data-slot=input-group-control]]:h-full max-md:[&_[data-slot=input-group]]:h-10 max-md:[&_[data-slot=input]]:h-10">
36
+ {activeComponent}
37
+ </Card>
38
+ );
32
39
  }
@@ -7,6 +7,7 @@ import { ReactNode } from "react";
7
7
  import { AttributeElement } from "../../../../components";
8
8
  import { SectionHeader, MicroLabel } from "../../../../components/typography";
9
9
  import { FiscalDataDisplay } from "../../../../components/fiscal/FiscalDataDisplay";
10
+ import { cn } from "../../../../utils";
10
11
  import { CompanyInterface } from "../../data";
11
12
 
12
13
  type CompanyContentProps = {
@@ -34,7 +35,8 @@ export function CompanyContent({ company, actions }: CompanyContentProps) {
34
35
  return (
35
36
  <div className="flex flex-col gap-y-8">
36
37
  {/* Title Row */}
37
- <div className="flex w-full items-center justify-between">
38
+ {/* Without actions the row only repeats the name the page header shows; hide it on a phone. */}
39
+ <div className={cn("flex w-full items-center justify-between", !actions && "max-md:hidden")}>
38
40
  <SectionHeader level={2}>{company.name}</SectionHeader>
39
41
  {actions && <div className="flex items-center gap-x-2">{actions}</div>}
40
42
  </div>
@@ -17,7 +17,15 @@ export function CompanyDetails() {
17
17
 
18
18
  return (
19
19
  <div className="flex w-full flex-col gap-y-2">
20
- <ContentTitle module={Modules.Company} type={title.type} element={title.element} functions={title.functions} />
20
+ {/* On a phone the page header already shows the type, the name and the
21
+ commands, so this inner copy is hidden there. */}
22
+ <ContentTitle
23
+ module={Modules.Company}
24
+ type={title.type}
25
+ element={title.element}
26
+ functions={title.functions}
27
+ className="max-md:hidden"
28
+ />
21
29
  <TokenStatusIndicator size="md" />
22
30
  <CompanyContent company={company} />
23
31
  </div>
@@ -12,6 +12,12 @@ interface TokenStatusIndicatorProps {
12
12
  className?: string;
13
13
  size?: "sm" | "md" | "lg";
14
14
  showExtraPages?: boolean;
15
+ /**
16
+ * Render the indicator as a link to this URL instead of a hover tooltip.
17
+ * For touch surfaces (the mobile header), where a hover tooltip never opens:
18
+ * the link carries a 40px tap area and leads to the usage page.
19
+ */
20
+ href?: string;
15
21
  }
16
22
 
17
23
  /**
@@ -36,7 +42,12 @@ const asWholeCredits = (value: number): string => Math.trunc(value).toLocaleStri
36
42
  * - BatteryLow: 5-25% available
37
43
  * - Battery (empty): <5% available
38
44
  */
39
- export function TokenStatusIndicator({ className, size = "md", showExtraPages = true }: TokenStatusIndicatorProps) {
45
+ export function TokenStatusIndicator({
46
+ className,
47
+ size = "md",
48
+ showExtraPages = true,
49
+ href,
50
+ }: TokenStatusIndicatorProps) {
40
51
  const { company } = useCurrentUserContext();
41
52
  const t = useTranslations();
42
53
 
@@ -131,6 +142,35 @@ export function TokenStatusIndicator({ className, size = "md", showExtraPages =
131
142
  </div>
132
143
  );
133
144
 
145
+ const badge = (
146
+ <>
147
+ {getBatteryIcon()}
148
+ <span className={cn(textSize, "text-muted-foreground font-medium leading-none")}>
149
+ {asWholeCredits(availableMonthlyCredits)}
150
+ </span>
151
+ {showExtraPages && availableExtraCredits > 0 && (
152
+ <span className="inline-flex items-center gap-0.5">
153
+ <PlusCircle className={cn(smallIconSize, "text-blue-500")} />
154
+ <span className={cn(textSize, "text-blue-500 font-medium leading-none")}>
155
+ {asWholeCredits(availableExtraCredits)}
156
+ </span>
157
+ </span>
158
+ )}
159
+ </>
160
+ );
161
+
162
+ if (href) {
163
+ return (
164
+ <Link
165
+ href={href}
166
+ className={cn("inline-flex min-h-10 min-w-10 items-center justify-center gap-1.5 px-1", className)}
167
+ aria-label={t("billing.tokens.status", { defaultValue: "Page Status" })}
168
+ >
169
+ {badge}
170
+ </Link>
171
+ );
172
+ }
173
+
134
174
  return (
135
175
  <Tooltip>
136
176
  {/* The layout classes live on the trigger itself, which renders the
@@ -142,18 +182,7 @@ export function TokenStatusIndicator({ className, size = "md", showExtraPages =
142
182
  className={cn("inline-flex items-center gap-1.5 cursor-default", className)}
143
183
  aria-label={t("billing.tokens.status", { defaultValue: "Page Status" })}
144
184
  >
145
- {getBatteryIcon()}
146
- <span className={cn(textSize, "text-muted-foreground font-medium leading-none")}>
147
- {asWholeCredits(availableMonthlyCredits)}
148
- </span>
149
- {showExtraPages && availableExtraCredits > 0 && (
150
- <span className="inline-flex items-center gap-0.5">
151
- <PlusCircle className={cn(smallIconSize, "text-blue-500")} />
152
- <span className={cn(textSize, "text-blue-500 font-medium leading-none")}>
153
- {asWholeCredits(availableExtraCredits)}
154
- </span>
155
- </span>
156
- )}
185
+ {badge}
157
186
  </TooltipTrigger>
158
187
  <TooltipContent side="top" className="max-w-xs">
159
188
  {tooltipContent}
@@ -16,14 +16,16 @@ export function HelpHeader() {
16
16
  const appHref = brand?.appHref ?? "/";
17
17
 
18
18
  return (
19
- <header className="border-border bg-background flex items-center justify-between border-b px-4 py-3">
20
- <Link href="/help" className="flex items-center gap-2">
21
- {logo ? <Image src={logo} alt={label} width={28} height={28} /> : null}
22
- <span className="text-base font-semibold">
19
+ <header className="border-border bg-background flex items-center justify-between border-b px-4 py-3 max-md:gap-2">
20
+ {/* Phone: the title shrinks and the "· Help & Documentation" suffix
21
+ truncates with an ellipsis instead of wrapping onto a second line. */}
22
+ <Link href="/help" className="flex items-center gap-2 max-md:min-w-0">
23
+ {logo ? <Image src={logo} alt={label} width={28} height={28} className="max-md:shrink-0" /> : null}
24
+ <span className="text-base font-semibold max-md:truncate">
23
25
  {label} · {t("help.footerLink")}
24
26
  </span>
25
27
  </Link>
26
- <div className="flex items-center gap-2">
28
+ <div className="flex items-center gap-2 max-md:shrink-0">
27
29
  <HelpAskAi />
28
30
  {/* Styled Links, not <Button render={<Link/>} nativeButton={false}>. Base UI's
29
31
  Button primitive warns when rendered as a non-button, and nativeButton={false}
@@ -41,12 +41,15 @@ export function TokenUsageAdminFilterBar({ granularity, companyId, metric, compa
41
41
 
42
42
  return (
43
43
  <div className="flex flex-wrap items-center gap-2">
44
- <DateRangeSelector
45
- onDateChange={(range) => {
46
- if (!range?.from || !range?.to) return;
47
- onChange({ from: range.from.toISOString(), to: range.to.toISOString() });
48
- }}
49
- />
44
+ {/* The date button is a fixed 300px; on a phone it takes the full row instead. */}
45
+ <div className="max-md:w-full max-md:[&_#date]:w-full">
46
+ <DateRangeSelector
47
+ onDateChange={(range) => {
48
+ if (!range?.from || !range?.to) return;
49
+ onChange({ from: range.from.toISOString(), to: range.to.toISOString() });
50
+ }}
51
+ />
52
+ </div>
50
53
 
51
54
  <Segmented
52
55
  ariaLabel={t("token_usage.admin.granularity.label")}
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createUsageFormatters } from "../formatters";
3
+
4
+ describe("createUsageFormatters.compact", () => {
5
+ const { compact } = createUsageFormatters("en-US", "EUR");
6
+
7
+ it("keeps sub-unit axis ticks distinct", () => {
8
+ const ticks = [0, 0.05, 0.1, 0.15, 0.2, 0.25];
9
+ const labels = ticks.map(compact);
10
+ expect(labels).toEqual(["0", "0.05", "0.1", "0.15", "0.2", "0.25"]);
11
+ expect(new Set(labels).size).toBe(ticks.length);
12
+ });
13
+
14
+ it("keeps thousands ticks distinct and exact", () => {
15
+ expect([0, 250, 500, 750, 1000, 1250].map(compact)).toEqual(["0", "250", "500", "750", "1K", "1.25K"]);
16
+ });
17
+
18
+ it("still abbreviates large values", () => {
19
+ expect(compact(1500000)).toBe("1.5M");
20
+ });
21
+ });
@@ -50,7 +50,11 @@ export function createUsageFormatters(locale: string, currency: string): UsageFo
50
50
 
51
51
  const dayFormat = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", timeZone: "UTC" });
52
52
  const monthFormat = new Intl.DateTimeFormat(locale, { month: "short", year: "numeric", timeZone: "UTC" });
53
- const compactFormat = new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 });
53
+ // Axis ticks step by 1, 2, 2.5 or 5 times a power of ten, so a tick never needs
54
+ // more than three significant digits. A fixed one-decimal cap rounded a 0.05
55
+ // step into repeated labels (0.1, 0.1, 0.2, 0.2); significant digits keep every
56
+ // tick distinct at any scale (0.05, 0.15, 1.25K).
57
+ const compactFormat = new Intl.NumberFormat(locale, { notation: "compact", maximumSignificantDigits: 3 });
54
58
  const collator = new Intl.Collator(locale);
55
59
 
56
60
  return {
@@ -21,7 +21,7 @@ const buttonVariants = cva(
21
21
  },
22
22
  size: {
23
23
  default:
24
- "h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
24
+ "h-7 max-md:h-9 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
25
25
  xs: "h-5 gap-1 rounded-sm px-2 text-xs/relaxed has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-2.5",
26
26
  sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
27
27
  lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2 [&_svg:not([class*='size-'])]:size-4",
@@ -10,7 +10,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
10
10
  <CheckboxPrimitive.Root
11
11
  data-slot="checkbox"
12
12
  className={cn(
13
- "border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-shadow group-has-disabled/field:opacity-50 focus-visible:ring-[2px] aria-invalid:ring-[2px] peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 cursor-pointer data-disabled:cursor-not-allowed data-disabled:opacity-50",
13
+ "border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-shadow group-has-disabled/field:opacity-50 focus-visible:ring-[2px] aria-invalid:ring-[2px] peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 max-md:after:-inset-y-3 cursor-pointer data-disabled:cursor-not-allowed data-disabled:opacity-50",
14
14
  className,
15
15
  )}
16
16
  {...props}
@@ -21,7 +21,7 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
21
21
  <CommandPrimitive
22
22
  data-slot="command"
23
23
  className={cn(
24
- "bg-popover text-popover-foreground rounded-xl p-1 flex size-full flex-col overflow-hidden",
24
+ "bg-popover text-popover-foreground rounded-xl p-1 flex size-full flex-col overflow-hidden max-md:min-h-0",
25
25
  "[[data-slot=dialog-content]_&]:bg-transparent [[data-slot=dialog-content]_&]:text-foreground",
26
26
  className,
27
27
  )}
@@ -75,11 +75,11 @@ function CommandDialog({
75
75
  function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
76
76
  return (
77
77
  <div data-slot="command-input-wrapper" className="p-1 pb-0">
78
- <InputGroup className="bg-input/20 dark:bg-input/30 h-8!">
78
+ <InputGroup className="bg-input/20 dark:bg-input/30 h-8! max-md:h-10!">
79
79
  <CommandPrimitive.Input
80
80
  data-slot="command-input"
81
81
  className={cn(
82
- "w-full text-xs/relaxed outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
82
+ "w-full text-xs/relaxed max-md:text-base outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
83
83
  className,
84
84
  )}
85
85
  {...props}
@@ -96,7 +96,7 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
96
96
  return (
97
97
  <CommandPrimitive.List
98
98
  data-slot="command-list"
99
- className={cn("no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto", className)}
99
+ className={cn("no-scrollbar max-h-72 max-md:min-h-0 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto", className)}
100
100
  {...props}
101
101
  />
102
102
  );
@@ -52,6 +52,8 @@ function DialogContent({
52
52
  className={cn(
53
53
  // rtl-ok: symmetric centering (`left-1/2` + `-translate-x-1/2`) — identical in LTR and RTL
54
54
  "bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid gap-4 rounded-xl p-4 text-xs/relaxed ring-1 duration-100 fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 outline-none",
55
+ // Phone: never wider or taller than the screen; the dialog scrolls inside.
56
+ "max-md:max-h-[calc(100svh-2rem)] max-md:max-w-[calc(100vw-2rem)] max-md:overflow-y-auto",
55
57
  className,
56
58
  )}
57
59
  {...props}
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
9
9
  type={type}
10
10
  data-slot="input"
11
11
  className={cn(
12
- "bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed 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",
12
+ "bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 max-md:h-10 rounded-md border px-2 py-0.5 text-base transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed 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",
13
13
  className,
14
14
  )}
15
15
  {...props}