@carlonicora/nextjs-jsonapi 1.134.0 → 1.135.1

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 (50) hide show
  1. package/dist/{BlockNoteEditor-3IHMNBZ2.mjs → BlockNoteEditor-FAPCNKXZ.mjs} +2 -2
  2. package/dist/{BlockNoteEditor-2WOKHBLM.js → BlockNoteEditor-PYRHKBF3.js} +9 -9
  3. package/dist/{BlockNoteEditor-2WOKHBLM.js.map → BlockNoteEditor-PYRHKBF3.js.map} +1 -1
  4. package/dist/billing/index.js +310 -310
  5. package/dist/billing/index.js.map +1 -1
  6. package/dist/billing/index.mjs +2 -2
  7. package/dist/billing/index.mjs.map +1 -1
  8. package/dist/{chunk-LBXHYRZH.js → chunk-DNWYHP4V.js} +283 -193
  9. package/dist/chunk-DNWYHP4V.js.map +1 -0
  10. package/dist/{chunk-DV5YYI3G.mjs → chunk-EF2HVXOV.mjs} +1698 -1608
  11. package/dist/chunk-EF2HVXOV.mjs.map +1 -0
  12. package/dist/client/index.js +2 -2
  13. package/dist/client/index.mjs +1 -1
  14. package/dist/components/index.d.mts +12 -1
  15. package/dist/components/index.d.ts +12 -1
  16. package/dist/components/index.js +4 -2
  17. package/dist/components/index.js.map +1 -1
  18. package/dist/components/index.mjs +3 -1
  19. package/dist/contexts/index.d.mts +44 -2
  20. package/dist/contexts/index.d.ts +44 -2
  21. package/dist/contexts/index.js +6 -2
  22. package/dist/contexts/index.js.map +1 -1
  23. package/dist/contexts/index.mjs +5 -1
  24. package/dist/features/help/index.js +31 -31
  25. package/dist/features/help/index.mjs +1 -1
  26. package/package.json +1 -1
  27. package/src/components/containers/RoundPageContainer.tsx +15 -5
  28. package/src/components/navigations/MobileNavigationBar.tsx +99 -0
  29. package/src/components/navigations/__tests__/MobileNavigationBar.spec.tsx +131 -0
  30. package/src/components/navigations/index.ts +1 -0
  31. package/src/contexts/MobileNavigationContext.tsx +66 -0
  32. package/src/contexts/index.ts +1 -0
  33. package/src/features/assistant/components/parts/AssistantEmptyState.tsx +1 -1
  34. package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +1 -1
  35. package/src/shadcnui/ui/__tests__/cursor-controls.test.tsx +109 -0
  36. package/src/shadcnui/ui/__tests__/cursor-menu-items.test.tsx +89 -0
  37. package/src/shadcnui/ui/button.tsx +1 -1
  38. package/src/shadcnui/ui/checkbox.tsx +1 -1
  39. package/src/shadcnui/ui/combobox.tsx +1 -1
  40. package/src/shadcnui/ui/command.tsx +1 -1
  41. package/src/shadcnui/ui/context-menu.tsx +4 -4
  42. package/src/shadcnui/ui/dropdown-menu.tsx +4 -4
  43. package/src/shadcnui/ui/label.tsx +1 -1
  44. package/src/shadcnui/ui/radio-group.tsx +1 -1
  45. package/src/shadcnui/ui/select.tsx +3 -3
  46. package/src/shadcnui/ui/slider.tsx +3 -2
  47. package/src/shadcnui/ui/switch.tsx +1 -1
  48. package/dist/chunk-DV5YYI3G.mjs.map +0 -1
  49. package/dist/chunk-LBXHYRZH.js.map +0 -1
  50. /package/dist/{BlockNoteEditor-3IHMNBZ2.mjs.map → BlockNoteEditor-FAPCNKXZ.mjs.map} +0 -0
@@ -22,7 +22,7 @@ var _chunkYBZVWLPHjs = require('../../chunk-YBZVWLPH.js');
22
22
 
23
23
 
24
24
 
25
- var _chunkLBXHYRZHjs = require('../../chunk-LBXHYRZH.js');
25
+ var _chunkDNWYHP4Vjs = require('../../chunk-DNWYHP4V.js');
26
26
 
27
27
 
28
28
  var _chunkC2LEIBUKjs = require('../../chunk-C2LEIBUK.js');
@@ -74,29 +74,29 @@ var _lucidereact = require('lucide-react');
74
74
 
75
75
 
76
76
  function HelpAssistantSheet({ open, onOpenChange }) {
77
- return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.Sheet, { open, onOpenChange, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
78
- _chunkLBXHYRZHjs.SheetContent,
77
+ return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.Sheet, { open, onOpenChange, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
78
+ _chunkDNWYHP4Vjs.SheetContent,
79
79
  {
80
80
  side: "right",
81
81
  className: "flex w-full flex-col data-[side=right]:sm:max-w-2xl data-[side=right]:lg:max-w-3xl",
82
- children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.AssistantProvider, { manageUrl: false, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, HelpAssistantSheetBody, {}) })
82
+ children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.AssistantProvider, { manageUrl: false, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, HelpAssistantSheetBody, {}) })
83
83
  }
84
84
  ) });
85
85
  }
86
86
  _chunk7QVYU63Ejs.__name.call(void 0, HelpAssistantSheet, "HelpAssistantSheet");
87
87
  function HelpAssistantSheetBody() {
88
88
  const t = _nextintl.useTranslations.call(void 0, );
89
- const ctx = _chunkLBXHYRZHjs.useAssistantContext.call(void 0, );
89
+ const ctx = _chunkDNWYHP4Vjs.useAssistantContext.call(void 0, );
90
90
  const showThread = !!ctx.assistant || ctx.sending || ctx.messages.length > 0;
91
91
  const send = /* @__PURE__ */ _chunk7QVYU63Ejs.__name.call(void 0, (content) => ctx.sendMessage(content, { howToMode: true }), "send");
92
92
  return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _jsxruntime.Fragment, { children: [
93
- /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkLBXHYRZHjs.SheetHeader, { children: [
94
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.SheetTitle, { children: t("help.askAi.sheet.title") }),
95
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.SheetDescription, { children: t("help.askAi.sheet.subtitle") })
93
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkDNWYHP4Vjs.SheetHeader, { children: [
94
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.SheetTitle, { children: t("help.askAi.sheet.title") }),
95
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.SheetDescription, { children: t("help.askAi.sheet.subtitle") })
96
96
  ] }),
97
- !showThread ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.AssistantEmptyState, { onSend: send }) : /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _jsxruntime.Fragment, { children: [
97
+ !showThread ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.AssistantEmptyState, { onSend: send }) : /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _jsxruntime.Fragment, { children: [
98
98
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
99
- _chunkLBXHYRZHjs.AssistantThread,
99
+ _chunkDNWYHP4Vjs.AssistantThread,
100
100
  {
101
101
  messages: ctx.messages,
102
102
  sending: ctx.sending,
@@ -106,7 +106,7 @@ function HelpAssistantSheetBody() {
106
106
  onRetry: ctx.retrySend
107
107
  }
108
108
  ),
109
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.AssistantComposer, { onSend: send, disabled: ctx.sending })
109
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.AssistantComposer, { onSend: send, disabled: ctx.sending })
110
110
  ] })
111
111
  ] });
112
112
  }
@@ -116,20 +116,20 @@ _chunk7QVYU63Ejs.__name.call(void 0, HelpAssistantSheetBody, "HelpAssistantSheet
116
116
 
117
117
  function HelpAskAi() {
118
118
  const t = _nextintl.useTranslations.call(void 0, );
119
- const { currentUser } = _chunkLBXHYRZHjs.useCurrentUserContext.call(void 0, );
119
+ const { currentUser } = _chunkDNWYHP4Vjs.useCurrentUserContext.call(void 0, );
120
120
  const [open, setOpen] = _react.useState.call(void 0, false);
121
121
  const disabled = !currentUser;
122
122
  if (disabled) {
123
- return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkLBXHYRZHjs.Tooltip, { children: [
124
- /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkLBXHYRZHjs.TooltipTrigger, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.Button, { variant: "outline", size: "sm", disabled: true }), children: [
123
+ return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkDNWYHP4Vjs.Tooltip, { children: [
124
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkDNWYHP4Vjs.TooltipTrigger, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.Button, { variant: "outline", size: "sm", disabled: true }), children: [
125
125
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.SparklesIcon, { className: "h-4 w-4" }),
126
126
  t("help.askAi.button")
127
127
  ] }),
128
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.TooltipContent, { children: t("help.askAi.loginTooltip") })
128
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.TooltipContent, { children: t("help.askAi.loginTooltip") })
129
129
  ] });
130
130
  }
131
131
  return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _jsxruntime.Fragment, { children: [
132
- /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkLBXHYRZHjs.Button, { variant: "outline", size: "sm", onClick: () => setOpen(true), children: [
132
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkDNWYHP4Vjs.Button, { variant: "outline", size: "sm", onClick: () => setOpen(true), children: [
133
133
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.SparklesIcon, { className: "h-4 w-4" }),
134
134
  t("help.askAi.button")
135
135
  ] }),
@@ -142,7 +142,7 @@ _chunk7QVYU63Ejs.__name.call(void 0, HelpAskAi, "HelpAskAi");
142
142
 
143
143
  function HelpHeader() {
144
144
  const t = _nextintl.useTranslations.call(void 0, );
145
- const { currentUser } = _chunkLBXHYRZHjs.useCurrentUserContext.call(void 0, );
145
+ const { currentUser } = _chunkDNWYHP4Vjs.useCurrentUserContext.call(void 0, );
146
146
  const { brand } = useHelp();
147
147
  const logo = _optionalChain([brand, 'optionalAccess', _ => _.logo]);
148
148
  const label = _nullishCoalesce(_optionalChain([brand, 'optionalAccess', _2 => _2.label]), () => ( "Help"));
@@ -158,7 +158,7 @@ function HelpHeader() {
158
158
  ] }),
159
159
  /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: "flex items-center gap-2", children: [
160
160
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, HelpAskAi, {}),
161
- currentUser ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.Button, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _link2.default, { href: appHref }), nativeButton: false, variant: "outline", size: "sm", children: t("help.header.openApp") }) : /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.Button, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _link2.default, { href: "/login" }), nativeButton: false, variant: "outline", size: "sm", children: t("help.header.login") })
161
+ currentUser ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.Button, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _link2.default, { href: appHref }), nativeButton: false, variant: "outline", size: "sm", children: t("help.header.openApp") }) : /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.Button, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _link2.default, { href: "/login" }), nativeButton: false, variant: "outline", size: "sm", children: t("help.header.login") })
162
162
  ] })
163
163
  ] });
164
164
  }
@@ -200,7 +200,7 @@ _chunk7QVYU63Ejs.__name.call(void 0, useHelpFilter, "useHelpFilter");
200
200
 
201
201
  function HelpSideNav() {
202
202
  const t = _nextintl.useTranslations.call(void 0, );
203
- const generateUrl = _chunkLBXHYRZHjs.usePageUrlGenerator.call(void 0, );
203
+ const generateUrl = _chunkDNWYHP4Vjs.usePageUrlGenerator.call(void 0, );
204
204
  const pathname = _navigation.usePathname.call(void 0, );
205
205
  const [articles, setArticles] = _react.useState.call(void 0, []);
206
206
  _react.useEffect.call(void 0, () => {
@@ -281,7 +281,7 @@ _chunk7QVYU63Ejs.__name.call(void 0, HelpSideNav, "HelpSideNav");
281
281
  function HelpArticleBody(props) {
282
282
  const t = _nextintl.useTranslations.call(void 0, );
283
283
  const format = _nextintl.useFormatter.call(void 0, );
284
- const generateUrl = _chunkLBXHYRZHjs.usePageUrlGenerator.call(void 0, );
284
+ const generateUrl = _chunkDNWYHP4Vjs.usePageUrlGenerator.call(void 0, );
285
285
  const { howToType, title, summary, updatedAt, prev, next, children } = props;
286
286
  return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "article", { className: "prose dark:prose-invert max-w-none", children: [
287
287
  /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "nav", { className: "text-muted-foreground mb-2 text-xs", children: [
@@ -338,7 +338,7 @@ function HelpTOC({ headings }) {
338
338
  }, [headings]);
339
339
  if (headings.length === 0) return null;
340
340
  return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "nav", { "aria-label": t("help.toc.title"), className: "hidden lg:block", children: [
341
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.MicroLabel, { className: "mb-2", children: t("help.toc.title") }),
341
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.MicroLabel, { className: "mb-2", children: t("help.toc.title") }),
342
342
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "ul", { className: "space-y-1 text-sm", children: headings.map((h) => /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "li", { style: { paddingLeft: `${(h.depth - 2) * 0.75}rem` }, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
343
343
  "a",
344
344
  {
@@ -359,7 +359,7 @@ _chunk7QVYU63Ejs.__name.call(void 0, HelpTOC, "HelpTOC");
359
359
 
360
360
  function HelpHint({ contextKey }) {
361
361
  const t = _nextintl.useTranslations.call(void 0, );
362
- const generateUrl = _chunkLBXHYRZHjs.usePageUrlGenerator.call(void 0, );
362
+ const generateUrl = _chunkDNWYHP4Vjs.usePageUrlGenerator.call(void 0, );
363
363
  const [open, setOpen] = _react.useState.call(void 0, false);
364
364
  const [picked, setPicked] = _react.useState.call(void 0, null);
365
365
  const [articles, setArticles] = _react.useState.call(void 0, []);
@@ -377,7 +377,7 @@ function HelpHint({ contextKey }) {
377
377
  if (matches.length === 0) return null;
378
378
  const active = _nullishCoalesce(picked, () => ( (matches.length === 1 ? matches[0] : null)));
379
379
  return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0,
380
- _chunkLBXHYRZHjs.Sheet,
380
+ _chunkDNWYHP4Vjs.Sheet,
381
381
  {
382
382
  open,
383
383
  onOpenChange: (o) => {
@@ -385,11 +385,11 @@ function HelpHint({ contextKey }) {
385
385
  if (!o) setPicked(null);
386
386
  },
387
387
  children: [
388
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.SheetTrigger, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.Button, { variant: "ghost", size: "icon-sm", "aria-label": t("help.hint.trigger") }), children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.HelpCircleIcon, { className: "h-4 w-4" }) }),
389
- /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkLBXHYRZHjs.SheetContent, { side: "right", className: "w-full max-w-md sm:max-w-lg", children: [
390
- /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkLBXHYRZHjs.SheetHeader, { children: [
391
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.SheetTitle, { children: active ? active.name : t("help.hint.pickArticle") }),
392
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.SheetDescription, { children: _nullishCoalesce(_optionalChain([active, 'optionalAccess', _4 => _4.summary]), () => ( "")) })
388
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.SheetTrigger, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.Button, { variant: "ghost", size: "icon-sm", "aria-label": t("help.hint.trigger") }), children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.HelpCircleIcon, { className: "h-4 w-4" }) }),
389
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkDNWYHP4Vjs.SheetContent, { side: "right", className: "w-full max-w-md sm:max-w-lg", children: [
390
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkDNWYHP4Vjs.SheetHeader, { children: [
391
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.SheetTitle, { children: active ? active.name : t("help.hint.pickArticle") }),
392
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.SheetDescription, { children: _nullishCoalesce(_optionalChain([active, 'optionalAccess', _4 => _4.summary]), () => ( "")) })
393
393
  ] }),
394
394
  active ? /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: "mt-4 space-y-3", children: [
395
395
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "p", { className: "text-muted-foreground text-sm", children: active.summary }),
@@ -421,7 +421,7 @@ _chunk7QVYU63Ejs.__name.call(void 0, HelpHint, "HelpHint");
421
421
 
422
422
  function HelpSearchResultRow({ result, onSelect }) {
423
423
  const t = _nextintl.useTranslations.call(void 0, );
424
- const generateUrl = _chunkLBXHYRZHjs.usePageUrlGenerator.call(void 0, );
424
+ const generateUrl = _chunkDNWYHP4Vjs.usePageUrlGenerator.call(void 0, );
425
425
  const [article, setArticle] = _react.useState.call(void 0, null);
426
426
  _react.useEffect.call(void 0, () => {
427
427
  let active = true;
@@ -432,7 +432,7 @@ function HelpSearchResultRow({ result, onSelect }) {
432
432
  }, [result.id]);
433
433
  if (!article) {
434
434
  return /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
435
- _chunkLBXHYRZHjs.CommandItem,
435
+ _chunkDNWYHP4Vjs.CommandItem,
436
436
  {
437
437
  value: result.id,
438
438
  disabled: true,
@@ -441,7 +441,7 @@ function HelpSearchResultRow({ result, onSelect }) {
441
441
  }
442
442
  );
443
443
  }
444
- return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLBXHYRZHjs.CommandItem, { value: result.id, onSelect, className: "cursor-pointer p-0", children: /* @__PURE__ */ _jsxruntime.jsxs.call(void 0,
444
+ return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkDNWYHP4Vjs.CommandItem, { value: result.id, onSelect, className: "cursor-pointer p-0", children: /* @__PURE__ */ _jsxruntime.jsxs.call(void 0,
445
445
  _link2.default,
446
446
  {
447
447
  href: generateUrl({ page: `/help/${article.howToType}/${article.slug}` }),
@@ -22,7 +22,7 @@ import {
22
22
  useAssistantContext,
23
23
  useCurrentUserContext,
24
24
  usePageUrlGenerator
25
- } from "../../chunk-DV5YYI3G.mjs";
25
+ } from "../../chunk-EF2HVXOV.mjs";
26
26
  import {
27
27
  _getStaticHelpContent
28
28
  } from "../../chunk-M5IAN7IR.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "1.134.0",
3
+ "version": "1.135.1",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
@@ -19,7 +19,7 @@ import {
19
19
  } from "@/components";
20
20
  import { partitionTabs, Tab } from "@/components/containers";
21
21
  import { RoundPageContainerTitle } from "@/components/containers/RoundPageContainerTitle";
22
- import { Header } from "@/components/navigations";
22
+ import { Header, MobileNavigationBar } from "@/components/navigations";
23
23
  import { useHeaderChildren, useHeaderLeftContent, useHeaderLogo, useHeaderMobileChildren } from "@/contexts";
24
24
  import { useUrlRewriter } from "@/hooks";
25
25
  import { cn, useIsMobile } from "@/index";
@@ -163,10 +163,15 @@ export function RoundPageContainer({
163
163
  >
164
164
  {headerChildren}
165
165
  </Header>
166
- <div className={cn("flex h-[calc(100vh-3rem)] w-full flex-col", isMobile ? "" : "p-2 pt-0 pl-0")}>
167
- <div className={cn("bg-background flex h-full w-full", isMobile ? "" : "rounded-lg border p-0")}>
166
+ <div className={cn("flex h-[calc(100vh-3rem)] w-full flex-col", isMobile ? "gap-1 p-1 pt-0" : "p-2 pt-0 pl-0")}>
167
+ {/* `min-h-0 flex-1`, NOT `h-full`: the bar below is an in-flow sibling in
168
+ this fixed-height flex column. `h-full` would resolve to 100% of the
169
+ wrapper, leaving no room and pushing the bar below the fold (visible
170
+ only after scrolling to the end of the page). */}
171
+ <div className={cn("bg-background flex min-h-0 w-full flex-1", isMobile ? "" : "rounded-lg border p-0")}>
168
172
  <div className="flex w-full flex-col" />
169
173
  </div>
174
+ <MobileNavigationBar />
170
175
  </div>
171
176
  </>
172
177
  );
@@ -182,8 +187,12 @@ export function RoundPageContainer({
182
187
  >
183
188
  {headerChildren}
184
189
  </Header>
185
- <div className={cn(`flex h-[calc(100vh-3rem)] w-full flex-col`, isMobile ? "p-1 pt-0" : "p-2 pt-0 pl-0")}>
186
- <div className="bg-background flex h-full w-full rounded-lg border p-0">
190
+ <div className={cn(`flex h-[calc(100vh-3rem)] w-full flex-col`, isMobile ? "gap-1 p-1 pt-0" : "p-2 pt-0 pl-0")}>
191
+ {/* `min-h-0 flex-1`, NOT `h-full`: MobileNavigationBar below is an in-flow
192
+ sibling in this fixed-height flex column. `h-full` resolves to 100% of
193
+ the wrapper, leaving the bar no room and pushing it below the fold —
194
+ it then appears only after scrolling to the very end of the page. */}
195
+ <div className="bg-background flex min-h-0 w-full flex-1 rounded-lg border p-0">
187
196
  <div className="flex w-full flex-col">
188
197
  {(!fullWidth || forceHeader) && (
189
198
  <RoundPageContainerTitle
@@ -409,6 +418,7 @@ export function RoundPageContainer({
409
418
  </div>
410
419
  ))}
411
420
  </div>
421
+ <MobileNavigationBar />
412
422
  </div>
413
423
  </>
414
424
  );
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { usePathname } from "next/navigation";
4
+ import { useMobileNavigationItems, type MobileNavigationItem } from "../../contexts/MobileNavigationContext";
5
+ import { Link } from "../../shadcnui";
6
+ import { cn } from "../../utils/cn";
7
+ import { useIsMobile } from "../../utils/use-mobile";
8
+
9
+ /**
10
+ * Active only for navigational slots.
11
+ *
12
+ * Exact-or-prefix, NOT a bare `startsWith`: the prefix arm keeps a detail route
13
+ * (/accounts/123) highlighting its section (/accounts).
14
+ *
15
+ * "/" is special-cased to EXACT match. Without that, its prefix arm degenerates
16
+ * to `pathname.startsWith("/")`, which is true for every route, and the Home
17
+ * slot highlights everywhere. This is pinned by a unit test in Task C.
18
+ */
19
+ function isActiveHref(pathname: string, href: string): boolean {
20
+ if (href === "/") return pathname === "/";
21
+ return pathname === href || pathname.startsWith(`${href}/`);
22
+ }
23
+
24
+ const slotClass = cn(
25
+ // min-h-12/min-w-12 = 48px touch target (Material/HIG guidance).
26
+ "relative flex min-h-12 min-w-12 flex-1 items-center justify-center",
27
+ "transition-colors",
28
+ );
29
+
30
+ function SlotContent({ item }: { item: MobileNavigationItem }) {
31
+ if (item.render) return <>{item.render}</>;
32
+ const Icon = item.icon;
33
+ return Icon ? <Icon className="size-6" /> : null;
34
+ }
35
+
36
+ /**
37
+ * The mobile bottom navigation bar.
38
+ *
39
+ * Renders nothing on desktop, and nothing when the application supplied no
40
+ * items — so page containers can render it unconditionally.
41
+ *
42
+ * Rendered as an in-flow flex sibling of the page content (NOT position:fixed),
43
+ * so the content column shrinks to fit and can never be hidden behind the bar.
44
+ */
45
+ export function MobileNavigationBar() {
46
+ const items = useMobileNavigationItems();
47
+ const isMobile = useIsMobile();
48
+ const pathname = usePathname();
49
+
50
+ if (!isMobile || items.length === 0) return null;
51
+
52
+ // A detached surface sitting BELOW the page card with a gap, so it carries a
53
+ // rounded border rather than a `border-t`, which would read as if the bar were
54
+ // welded to the card's bottom edge. `bg-sidebar` deliberately matches the
55
+ // Header's own background so the two chrome edges of the viewport agree.
56
+ return (
57
+ <nav
58
+ aria-label="Primary"
59
+ data-testid="mobile-navigation-bar"
60
+ className="bg-sidebar flex w-full shrink-0 flex-row items-stretch rounded-lg border pb-[env(safe-area-inset-bottom)]"
61
+ >
62
+ {items.map((item) => {
63
+ const active = item.href ? isActiveHref(pathname, item.href) : false;
64
+ const tone = active ? "text-primary" : "text-muted-foreground";
65
+
66
+ const body = (
67
+ <>
68
+ {active && <span aria-hidden className="bg-primary absolute inset-x-0 top-0 h-0.5" />}
69
+ <SlotContent item={item} />
70
+ </>
71
+ );
72
+
73
+ return item.href ? (
74
+ <Link
75
+ key={item.key}
76
+ href={item.href}
77
+ aria-label={item.label}
78
+ aria-current={active ? "page" : undefined}
79
+ data-testid={`mobile-nav-${item.key}`}
80
+ className={cn(slotClass, tone)}
81
+ >
82
+ {body}
83
+ </Link>
84
+ ) : (
85
+ <button
86
+ key={item.key}
87
+ type="button"
88
+ onClick={item.onClick}
89
+ aria-label={item.label}
90
+ data-testid={`mobile-nav-${item.key}`}
91
+ className={cn(slotClass, tone)}
92
+ >
93
+ {body}
94
+ </button>
95
+ );
96
+ })}
97
+ </nav>
98
+ );
99
+ }
@@ -0,0 +1,131 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { HomeIcon, SearchIcon } from "lucide-react";
4
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { MobileNavigationProvider, type MobileNavigationItem } from "../../../contexts/MobileNavigationContext";
6
+ import { ViewportProvider } from "../../../contexts/ViewportContext";
7
+ import { configureI18n } from "../../../i18n";
8
+ import { MobileNavigationBar } from "../MobileNavigationBar";
9
+
10
+ // usePathname is globally mocked to "/" in vitest.setup.ts. Override it here so
11
+ // the active-state rule can be exercised against a real route. A spec-level
12
+ // vi.mock takes precedence over the setup-file mock for this module.
13
+ const pathnameRef = { current: "/" };
14
+ vi.mock("next/navigation", () => ({
15
+ usePathname: () => pathnameRef.current,
16
+ useRouter: () => ({
17
+ push: vi.fn(),
18
+ replace: vi.fn(),
19
+ prefetch: vi.fn(),
20
+ back: vi.fn(),
21
+ forward: vi.fn(),
22
+ refresh: vi.fn(),
23
+ }),
24
+ useSearchParams: () => new URLSearchParams(),
25
+ useParams: () => ({}),
26
+ redirect: vi.fn(),
27
+ notFound: vi.fn(),
28
+ }));
29
+
30
+ beforeAll(() => {
31
+ // The package Link resolves its inner component at runtime and throws if i18n
32
+ // was never configured (src/i18n/config.ts:53-58).
33
+ configureI18n({
34
+ useRouter: () => ({
35
+ push: vi.fn(),
36
+ replace: vi.fn(),
37
+ back: vi.fn(),
38
+ forward: vi.fn(),
39
+ refresh: vi.fn(),
40
+ prefetch: vi.fn(),
41
+ }),
42
+ useTranslations: () => (key: string) => key,
43
+ usePathname: () => pathnameRef.current,
44
+ Link: ({ href, children, ...rest }: { href: string; children: React.ReactNode; [key: string]: any }) => (
45
+ <a href={href} {...rest}>
46
+ {children}
47
+ </a>
48
+ ),
49
+ });
50
+ });
51
+
52
+ beforeEach(() => {
53
+ pathnameRef.current = "/";
54
+ // jsdom defaults to 1024; ViewportProvider's effect measures immediately and
55
+ // would otherwise overwrite the mobile seed with "desktop".
56
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 375 });
57
+ });
58
+
59
+ function renderBar(items: MobileNavigationItem[], { mobile = true }: { mobile?: boolean } = {}) {
60
+ if (!mobile) {
61
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 });
62
+ }
63
+ return render(
64
+ <ViewportProvider initialIsMobile={mobile}>
65
+ <MobileNavigationProvider items={items}>
66
+ <MobileNavigationBar />
67
+ </MobileNavigationProvider>
68
+ </ViewportProvider>,
69
+ );
70
+ }
71
+
72
+ const searchItem: MobileNavigationItem = { key: "search", label: "Cerca", icon: SearchIcon, onClick: vi.fn() };
73
+ const homeItem: MobileNavigationItem = { key: "home", label: "Dashboard", icon: HomeIcon, href: "/" };
74
+
75
+ describe("MobileNavigationBar", () => {
76
+ it("renders nothing when the application supplied no items", () => {
77
+ renderBar([]);
78
+ expect(screen.queryByTestId("mobile-navigation-bar")).not.toBeInTheDocument();
79
+ });
80
+
81
+ it("renders nothing on desktop even when items exist", () => {
82
+ renderBar([searchItem], { mobile: false });
83
+ expect(screen.queryByTestId("mobile-navigation-bar")).not.toBeInTheDocument();
84
+ });
85
+
86
+ it("renders one slot per item, labelled by its accessible name", () => {
87
+ renderBar([searchItem, homeItem]);
88
+ expect(screen.getByTestId("mobile-navigation-bar")).toBeInTheDocument();
89
+ expect(screen.getByRole("button", { name: "Cerca" })).toBeInTheDocument();
90
+ expect(screen.getByRole("link", { name: "Dashboard" })).toBeInTheDocument();
91
+ });
92
+
93
+ it("does not render the label as visible text (icons-only bar)", () => {
94
+ renderBar([searchItem]);
95
+ expect(screen.queryByText("Cerca")).not.toBeInTheDocument();
96
+ });
97
+
98
+ it("fires onClick for action slots", async () => {
99
+ const onClick = vi.fn();
100
+ renderBar([{ ...searchItem, onClick }]);
101
+ await userEvent.click(screen.getByRole("button", { name: "Cerca" }));
102
+ expect(onClick).toHaveBeenCalledOnce();
103
+ });
104
+
105
+ it("renders the `render` escape hatch instead of an icon", () => {
106
+ renderBar([
107
+ { key: "profile", label: "Impostazioni", render: <span data-testid="avatar-stub" />, href: "/settings" },
108
+ ]);
109
+ expect(screen.getByTestId("avatar-stub")).toBeInTheDocument();
110
+ });
111
+
112
+ // The load-bearing case: a bare startsWith would make href="/" match everything.
113
+ it("marks only the exactly-matching slot active when one href is '/'", () => {
114
+ pathnameRef.current = "/conversations";
115
+ renderBar([homeItem, { key: "ai", label: "Chiedi all'AI", icon: SearchIcon, href: "/conversations" }]);
116
+ expect(screen.getByRole("link", { name: "Dashboard" })).not.toHaveAttribute("aria-current");
117
+ expect(screen.getByRole("link", { name: "Chiedi all'AI" })).toHaveAttribute("aria-current", "page");
118
+ });
119
+
120
+ it("keeps a section active on its detail route", () => {
121
+ pathnameRef.current = "/conversations/abc-123";
122
+ renderBar([{ key: "ai", label: "Chiedi all'AI", icon: SearchIcon, href: "/conversations" }]);
123
+ expect(screen.getByRole("link", { name: "Chiedi all'AI" })).toHaveAttribute("aria-current", "page");
124
+ });
125
+
126
+ it("never marks an action slot active", () => {
127
+ pathnameRef.current = "/";
128
+ renderBar([searchItem]);
129
+ expect(screen.getByRole("button", { name: "Cerca" })).not.toHaveAttribute("aria-current");
130
+ });
131
+ });
@@ -1,6 +1,7 @@
1
1
  export * from "./Breadcrumb";
2
2
  export * from "./ContentTitle";
3
3
  export * from "./Header";
4
+ export * from "./MobileNavigationBar";
4
5
  export * from "./ModeToggleSwitch";
5
6
  export * from "./PageSection";
6
7
  export * from "./RecentPagesNavigator";
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import type { ReactNode } from "react";
5
+ import type { LucideIcon } from "lucide-react";
6
+
7
+ /**
8
+ * One slot in the mobile bottom navigation bar.
9
+ *
10
+ * Supply exactly one of `icon` / `render`, and exactly one of `href` / `onClick`.
11
+ * This is a review-time contract, not a runtime-validated one — the mistake is
12
+ * immediately visible in dev, and validation code would ship to every consumer.
13
+ *
14
+ * `render` content MUST NOT contain an interactive element: an `href` slot is
15
+ * already wrapped in a <Link>, so a nested <a>/<button> is invalid HTML and
16
+ * triggers a hydration error.
17
+ */
18
+ export type MobileNavigationItem = {
19
+ /** Stable unique key. Used as the React key and the `data-testid` suffix. */
20
+ key: string;
21
+ /** Accessible name. The bar is icons-only, so this is never rendered as text. */
22
+ label: string;
23
+ /** Rendered at 24px. Mutually exclusive with `render`. */
24
+ icon?: LucideIcon;
25
+ /** Escape hatch for slots an icon cannot express (logo mark, user avatar). */
26
+ render?: ReactNode;
27
+ /** Navigational slot — receives the active highlight. */
28
+ href?: string;
29
+ /** Action slot — never highlighted. */
30
+ onClick?: () => void;
31
+ };
32
+
33
+ /**
34
+ * A plain module-scope context, mirroring the sibling `HeaderChildrenContext`.
35
+ *
36
+ * The `globalThis`-Symbol lazy-creation dance in `utils/use-mobile.tsx` is NOT
37
+ * needed here: that exists for the `core` entry, which is deliberately excluded
38
+ * from tsup's `clientEntries` and is reachable from server code. Provider (the
39
+ * `contexts` entry) and consumer (the `components` entry) share one context
40
+ * object, exactly as `HeaderChildrenContext` already does in production.
41
+ */
42
+ const MobileNavigationContext = React.createContext<MobileNavigationItem[]>([]);
43
+
44
+ /**
45
+ * Supplies the items rendered by `MobileNavigationBar`.
46
+ *
47
+ * Unlike `useSharedContext`, the hook below does NOT throw without a provider —
48
+ * it returns `[]` and the bar renders nothing. Apps that never install this
49
+ * provider are completely unaffected.
50
+ *
51
+ * MUST be rendered inside `ViewportProvider` so the bar's `useIsMobile()` reads
52
+ * the server-seeded viewport rather than flashing.
53
+ */
54
+ export function MobileNavigationProvider({ children, items }: { children: ReactNode; items: MobileNavigationItem[] }) {
55
+ // Items are rebuilt on every render of the app-side provider (it depends on
56
+ // hooks); memoising on the array identity keeps consumers from re-rendering
57
+ // more than the parent already does.
58
+ const value = React.useMemo(() => items, [items]);
59
+
60
+ return <MobileNavigationContext.Provider value={value}>{children}</MobileNavigationContext.Provider>;
61
+ }
62
+
63
+ /** Returns the application-supplied bottom-navigation items, or `[]` when none. */
64
+ export function useMobileNavigationItems(): MobileNavigationItem[] {
65
+ return React.useContext(MobileNavigationContext);
66
+ }
@@ -10,6 +10,7 @@ export * from "./CommonContext";
10
10
  export * from "./HeaderChildrenContext";
11
11
  export * from "./HeaderLeftContentContext";
12
12
  export * from "./HeaderLogoContext";
13
+ export * from "./MobileNavigationContext";
13
14
  export * from "./SharedContext";
14
15
  export * from "./SocketContext";
15
16
  export * from "./ViewportContext";
@@ -43,7 +43,7 @@ export function AssistantEmptyState({ onSend, operatorMode = false, onOperatorMo
43
43
  checked={operatorMode}
44
44
  onCheckedChange={(checked) => onOperatorModeChange(checked === true)}
45
45
  />
46
- <Label htmlFor="assistant-operator-mode" className="text-muted-foreground text-xs">
46
+ <Label htmlFor="assistant-operator-mode" className="text-muted-foreground cursor-pointer text-xs">
47
47
  {t("features.assistant.operator_mode")}
48
48
  </Label>
49
49
  </div>
@@ -132,7 +132,7 @@ export function PaymentMethodForm({ onSuccess, onCancel, isLoading = false }: Pa
132
132
  {/* Set as Default Checkbox */}
133
133
  <div className="flex items-center gap-x-2">
134
134
  <Checkbox id="setAsDefault" checked={setAsDefault} onCheckedChange={(checked) => setSetAsDefault(!!checked)} />
135
- <Label htmlFor="setAsDefault" className="font-normal">
135
+ <Label htmlFor="setAsDefault" className="cursor-pointer font-normal">
136
136
  Set as default payment method
137
137
  </Label>
138
138
  </div>