@olwiba/ui 0.2.5 → 0.2.7
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.
- package/dist/index.d.ts +155 -3
- package/dist/index.js +240 -134
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/AppShell.tsx +11 -1
- package/src/app/DataView.tsx +99 -0
- package/src/components/MediaCard.tsx +68 -0
- package/src/components/ViewToggle.tsx +47 -0
- package/src/hooks/use-view-mode.ts +36 -0
- package/src/index.ts +4 -0
- package/src/layout/AppGrid.tsx +26 -7
- package/src/layout/index.ts +1 -1
- package/src/marketing/Navbar.tsx +19 -12
package/package.json
CHANGED
package/src/app/AppShell.tsx
CHANGED
|
@@ -110,6 +110,15 @@ export interface AppShellProps {
|
|
|
110
110
|
sidebarPosition?: 'viewport' | 'contained';
|
|
111
111
|
/** Which side the sidebar sits on. @default "left" */
|
|
112
112
|
side?: 'left' | 'right';
|
|
113
|
+
/**
|
|
114
|
+
* Class applied to the content wrapper around `children`, between the
|
|
115
|
+
* header and the page content. Every consuming page otherwise has to
|
|
116
|
+
* remember its own padding — some do, some don't, and the ones that don't
|
|
117
|
+
* end up flush against the sidebar/header chrome. Override to `''` for a
|
|
118
|
+
* genuinely edge-to-edge page (a full-width table, a map/canvas).
|
|
119
|
+
* @default "p-6"
|
|
120
|
+
*/
|
|
121
|
+
contentClassName?: string;
|
|
113
122
|
}
|
|
114
123
|
|
|
115
124
|
// ─── Internal sub-components ──────────────────────────────────────────────────
|
|
@@ -377,6 +386,7 @@ export function AppShell({
|
|
|
377
386
|
collapsible = 'icon',
|
|
378
387
|
sidebarPosition = 'viewport',
|
|
379
388
|
side = 'left',
|
|
389
|
+
contentClassName = 'p-6',
|
|
380
390
|
children,
|
|
381
391
|
}: AppShellProps = {}) {
|
|
382
392
|
const isContained = sidebarPosition === 'contained';
|
|
@@ -398,7 +408,7 @@ export function AppShell({
|
|
|
398
408
|
/>
|
|
399
409
|
<SidebarInset className={isContained ? undefined : 'overflow-y-auto'}>
|
|
400
410
|
<ShellHeader pageTitle={pageTitle} headerStart={headerStart} headerEnd={headerEnd} />
|
|
401
|
-
{children}
|
|
411
|
+
<div className={contentClassName}>{children}</div>
|
|
402
412
|
</SidebarInset>
|
|
403
413
|
</SidebarProvider>
|
|
404
414
|
);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import type { ColumnDef } from '@tanstack/react-table';
|
|
5
|
+
import { cn } from '@olwiba/cn';
|
|
6
|
+
import { AppGrid, type AppGridColumns } from '../layout/AppGrid';
|
|
7
|
+
import { DataTable } from '../components/DataTable';
|
|
8
|
+
import type { ViewMode } from '../hooks/use-view-mode';
|
|
9
|
+
|
|
10
|
+
export interface DataViewProps<TData> {
|
|
11
|
+
items: TData[];
|
|
12
|
+
/** Stable key per item. */
|
|
13
|
+
getRowId: (item: TData) => string;
|
|
14
|
+
/** Card renderer for the grid view. */
|
|
15
|
+
renderCard: (item: TData) => React.ReactNode;
|
|
16
|
+
/** Columns for the list view. */
|
|
17
|
+
columns: ColumnDef<TData>[];
|
|
18
|
+
view: ViewMode;
|
|
19
|
+
/**
|
|
20
|
+
* False while a persisted preference is still being read. The whole view is
|
|
21
|
+
* withheld until true — see the note on the component.
|
|
22
|
+
*/
|
|
23
|
+
ready?: boolean;
|
|
24
|
+
/** Grid density at the widest breakpoint. Default 3. */
|
|
25
|
+
gridColumns?: AppGridColumns;
|
|
26
|
+
gap?: 'none' | 'sm' | 'md' | 'lg';
|
|
27
|
+
/** Rendered instead of either view when there is nothing to show. */
|
|
28
|
+
empty?: React.ReactNode;
|
|
29
|
+
/** Shown while `ready` is false. Defaults to nothing. */
|
|
30
|
+
placeholder?: React.ReactNode;
|
|
31
|
+
searchKey?: string;
|
|
32
|
+
searchPlaceholder?: string;
|
|
33
|
+
pageSize?: number;
|
|
34
|
+
emptyMessage?: string;
|
|
35
|
+
onRowClick?: (row: TData) => void;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A collection rendered either as a grid of cards or as a table, from one set
|
|
41
|
+
* of data.
|
|
42
|
+
*
|
|
43
|
+
* Knows nothing about what it is listing: callers supply a card renderer and
|
|
44
|
+
* table columns. Cards are for "what arrived recently", the table for
|
|
45
|
+
* comparing many rows at once, and switching between them should not mean two
|
|
46
|
+
* page implementations that drift apart.
|
|
47
|
+
*
|
|
48
|
+
* The `ready` gate is the subtle part, and the reason this is a component
|
|
49
|
+
* rather than a snippet to copy. A persisted preference cannot be read during
|
|
50
|
+
* SSR or the first client render, so a page that renders its default
|
|
51
|
+
* immediately will either mismatch on hydration or visibly flash the wrong
|
|
52
|
+
* view at anyone who chose the other one. Holding the section until the
|
|
53
|
+
* preference resolves avoids both, and putting that here means no consumer
|
|
54
|
+
* has to work it out again.
|
|
55
|
+
*/
|
|
56
|
+
export function DataView<TData>({
|
|
57
|
+
items,
|
|
58
|
+
getRowId,
|
|
59
|
+
renderCard,
|
|
60
|
+
columns,
|
|
61
|
+
view,
|
|
62
|
+
ready = true,
|
|
63
|
+
gridColumns = 3,
|
|
64
|
+
gap = 'sm',
|
|
65
|
+
empty,
|
|
66
|
+
placeholder = null,
|
|
67
|
+
searchKey,
|
|
68
|
+
searchPlaceholder,
|
|
69
|
+
pageSize = 25,
|
|
70
|
+
emptyMessage,
|
|
71
|
+
onRowClick,
|
|
72
|
+
className,
|
|
73
|
+
}: DataViewProps<TData>) {
|
|
74
|
+
if (!ready) return <>{placeholder}</>;
|
|
75
|
+
if (items.length === 0 && empty) return <>{empty}</>;
|
|
76
|
+
|
|
77
|
+
if (view === 'list') {
|
|
78
|
+
return (
|
|
79
|
+
<DataTable
|
|
80
|
+
columns={columns}
|
|
81
|
+
data={items}
|
|
82
|
+
searchKey={searchKey}
|
|
83
|
+
searchPlaceholder={searchPlaceholder}
|
|
84
|
+
pageSize={pageSize}
|
|
85
|
+
emptyMessage={emptyMessage}
|
|
86
|
+
onRowClick={onRowClick}
|
|
87
|
+
className={className}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<AppGrid columns={gridColumns} gap={gap} className={cn(className)}>
|
|
94
|
+
{items.map((item) => (
|
|
95
|
+
<React.Fragment key={getRowId(item)}>{renderCard(item)}</React.Fragment>
|
|
96
|
+
))}
|
|
97
|
+
</AppGrid>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Card, cn } from '@olwiba/cn';
|
|
5
|
+
|
|
6
|
+
const bannerAspectClass = {
|
|
7
|
+
wide: 'aspect-[3/1]',
|
|
8
|
+
video: 'aspect-video',
|
|
9
|
+
square: 'aspect-square',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface MediaCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
13
|
+
/**
|
|
14
|
+
* Banner rendered above the content, clipped to the card's rounded corners.
|
|
15
|
+
* A ReactNode rather than a src, so a brand fallback, an inline SVG, a map,
|
|
16
|
+
* or a plain <img> all work — this is the difference from ImageCard, which
|
|
17
|
+
* takes a URL and is the right choice when you actually have one.
|
|
18
|
+
*/
|
|
19
|
+
banner?: React.ReactNode;
|
|
20
|
+
/** Banner proportions. Default 'wide' (3:1), which suits a header strip. */
|
|
21
|
+
bannerAspect?: keyof typeof bannerAspectClass;
|
|
22
|
+
/**
|
|
23
|
+
* Pinned to the bottom of the card. In a grid this is what keeps the
|
|
24
|
+
* primary action on a shared baseline across a row, however unevenly the
|
|
25
|
+
* titles above it wrap.
|
|
26
|
+
*/
|
|
27
|
+
footer?: React.ReactNode;
|
|
28
|
+
children?: React.ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A Card with an optional banner and a bottom-pinned footer.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately knows nothing about what it is showing — the domain-shaped
|
|
35
|
+
* cards (a property, a monitor, an article) compose this and supply their own
|
|
36
|
+
* content. It exists because "banner, then content, then an action pinned to
|
|
37
|
+
* the bottom, all the same height across a row" was being hand-rolled per
|
|
38
|
+
* product, and the equal-height part in particular is easy to get subtly
|
|
39
|
+
* wrong.
|
|
40
|
+
*
|
|
41
|
+
* `h-full` is on the card itself so a bare `<MediaCard>` inside a grid cell
|
|
42
|
+
* fills that cell without every caller remembering to ask.
|
|
43
|
+
*/
|
|
44
|
+
export function MediaCard({
|
|
45
|
+
banner,
|
|
46
|
+
bannerAspect = 'wide',
|
|
47
|
+
footer,
|
|
48
|
+
children,
|
|
49
|
+
className,
|
|
50
|
+
...props
|
|
51
|
+
}: MediaCardProps) {
|
|
52
|
+
return (
|
|
53
|
+
<Card
|
|
54
|
+
className={cn('flex h-full w-full flex-col overflow-hidden', banner && 'pt-0', className)}
|
|
55
|
+
{...props}
|
|
56
|
+
>
|
|
57
|
+
{banner && (
|
|
58
|
+
<div className={cn('w-full shrink-0 overflow-hidden', bannerAspectClass[bannerAspect])}>
|
|
59
|
+
{banner}
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
<div className="flex flex-1 flex-col gap-3 p-4">
|
|
63
|
+
{children}
|
|
64
|
+
{footer && <div className="mt-auto pt-1">{footer}</div>}
|
|
65
|
+
</div>
|
|
66
|
+
</Card>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Button, cn } from '@olwiba/cn';
|
|
5
|
+
import { LayoutGrid, List } from 'lucide-react';
|
|
6
|
+
import type { ViewMode } from '../hooks/use-view-mode';
|
|
7
|
+
|
|
8
|
+
export interface ViewToggleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
|
9
|
+
view: ViewMode;
|
|
10
|
+
onChange: (view: ViewMode) => void;
|
|
11
|
+
labels?: { cards?: string; list?: string };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Two-state segmented control for switching a collection between cards and a
|
|
16
|
+
* list. Controlled — pair it with useViewMode to persist the choice.
|
|
17
|
+
*/
|
|
18
|
+
export function ViewToggle({ view, onChange, labels, className, ...props }: ViewToggleProps) {
|
|
19
|
+
const options = [
|
|
20
|
+
{ mode: 'cards' as const, label: labels?.cards ?? 'Card view', Icon: LayoutGrid },
|
|
21
|
+
{ mode: 'list' as const, label: labels?.list ?? 'List view', Icon: List },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={cn('inline-flex items-center rounded-md border p-0.5', className)}
|
|
27
|
+
role="group"
|
|
28
|
+
aria-label="View mode"
|
|
29
|
+
{...props}
|
|
30
|
+
>
|
|
31
|
+
{options.map(({ mode, label, Icon }) => (
|
|
32
|
+
<Button
|
|
33
|
+
key={mode}
|
|
34
|
+
type="button"
|
|
35
|
+
size="sm"
|
|
36
|
+
variant={view === mode ? 'secondary' : 'ghost'}
|
|
37
|
+
className="h-7 px-2"
|
|
38
|
+
aria-label={label}
|
|
39
|
+
aria-pressed={view === mode}
|
|
40
|
+
onClick={() => onChange(mode)}
|
|
41
|
+
>
|
|
42
|
+
<Icon className="size-4" />
|
|
43
|
+
</Button>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useLocalStorage } from './use-local-storage';
|
|
4
|
+
import { useMounted } from './use-mounted';
|
|
5
|
+
|
|
6
|
+
export type ViewMode = 'cards' | 'list';
|
|
7
|
+
|
|
8
|
+
export interface UseViewModeReturn {
|
|
9
|
+
view: ViewMode;
|
|
10
|
+
setView: (view: ViewMode) => void;
|
|
11
|
+
/**
|
|
12
|
+
* False until the stored preference is readable. Render a skeleton or hold
|
|
13
|
+
* the section until this is true — see the note below on why.
|
|
14
|
+
*/
|
|
15
|
+
ready: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Remembered cards/list preference.
|
|
20
|
+
*
|
|
21
|
+
* Returns the fallback on the server and on the first client render, then
|
|
22
|
+
* settles to the stored value once mounted. Returning the stored value
|
|
23
|
+
* immediately would have the server emit card markup while the client builds
|
|
24
|
+
* a table — a hydration mismatch — and painting the fallback first flashes
|
|
25
|
+
* the wrong view at someone who chose the other one. `ready` lets callers
|
|
26
|
+
* wait for the real answer instead of doing either.
|
|
27
|
+
*
|
|
28
|
+
* The key is shared by default so the choice reads as one product-wide
|
|
29
|
+
* preference: pick list on one page and every page follows. Pass a distinct
|
|
30
|
+
* key where a page genuinely wants its own.
|
|
31
|
+
*/
|
|
32
|
+
export function useViewMode(key = 'view-mode', fallback: ViewMode = 'cards'): UseViewModeReturn {
|
|
33
|
+
const [stored, setStored] = useLocalStorage<ViewMode>(key, fallback);
|
|
34
|
+
const mounted = useMounted();
|
|
35
|
+
return { view: mounted ? stored : fallback, setView: setStored, ready: mounted };
|
|
36
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -128,6 +128,7 @@ export { CommandMenu, type CommandMenuProps, type CommandMenuGroup, type Command
|
|
|
128
128
|
|
|
129
129
|
// ─── Components — data ───────────────────────────────────────────────────────
|
|
130
130
|
export { DataTable, type DataTableProps, type DataTableColumn } from './components/DataTable';
|
|
131
|
+
export { DataView, type DataViewProps } from './app/DataView';
|
|
131
132
|
export { Chart, type ChartProps, type ChartSeries } from './components/Chart';
|
|
132
133
|
export { FileUpload, type FileUploadProps, type FileUploadEntry } from './components/FileUpload';
|
|
133
134
|
|
|
@@ -147,6 +148,8 @@ export { StatCard, type StatCardProps } from './components/StatCard';
|
|
|
147
148
|
export { TestimonialCard, type TestimonialCardProps } from './components/TestimonialCard';
|
|
148
149
|
export { PricingCard, type PricingCardProps, type PricingFeature } from './components/PricingCard';
|
|
149
150
|
export { ImageCard, type ImageCardProps } from './components/ImageCard';
|
|
151
|
+
export { MediaCard, type MediaCardProps } from './components/MediaCard';
|
|
152
|
+
export { ViewToggle, type ViewToggleProps } from './components/ViewToggle';
|
|
150
153
|
|
|
151
154
|
// ─── Components — diagram ────────────────────────────────────────────────────
|
|
152
155
|
export { FlowConnector, type FlowConnectorProps } from './components/FlowConnector';
|
|
@@ -183,5 +186,6 @@ export { useCopyToClipboard } from './hooks/use-copy-to-clipboard';
|
|
|
183
186
|
export { useDebounce } from './hooks/use-debounce';
|
|
184
187
|
export { useIntersectionObserver } from './hooks/use-intersection-observer';
|
|
185
188
|
export { useLocalStorage } from './hooks/use-local-storage';
|
|
189
|
+
export { useViewMode, type ViewMode, type UseViewModeReturn } from './hooks/use-view-mode';
|
|
186
190
|
export { useMediaQuery } from './hooks/use-media-query';
|
|
187
191
|
export { usePagination, type UsePaginationReturn } from './hooks/use-pagination';
|
package/src/layout/AppGrid.tsx
CHANGED
|
@@ -1,29 +1,48 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { cn } from '../lib/utils'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Responsive ramps per column count. Every ramp starts at a single column —
|
|
6
|
+
* a card grid that stays multi-column on a phone is unreadable — and adds
|
|
7
|
+
* columns at breakpoints wide enough to keep each card legible.
|
|
8
|
+
*
|
|
9
|
+
* Written out in full rather than composed, because Tailwind scans source for
|
|
10
|
+
* complete class strings; a template literal like `xl:grid-cols-${n}` produces
|
|
11
|
+
* nothing at build time.
|
|
12
|
+
*/
|
|
4
13
|
const columnsMap = {
|
|
5
14
|
1: 'grid-cols-1',
|
|
6
|
-
2: 'grid-cols-1
|
|
7
|
-
3: 'grid-cols-1
|
|
8
|
-
4: 'grid-cols-1
|
|
15
|
+
2: 'grid-cols-1 sm:grid-cols-2',
|
|
16
|
+
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
|
17
|
+
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
|
18
|
+
5: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
|
|
19
|
+
6: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
|
|
9
20
|
}
|
|
10
21
|
|
|
11
22
|
const gapMap = {
|
|
23
|
+
none: 'gap-0',
|
|
12
24
|
sm: 'gap-3',
|
|
13
25
|
md: 'gap-4',
|
|
14
26
|
lg: 'gap-6',
|
|
15
27
|
}
|
|
16
28
|
|
|
17
29
|
const spanMap = {
|
|
18
|
-
1: '
|
|
19
|
-
2: '
|
|
20
|
-
3: '
|
|
30
|
+
1: 'sm:col-span-1',
|
|
31
|
+
2: 'sm:col-span-2',
|
|
32
|
+
3: 'lg:col-span-3',
|
|
21
33
|
4: 'xl:col-span-4',
|
|
22
34
|
full: 'col-span-full',
|
|
23
35
|
}
|
|
24
36
|
|
|
37
|
+
export type AppGridColumns = keyof typeof columnsMap
|
|
38
|
+
|
|
25
39
|
export interface AppGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
26
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Columns at the widest breakpoint. Narrower screens step down through the
|
|
42
|
+
* ramp automatically, so this is "how dense at full width", not a fixed
|
|
43
|
+
* count. Default 3.
|
|
44
|
+
*/
|
|
45
|
+
columns?: AppGridColumns
|
|
27
46
|
gap?: keyof typeof gapMap
|
|
28
47
|
}
|
|
29
48
|
|
package/src/layout/index.ts
CHANGED
|
@@ -7,6 +7,6 @@ export type { SectionProps } from './Section'
|
|
|
7
7
|
export { AppContent } from './AppContent'
|
|
8
8
|
export type { AppContentProps } from './AppContent'
|
|
9
9
|
export { AppGrid, AppGridCell } from './AppGrid'
|
|
10
|
-
export type { AppGridProps, AppGridCellProps } from './AppGrid'
|
|
10
|
+
export type { AppGridProps, AppGridCellProps, AppGridColumns } from './AppGrid'
|
|
11
11
|
export { PublicPageFrame } from './PublicPageFrame'
|
|
12
12
|
export type { PublicPageFrameProps } from './PublicPageFrame'
|
package/src/marketing/Navbar.tsx
CHANGED
|
@@ -31,6 +31,7 @@ export function Navbar({
|
|
|
31
31
|
const [open, setOpen] = React.useState(false);
|
|
32
32
|
const brandHref = brand.href ?? '/';
|
|
33
33
|
const hasRightContent = controls?.length || cta?.primary || cta?.secondary;
|
|
34
|
+
const closeMobileMenu = React.useCallback(() => setOpen(false), []);
|
|
34
35
|
|
|
35
36
|
return (
|
|
36
37
|
<section className="overflow-hidden rounded-2xl border bg-card">
|
|
@@ -92,7 +93,7 @@ export function Navbar({
|
|
|
92
93
|
<SheetContent side="left" className="w-72">
|
|
93
94
|
{/* Brand row alone at the top — leaves the top-right corner to the built-in close */}
|
|
94
95
|
<div className="flex items-center pb-4">
|
|
95
|
-
<span
|
|
96
|
+
<span onClickCapture={closeMobileMenu} className="cursor-pointer">
|
|
96
97
|
{renderLink({
|
|
97
98
|
href: brandHref,
|
|
98
99
|
children: (
|
|
@@ -114,7 +115,7 @@ export function Navbar({
|
|
|
114
115
|
<Separator />
|
|
115
116
|
<nav className="mt-4 flex flex-col gap-1">
|
|
116
117
|
{navLinks.map((link) => (
|
|
117
|
-
<span key={link.label}
|
|
118
|
+
<span key={link.label} onClickCapture={closeMobileMenu}>
|
|
118
119
|
{renderLink({
|
|
119
120
|
href: link.href,
|
|
120
121
|
className: 'block rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
|
|
@@ -124,16 +125,22 @@ export function Navbar({
|
|
|
124
125
|
))}
|
|
125
126
|
</nav>
|
|
126
127
|
<div className="mt-6 flex flex-col gap-2">
|
|
127
|
-
{cta?.secondary &&
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
128
|
+
{cta?.secondary && (
|
|
129
|
+
<span onClickCapture={closeMobileMenu}>
|
|
130
|
+
{renderLink({
|
|
131
|
+
href: cta.secondary.href,
|
|
132
|
+
children: <Button variant="outline" className="w-full">{cta.secondary.label}</Button>,
|
|
133
|
+
})}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
{cta?.primary && (
|
|
137
|
+
<span onClickCapture={closeMobileMenu}>
|
|
138
|
+
{renderLink({
|
|
139
|
+
href: cta.primary.href,
|
|
140
|
+
children: <Button className="w-full">{cta.primary.label}</Button>,
|
|
141
|
+
})}
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
137
144
|
</div>
|
|
138
145
|
</SheetContent>
|
|
139
146
|
</Sheet>
|