@star-insure/sdk 3.0.2 → 3.1.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.
@@ -11,6 +11,7 @@ interface Props {
11
11
  children: React.ReactNode;
12
12
  disabled?: boolean;
13
13
  as?: 'link' | 'button' | 'a';
14
+ small?: boolean;
14
15
  }
15
16
 
16
17
  export default function Button({
@@ -23,9 +24,11 @@ export default function Button({
23
24
  status = 'default',
24
25
  disabled = false,
25
26
  as,
27
+ small = false,
26
28
  }: Props) {
27
- const baseClasses =
28
- 'font-black inline-flex items-center gap-3 justify-center text-white text-center px-5 py-2 rounded-md min-w-[120px] transition-all hover:opacity-75';
29
+ const baseClasses = small
30
+ ? 'font-bold text-sm inline-flex items-center gap-3 justify-center text-white text-center px-3 py-1 rounded min-w-[80px] transition-all hover:opacity-75'
31
+ : 'font-black inline-flex items-center gap-3 justify-center text-white text-center px-5 py-2 rounded-md min-w-[120px] transition-all hover:opacity-75';
29
32
 
30
33
  const statusClass =
31
34
  (status === 'primary' && 'bg-teal') ||
@@ -0,0 +1,30 @@
1
+ import { Link } from "@inertiajs/react";
2
+ import { TPageHeaderAction } from "../../types";
3
+ import React from "react";
4
+
5
+ export default function Action({ title, href, as = 'Link', target = '_self', type, onClick = () => {} }: TPageHeaderAction) {
6
+ const className =
7
+ 'bg-white rounded-full font-bold px-3 py-1.5 text-xs whitespace-nowrap hover:bg-gray-100 hover:border-gray-300 transition-colors border border-gray-200';
8
+
9
+ if (as === 'Link' && href) {
10
+ return (
11
+ <Link className={className} href={href}>
12
+ {title}
13
+ </Link>
14
+ );
15
+ }
16
+
17
+ if (as === 'a' && href) {
18
+ return (
19
+ <a className={className} target={target} href={href}>
20
+ {title}
21
+ </a>
22
+ );
23
+ }
24
+
25
+ return (
26
+ <button className={className} type={type} onClick={() => onClick()}>
27
+ {title}
28
+ </button>
29
+ );
30
+ }
@@ -0,0 +1,35 @@
1
+ import { Link } from "@inertiajs/react";
2
+ import React from "react";
3
+ import { HiArrowLeft } from "react-icons/hi2";
4
+ import { usePage } from "../../lib/page";
5
+
6
+ export function BackButton({ back }: { back?: string | boolean }) {
7
+ const [backUrl, setBackUrl] = React.useState<string | undefined>(typeof back === 'string' ? back : undefined);
8
+ const { breadcrumbs } = usePage().props;
9
+
10
+ /**
11
+ * Set the back URL on mount
12
+ */
13
+ React.useEffect(() => {
14
+ if (typeof window !== 'undefined') {
15
+ // Set back button URL
16
+ if (back && typeof back === 'boolean') {
17
+ // If we haven't provided a path as a prop, use the last breadcrumb that's not the current one
18
+ const crumb = breadcrumbs.slice(breadcrumbs.length - 2, breadcrumbs.length - 1);
19
+ if (crumb && crumb.length === 1) {
20
+ // Strip off everything but the path so the <Link> component works
21
+ const url = crumb[0].url;
22
+ if (url) {
23
+ setBackUrl(new URL(url).pathname);
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }, [breadcrumbs]);
29
+
30
+ return (
31
+ <Link href={backUrl || '/'} className="hover:text-teal transition-all p-1 -mr-1">
32
+ <HiArrowLeft className="h-5 w-5 stroke-[1.25]" />
33
+ </Link>
34
+ );
35
+ }
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+
3
+ import { createHost, createSlot } from 'create-slots';
4
+ import { useClickOutside } from "../../lib";
5
+
6
+ const DropdownTitle = createSlot('button');
7
+ const DropdownContent = createSlot('form');
8
+
9
+ type Props = {
10
+ children: React.ReactNode;
11
+ onClose: Function;
12
+ active: boolean;
13
+ }
14
+
15
+ function Dropdown({ children, onClose, active }: Props) {
16
+ const ref = React.useRef<HTMLDivElement | null>(null);
17
+ const innerRef = React.useRef<HTMLDivElement | null>(null);
18
+
19
+ useClickOutside(ref, () => onClose(false));
20
+
21
+ React.useEffect(() => {
22
+ const current = ref?.current;
23
+ const innerCurrent = innerRef.current;
24
+
25
+ current?.classList.toggle('active', active)
26
+
27
+ if (!current || !innerCurrent) return;
28
+
29
+ const pos = current?.getBoundingClientRect();
30
+ innerCurrent.style.setProperty('left', `${pos?.left}px`)
31
+ }, [active, ref.current, innerRef.current]);
32
+
33
+ return createHost(children, (Slots) => {
34
+ return (
35
+ <div ref={ref} className="static inline-block">
36
+ {Slots.get(DropdownTitle)}
37
+
38
+ {active && (
39
+ <div ref={innerRef} className="absolute children z-10 flex flex-col">
40
+ {Slots.get(DropdownContent)}
41
+ </div>
42
+ )}
43
+ </div>
44
+ )
45
+ });
46
+ };
47
+
48
+ Dropdown.Title = DropdownTitle;
49
+ Dropdown.Content = DropdownContent;
50
+
51
+ export default Dropdown;
@@ -0,0 +1,318 @@
1
+ import { router } from "@inertiajs/react";
2
+ import { format, subYears } from "date-fns";
3
+ import React from "react";
4
+ import { FilterOption, FilterValue } from "../../types";
5
+ import Select from 'react-select';
6
+ import { HiChevronDown } from "react-icons/hi2";
7
+ import Dropdown from "./Dropdown";
8
+ import cn from 'classnames';
9
+ import { Button } from "../common";
10
+
11
+ export function FilterItem({ filter }: { filter: FilterOption, path?: string }) {
12
+ const [isOpen, setOpen] = React.useState<boolean>(false);
13
+ const [selected, setSelected] = React.useState<string[]>([]);
14
+
15
+ const [selectedOptions, setSelectedOptions] = React.useState<FilterValue[]>([]); // Filter type: select
16
+ const hasFilters = selected.length > 0;
17
+
18
+ // Populate values on load
19
+ React.useEffect(() => {
20
+ if (typeof window !== 'undefined') {
21
+ const search = new URLSearchParams(window.location.search);
22
+
23
+ if (filter.type === 'date') {
24
+ const fromDate = search.get(`${filter.name}[from]`);
25
+ const toDate = search.get(`${filter.name}[to]`);
26
+ if (fromDate && toDate) {
27
+ setSelected([fromDate, toDate]);
28
+ }
29
+ } else if (filter.type === 'greaterThan') {
30
+ const selectedFromUrl = search.getAll(`${filter.name}-GTE`);
31
+ if (selectedFromUrl) {
32
+ setSelected(selectedFromUrl);
33
+ }
34
+ } else if (filter.type === 'scope') {
35
+ const selectedFromUrl = search.getAll(`scope${filter.name}`);
36
+ if (selectedFromUrl) {
37
+ setSelected(selectedFromUrl);
38
+ }
39
+ } else if (filter.type === 'select') {
40
+ const selectedFromUrl = search.getAll(`${filter.name}[]`);
41
+ if (selectedFromUrl) {
42
+ setSelected(selectedFromUrl);
43
+ const selectedOptions = filter.options && filter.options.filter(item => selectedFromUrl.includes(item.value.toString()));
44
+ selectedOptions && setSelectedOptions(selectedOptions);
45
+ }
46
+ } else {
47
+ const selectedFromUrl = search.getAll(`${filter.name}[]`);
48
+ if (selectedFromUrl) {
49
+ setSelected(selectedFromUrl);
50
+ }
51
+ }
52
+ }
53
+ }, []);
54
+
55
+ function handleInput(e: React.SyntheticEvent<HTMLInputElement>) {
56
+ const { value, checked } = e.currentTarget;
57
+
58
+ if (checked) {
59
+ setSelected((curr) => [...curr, value]);
60
+ } else {
61
+ setSelected(selected.filter((f) => f !== value));
62
+ }
63
+ }
64
+
65
+ function handleDateSelect(e: React.SyntheticEvent<HTMLInputElement>) {
66
+ const { name, value } = e.currentTarget;
67
+
68
+ // First value in "selected" will be the "from", second will be the "to"
69
+ if (name.includes('from')) {
70
+ return setSelected([value]);
71
+ }
72
+
73
+ if (name.includes('to')) {
74
+ // Make sure we have a "from" value first
75
+ if (selected.length === 0) {
76
+ // We'll default to a year ago if nothing entered
77
+ return setSelected([format(subYears(new Date(), 1), 'yyyy-MM-dd'), value]);
78
+ }
79
+ return setSelected([selected[0], value]);
80
+ }
81
+
82
+ return setSelected([]);
83
+ }
84
+
85
+ /**
86
+ * Handle Select for FilterType: Select
87
+ */
88
+ const handleSelectedOptions = (options: FilterValue[] | any) => {
89
+ setSelectedOptions(options);
90
+ const selectedValues = options.map((option : FilterValue) => option.value.toString());
91
+
92
+ if (selectedValues.length > 0) {
93
+ setSelected(selectedValues);
94
+ } else {
95
+ setSelected([]);
96
+ }
97
+ };
98
+
99
+ /**
100
+ * For Filter Select
101
+ * Removes the Clear Button
102
+ */
103
+ function NullComponent() {
104
+ return null;
105
+ }
106
+
107
+ function handleSelect(e: React.SyntheticEvent<HTMLSelectElement>) {
108
+ const { value } = e.currentTarget;
109
+
110
+ if (value) {
111
+ setSelected([value]);
112
+ } else {
113
+ setSelected([]);
114
+ }
115
+ }
116
+
117
+ function handleApply(e: React.FormEvent) {
118
+ e.preventDefault();
119
+
120
+ const search = new URLSearchParams(window.location.search);
121
+
122
+ // Reset the page in the query
123
+ search.set('page', '1');
124
+
125
+ if (filter.type === 'date') {
126
+ const [from, to] = selected;
127
+
128
+ search.set(`${filter.name}[from]`, from ?? format(new Date(), 'yyyy-MM-dd'));
129
+ search.set(`${filter.name}[to]`, to ?? format(new Date(), 'yyyy-MM-dd'));
130
+ } else if (filter.type === 'greaterThan') {
131
+ search.delete(`${filter.name}-GTE`);
132
+
133
+ if (selected.length > 0) {
134
+ search.set(`${filter.name}-GTE`, selected[0]);
135
+ }
136
+ } else if (filter.type === 'scope') {
137
+ search.delete(`${filter.name}`);
138
+ if (selected.length > 0) {
139
+ search.set(`scope${filter.name}`, selected[0]);
140
+ }
141
+ } else {
142
+ // Clear this filter first
143
+ search.delete(`${filter.name}[]`);
144
+
145
+ // Apply the filters to the query string
146
+ selected.forEach((selectedValue, i) => {
147
+ // Fall back to option equality filters
148
+ search.append(`${filter.name}[]`, selectedValue);
149
+ });
150
+ }
151
+
152
+ // Fetch new data
153
+ router.get(`${window.location.pathname}?${search.toString()}`);
154
+ }
155
+
156
+ function handleClear() {
157
+ const search = new URLSearchParams(window.location.search);
158
+
159
+ // Reset the page in the query
160
+ search.set('page', '1');
161
+
162
+ // Clear this filter
163
+ if (filter.type === 'date') {
164
+ search.delete(`${filter.name}[from]`);
165
+ search.delete(`${filter.name}[to]`);
166
+ } else if (filter.type === 'greaterThan') {
167
+ search.delete(`${filter.name}-GTE`);
168
+ } else if (filter.type === 'scope') {
169
+ search.delete(`scope${filter.name}`);
170
+ } else {
171
+ search.delete(`${filter.name}[]`);
172
+ }
173
+
174
+ // Fetch new data
175
+ router.get(`${window.location.pathname}?${search.toString()}`);
176
+ }
177
+
178
+ function handleClick(force?: boolean) {
179
+ if (typeof force === 'boolean') {
180
+ return setOpen(force);
181
+ }
182
+
183
+ setOpen(!isOpen);
184
+ }
185
+
186
+ return (
187
+ <Dropdown onClose={() => setOpen(!open)} active={isOpen}>
188
+ <Dropdown.Title
189
+ onClick={() => handleClick()}
190
+ className={cn(
191
+ 'flex rounded-2xl border hover:border-teal bg-gray-600 min-w-[90px] px-2 py-1 items-center justify-between shrink-0 gap-2 text-xs text-white hover:bg-teal transition-colors',
192
+ {
193
+ '!bg-teal border-teal': hasFilters,
194
+ }
195
+ )}
196
+ >
197
+ <p className="whitespace-nowrap">{filter.label}</p>
198
+ <HiChevronDown />
199
+ </Dropdown.Title>
200
+
201
+ <Dropdown.Content
202
+ className={`mt-2 flex max-h-[350px] min-w-[200px] flex-col gap-2 rounded-md border border-gray-300 bg-white p-4 shadow-lg ${ filter.type && filter.type === 'select' ? '' : 'overflow-y-scroll'}`}
203
+ onSubmit={handleApply}
204
+ >
205
+ <div className="flex flex-col items-start gap-1">
206
+ {(!filter.type || filter.type === 'options') &&
207
+ filter.options?.map((option, i) => (
208
+ <div className="checkbox text-sm " key={`${option.label}-${i}`}>
209
+ <input
210
+ type="checkbox"
211
+ name={filter.name}
212
+ id={option.label}
213
+ value={option.value.toString()}
214
+ checked={selected.includes(option.value.toString())}
215
+ onChange={handleInput}
216
+ />
217
+ <label className="whitespace-nowrap" htmlFor={option.label}>
218
+ {option.label}
219
+ </label>
220
+ </div>
221
+ ))}
222
+ {filter.type === 'greaterThan' && filter.options && (
223
+ <label className="mb-2 w-full">
224
+ <div className="text-sm">Greater than or equal to</div>
225
+ <select
226
+ name={filter.name}
227
+ id={filter.label}
228
+ value={selected[0]}
229
+ onChange={handleSelect}
230
+ className="w-full"
231
+ >
232
+ <option value="">Select option</option>
233
+ {filter.options?.map((option) => (
234
+ <option key={option.value} value={option.value}>
235
+ {option.label}
236
+ </option>
237
+ ))}
238
+ </select>
239
+ </label>
240
+ )}
241
+ {filter.type === 'scope' && filter.options && (
242
+ <label className="mb-2 w-full">
243
+ <select
244
+ name={filter.name}
245
+ id={filter.label}
246
+ value={selected[0]}
247
+ onChange={handleSelect}
248
+ className="w-full"
249
+ >
250
+ <option value="">Select option</option>
251
+ {filter.options?.map((option) => (
252
+ <option key={option.value} value={option.value}>
253
+ {option.label}
254
+ </option>
255
+ ))}
256
+ </select>
257
+ </label>
258
+ )}
259
+ {filter.type === 'date' && (
260
+ <div className="mb-2 flex flex-col gap-4">
261
+ <label className="text-xs">
262
+ From
263
+ <input
264
+ type="date"
265
+ name={`${filter.name}[from]`}
266
+ id={`${filter.name}[from]`}
267
+ onChange={handleDateSelect}
268
+ value={selected[0] ?? ''}
269
+ />
270
+ </label>
271
+ <label className="text-xs">
272
+ To
273
+ <input
274
+ type="date"
275
+ name={`${filter.name}[to]`}
276
+ id={`${filter.name}[to]`}
277
+ onChange={handleDateSelect}
278
+ value={selected[1] ?? ''}
279
+ />
280
+ </label>
281
+ </div>
282
+ )}
283
+ {filter.type === 'select' && filter.options && (
284
+ <div className="w-full">
285
+ <Select
286
+ isMulti
287
+ options={filter.options}
288
+ className="basic-multi-select text-sm w-64 !transition-none"
289
+ classNamePrefix="select"
290
+ onChange={handleSelectedOptions}
291
+ components={{
292
+ ClearIndicator: NullComponent, // Hide the ClearIndicator (X button) -> exits filter when clicked
293
+ }}
294
+ value={selectedOptions}
295
+ theme={(theme) => ({
296
+ ...theme,
297
+ colors: {
298
+ ...theme.colors,
299
+ primary25: 'rgb(111, 199, 182)',
300
+ primary: 'rgb(111, 199, 182)',
301
+ },
302
+ })}
303
+ />
304
+ </div>
305
+ )}
306
+ </div>
307
+ <div className="flex items-center gap-2">
308
+ <Button type="button" className="!min-w-[0px] flex-grow !px-2 text-sm !transition-none" onClick={handleClear}>
309
+ Clear
310
+ </Button>
311
+ <Button type="submit" status="primary" className="!min-w-[0px] flex-grow !px-2 text-sm !transition-none">
312
+ Apply
313
+ </Button>
314
+ </div>
315
+ </Dropdown.Content>
316
+ </Dropdown>
317
+ );
318
+ }
@@ -0,0 +1,21 @@
1
+ import React from "react";
2
+ import { FilterOption } from "../../types";
3
+ import Button from "../common/Button"
4
+ import { FilterItem } from "./FilterItem";
5
+ import { router } from "@inertiajs/react";
6
+
7
+ export function FilterOptions({ filterOptions }: { filterOptions: FilterOption[] }) {
8
+ function handleClear() {
9
+ router.get(`${window.location.pathname}`);
10
+ }
11
+
12
+ return (
13
+ <div className="flex items-center">
14
+ {filterOptions.map(filter => (
15
+ <FilterItem filter={filter} key={filter.name} />
16
+ ))}
17
+ <Button onClick={handleClear}>Clear filters</Button>
18
+ </div>
19
+ );
20
+ }
21
+
@@ -0,0 +1,176 @@
1
+ import React from 'react';
2
+ import cn from 'classnames';
3
+ import { FilterOption, TPageHeaderAction } from '../../types';
4
+ import { BackButton } from './Back';
5
+ import SearchBar from './SearchBar';
6
+ import Action from './Action';
7
+ import { FilterItem } from './FilterItem';
8
+ import { HiChevronLeft, HiChevronRight, HiXMark } from 'react-icons/hi2';
9
+ import { router } from '@inertiajs/core';
10
+
11
+ interface Props {
12
+ title: string;
13
+ search?: string;
14
+ className?: string;
15
+ innerClassName?: string;
16
+ back?: boolean | string;
17
+ actions?: TPageHeaderAction[];
18
+ backText?: string;
19
+ filterOptions?: FilterOption[];
20
+ }
21
+
22
+ export default function PageHeader({
23
+ title,
24
+ search,
25
+ className = '',
26
+ innerClassName = '',
27
+ back = true,
28
+ actions = [],
29
+ filterOptions = [],
30
+ }: Props) {
31
+ const [isSearchActive, setSearchActive] = React.useState<boolean>(false);
32
+ const scrollContainerRef = React.useRef<HTMLDivElement | null>(null);
33
+
34
+ const [overScroll, setOverScroll] = React.useState<boolean>(false);
35
+
36
+ React.useEffect(() => {
37
+ const current = scrollContainerRef.current;
38
+ if (!current) return;
39
+
40
+ const listener = function() {
41
+ const activeDropdown = current.querySelector('.active');
42
+ if (!activeDropdown) return;
43
+
44
+ const childrenContainer = activeDropdown.querySelector('.children');
45
+ if (!childrenContainer) return;
46
+
47
+ const pos = activeDropdown.getBoundingClientRect();
48
+
49
+ const posChild = childrenContainer.getBoundingClientRect();
50
+ const posContainer = current.getBoundingClientRect();
51
+
52
+ // if the dropdown content moves outside the scroll container to the left, make it disappear
53
+ if (posChild.left <= posContainer.left - 50) {
54
+ // @ts-ignore
55
+ childrenContainer.style.visibility = 'hidden';
56
+ } else if (posChild.left >= posContainer.right - 50) {
57
+ // @ts-ignore
58
+ childrenContainer.style.visibility = 'hidden';
59
+ } else {
60
+ // @ts-ignore
61
+ childrenContainer.style.visibility = 'visible';
62
+ }
63
+
64
+ // @ts-ignore
65
+ childrenContainer.style.left = `${pos.left}px`;
66
+ }
67
+
68
+ const mouseListener = function(e: MouseEvent) {
69
+ const pos = current.getBoundingClientRect();
70
+ if (e.clientX > pos.left && e.clientX < pos.right && e.clientY > pos.top && e.clientY < pos.bottom) {
71
+ setOverScroll(true);
72
+ } else {
73
+ setOverScroll(false);
74
+ }
75
+ };
76
+
77
+ current.addEventListener('scroll', listener);
78
+ document.addEventListener('mousemove', mouseListener)
79
+
80
+ return () => {
81
+ current.removeEventListener('scroll', listener);
82
+ document.removeEventListener('mousemove', mouseListener)
83
+ }
84
+ }, []);
85
+
86
+ React.useEffect(() => {
87
+ const scrollListener = function(e: WheelEvent) {
88
+ if (overScroll) {
89
+ e.preventDefault();
90
+ scrollContainerRef.current?.scrollBy({ left: e.deltaY, behavior: 'auto' });
91
+ }
92
+ }
93
+
94
+ document.addEventListener('wheel', scrollListener, { passive: false });
95
+
96
+ return () => {
97
+ document.removeEventListener('wheel', scrollListener);
98
+ }
99
+ }, [overScroll])
100
+
101
+ function handleClear() {
102
+ router.get(`${window.location.pathname}`);
103
+ }
104
+
105
+ const [hasScroll, setHasScroll] = React.useState<boolean>(false);
106
+
107
+ const checkScroll = React.useCallback(() => {
108
+ const current = scrollContainerRef.current;
109
+ if (!current) return;
110
+
111
+ const pos = current.getBoundingClientRect();
112
+
113
+ setHasScroll(current.scrollWidth > (pos.width + 11));
114
+ }, [scrollContainerRef.current]);
115
+
116
+ React.useEffect(() => {
117
+ checkScroll();
118
+
119
+ window.addEventListener('resize', checkScroll)
120
+
121
+ return () => {
122
+ window.removeEventListener('resize', checkScroll);
123
+ }
124
+ }, [checkScroll]);
125
+
126
+ function clickRight() {
127
+ scrollContainerRef.current?.scrollBy({ left: 100, behavior: 'auto' });
128
+ checkScroll();
129
+ }
130
+
131
+ function clickLeft() {
132
+ scrollContainerRef.current?.scrollBy({ left: -100, behavior: 'auto' });
133
+ checkScroll();
134
+ }
135
+
136
+ return (
137
+ <section className={cn('col-span-full max-w-full rounded-lg bg-white p-2 shadow', className)}>
138
+ <div className={cn('grid grid-cols-[auto_1fr_auto] min-h-[60px] rounded gap-4 bg-gray-100 p-3', innerClassName)}>
139
+ <div className="mr-auto flex items-center gap-4">
140
+ {back && <BackButton back={back} />}
141
+
142
+ {search && <SearchBar search={search} active={isSearchActive} onActive={setSearchActive} placeholder={`Search ${title}...`} />}
143
+
144
+ {!isSearchActive && <h1 className="text-base font-bold">{title}</h1>}
145
+ </div>
146
+
147
+ <div className="flex ml-auto items-center max-w-sm md:max-w-md lg:max-w-lg xl:max-w-2xl gap-2 h-full min-w-0">
148
+ {filterOptions.length > 0 && (
149
+ <>
150
+ <button onClick={handleClear} className="p-1.5 hover:text-red-500"><HiXMark className="w-5 h-5 stroke-[1.25]" /></button>
151
+
152
+ {hasScroll && <HiChevronLeft onClick={clickLeft} className="w-5 h-5 text-gray-400 stroke-[1.25]" />}
153
+ <div ref={scrollContainerRef} className="flex items-center h-full w-full gap-3 overflow-x-scroll hide-scroll">
154
+ {filterOptions.map(filter => (
155
+ <FilterItem key={filter.name} filter={filter} />
156
+ ))}
157
+ </div>
158
+ {hasScroll && <HiChevronRight onClick={clickRight} className="w-5 h-5 text-gray-400 stroke-[1.25]" />}
159
+ </>
160
+ )}
161
+ </div>
162
+
163
+ {actions.length > 0 && (
164
+ <div className="flex items-center gap-3">
165
+ {filterOptions.length > 0 && <div className="w-[1px] h-full bg-gray-300" />}
166
+ <nav className="flex items-center gap-2">
167
+ {actions.map((action) => (
168
+ <Action key={`${action.title}-${action.as}-${action.href}`} {...action} />
169
+ ))}
170
+ </nav>
171
+ </div>
172
+ )}
173
+ </div>
174
+ </section>
175
+ );
176
+ }