@nlabs/metropolisjs 1.0.7 → 1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.7
4
+
5
+ - Flush pending RUM analytics with the Beacon API when a page is hidden or unloaded.
6
+ - Fall back to the normal unauthenticated RUM request when beacon delivery is unavailable or declined.
7
+ - Expose `flush({useBeacon: true})` and `rumBeaconRequest(...)` for explicit terminal delivery.
8
+ - Document RUM endpoint configuration, beacon lifecycle behavior, fallback semantics, and `useAwsRum()` usage.
9
+
3
10
  ## 1.0.3
4
11
 
5
12
  - Send the public RUM `analyticsId` to Reaktor in the JSON mutation payload.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # MetropolisJS: Seamless Frontend-Backend Integration Framework
2
2
 
3
3
  <p align="center">
4
- <img src="https://raw.githubusercontent.com/nitrogenlabs/metropolisjs/main/docs/assets/metropolisjs-logo.png" alt="MetropolisJS logo" width="432">
4
+ <img src="https://raw.githubusercontent.com/nitrogenlabs/metropolisjs/main/docs/assets/metropolisjs-mark.svg" alt="MetropolisJS winged V logo" width="432">
5
5
  </p>
6
6
 
7
7
  > **The Ultimate Frontend Integration Library for Modern Web Applications**
@@ -155,7 +155,7 @@ The `Metropolis` component accepts three main props: `config`, `adapters`, and `
155
155
 
156
156
  ### RUM analytics
157
157
 
158
- Configure the public analytics identifier returned by Reaktor under `app.rum.analyticsId`. MetropolisJS sends it in the JSON `RumBatchInput.analyticsId` field; it is not placed in a URL or request header.
158
+ Configure the public analytics identifier returned by Reaktor under `app.rum.analyticsId` and the collection endpoint under `app.api.endpoints.rum`. MetropolisJS sends the identifier in the JSON batch body; it is not placed in the URL or an authorization header.
159
159
 
160
160
  ```tsx
161
161
  <Metropolis
@@ -163,9 +163,18 @@ Configure the public analytics identifier returned by Reaktor under `app.rum.ana
163
163
  production: {
164
164
  app: {
165
165
  name: 'My App',
166
+ api: {
167
+ endpoints: {
168
+ rum: 'https://events.example.com/track'
169
+ }
170
+ },
166
171
  rum: {
167
172
  analyticsId: '00000000-0000-4000-8000-000000000000',
168
- enabled: true
173
+ debounceMs: 250,
174
+ dedupeMs: 1000,
175
+ enabled: true,
176
+ respectPrivacySignals: true,
177
+ throttleMs: 1000
169
178
  }
170
179
  }
171
180
  }
@@ -174,7 +183,47 @@ Configure the public analytics identifier returned by Reaktor under `app.rum.ana
174
183
  </Metropolis>
175
184
  ```
176
185
 
177
- `app.rum.analyticsId` is the identifier sent with each analytics batch.
186
+ Configure the endpoint at `app.api.endpoints.rum`. RUM delivery is unauthenticated and each batch contains `analyticsId` and up to 50 sanitized events.
187
+
188
+ #### Beacon delivery
189
+
190
+ When the page is hidden or receives `pagehide`, the `Metropolis` provider automatically flushes pending RUM events with `navigator.sendBeacon()`. This gives terminal analytics a chance to finish without delaying navigation or page shutdown.
191
+
192
+ Beacon delivery is best-effort:
193
+
194
+ - If `navigator.sendBeacon` is unavailable, throws, or declines the payload, MetropolisJS immediately falls back to its normal asynchronous RUM request.
195
+ - A batch accepted by `sendBeacon` is not sent again through the normal request path.
196
+ - Scheduled and explicit `flush()` calls continue to use the normal request path unless `useBeacon` is requested.
197
+ - Privacy signals and the `enabled` option are respected for both delivery paths.
198
+
199
+ Use the specialized hook when an application needs to track or flush events directly:
200
+
201
+ ```tsx
202
+ import {useAwsRum} from '@nlabs/metropolisjs';
203
+
204
+ const SaveButton = () => {
205
+ const rum = useAwsRum();
206
+
207
+ const onSave = () => {
208
+ rum.track({
209
+ name: 'settings_saved',
210
+ path: '/settings',
211
+ properties: {section: 'profile'},
212
+ type: 'click'
213
+ });
214
+ };
215
+
216
+ return <button onClick={onSave}>Save</button>;
217
+ };
218
+ ```
219
+
220
+ For an application-controlled terminal flush, request beacon delivery explicitly:
221
+
222
+ ```ts
223
+ await rum.flush({useBeacon: true});
224
+ ```
225
+
226
+ Calling `flush({useBeacon: true})` is safe in non-browser environments and older browsers because it falls back to the normal RUM request when the Beacon API cannot be used.
178
227
 
179
228
  ### Configuration Object
180
229
 
@@ -401,7 +450,7 @@ const LoginForm = () => {
401
450
 
402
451
  const handleLogin = async () => {
403
452
  try {
404
- const session = await userActions.signIn(username, password);
453
+ const session = await userActions.signIn({password, username});
405
454
  console.log('User logged in successfully!', session);
406
455
  } catch (error) {
407
456
  console.error('Login failed:', error);
@@ -427,6 +476,41 @@ const LoginForm = () => {
427
476
  };
428
477
  ```
429
478
 
479
+ ### Billing Setup Sessions
480
+
481
+ Billing cards are collected through a hosted setup session, so raw card details never pass through application code. Start the flow with an authenticated user action and redirect the browser to the returned checkout URL:
482
+
483
+ ```tsx
484
+ import {useUserActions} from '@nlabs/metropolisjs';
485
+
486
+ const AddBillingCardButton = () => {
487
+ const userActions = useUserActions();
488
+
489
+ const addBillingCard = async () => {
490
+ const returnUrl = `${window.location.origin}/settings/billing/complete`;
491
+ const checkoutUrl = await userActions.createBillingSetupSession(returnUrl);
492
+ window.location.assign(checkoutUrl);
493
+ };
494
+
495
+ return <button onClick={addBillingCard}>Add billing card</button>;
496
+ };
497
+ ```
498
+
499
+ On the return page, read the provider's setup-session identifier and complete the flow. The action returns the updated user, refreshes the matching session data, dispatches the standard user update event, and clears related user request caches:
500
+
501
+ ```tsx
502
+ const sessionId = new URLSearchParams(window.location.search).get('session_id');
503
+
504
+ if(sessionId) {
505
+ const user = await userActions.completeBillingSetupSession(sessionId, [
506
+ 'stripeCardBrand',
507
+ 'stripeCardLast4'
508
+ ]);
509
+ }
510
+ ```
511
+
512
+ Use `deleteBillingCard()` to remove the saved billing method. Both completion and deletion return sanitized billing metadata; MetropolisJS does not accept raw card numbers or tokens.
513
+
430
514
  ### Real-Time Messaging
431
515
 
432
516
  ```tsx
@@ -505,6 +589,7 @@ MetropolisJS provides comprehensive actions for all your needs. Access them usin
505
589
 
506
590
  ### Specialized Hooks (Recommended)
507
591
 
592
+ - `useAwsRum()` - Analytics tracking, batching, and terminal beacon delivery
508
593
  - `useUserActions()` - Authentication, personas, user management
509
594
  - `useMessageActions()` - Real-time messaging and conversations
510
595
  - `usePermissionActions()` - Permission and role management (RBAC)
@@ -547,21 +632,34 @@ MetropolisJS uses a **factory function pattern** for actions. This provides func
547
632
  ### Basic Usage
548
633
 
549
634
  ```typescript
550
- import {createUserActions} from '../actions/userActions';
635
+ import {createAction, createActions, createUserActions} from '@nlabs/metropolisjs';
551
636
 
552
637
  const userActions = createUserActions(flux);
553
- const user = await userActions.add(userData);
638
+ const user = await userActions.addUser(userData);
639
+
640
+ const postActions = createAction('post', flux);
641
+ const post = await postActions.add({content: 'Hello!'});
642
+
643
+ const actions = createActions(['user', 'post', 'message'], flux);
644
+ await actions.message.sendMessage({
645
+ content: 'Welcome!',
646
+ recipientId: user.userId
647
+ });
554
648
  ```
555
649
 
650
+ Factory results preserve their selected types. `createAction('post', flux)` returns `PostActions`, while `createActions(['user', 'post'], flux)` returns only typed `user` and `post` keys. `createAllActions(flux)` returns the complete `ActionMap`.
651
+
556
652
  ### Advanced Usage with Custom Adapters
557
653
 
558
654
  #### Custom Validation Adapter
559
655
 
560
656
  ```typescript
657
+ import type {User} from '@nlabs/metropolisjs';
658
+
561
659
  // Custom adapter that extends default behavior
562
- const customUserAdapter = (input: unknown, options?: UserAdapterOptions) => {
660
+ const customUserAdapter = (input: unknown): User => {
563
661
  // input is already validated by default adapter
564
- const user = input as any;
662
+ const user = input as User;
565
663
 
566
664
  // Add business-specific validation
567
665
  if (user.email && !user.email.includes('@company.com')) {
@@ -572,7 +670,7 @@ const customUserAdapter = (input: unknown, options?: UserAdapterOptions) => {
572
670
  return {
573
671
  ...user,
574
672
  fullName: `${user.firstName || ''} ${user.lastName || ''}`.trim(),
575
- isAdmin: user.userAccess >= 3
673
+ isAdmin: (user.userAccess || 0) >= 3
576
674
  };
577
675
  };
578
676
 
@@ -638,31 +736,7 @@ interface AdapterOptions {
638
736
  }
639
737
  ```
640
738
 
641
- ### Migration Guide
642
-
643
- #### Step 1: Update Imports
644
-
645
- ```typescript
646
- // Old
647
- import {userActions} from '../actions/userActions';
648
-
649
- // New
650
- import {createUserActions} from '../actions/userActions';
651
- ```
652
-
653
- #### Step 2: Update Instantiation
654
-
655
- ```typescript
656
- // Old
657
- const userActions = new userActions(flux, customAdapter);
658
-
659
- // New
660
- const userActions = createUserActions(flux, {
661
- userAdapter: customAdapter
662
- });
663
- ```
664
-
665
- #### Step 3: Using Actions in Components
739
+ ### Using Actions in Components
666
740
 
667
741
  The recommended approach is to use specialized hooks:
668
742
 
@@ -715,11 +789,11 @@ const apiUrl = config.app?.api?.url || '';
715
789
  #### Unit Testing Actions
716
790
 
717
791
  ```typescript
718
- import {createUserActions} from '@nlabs/metropolisjs';
792
+ import {createUserActions, type UserActions} from '@nlabs/metropolisjs';
719
793
 
720
794
  describe('userActions', () => {
721
795
  let flux: FluxFramework;
722
- let userActions: userActions;
796
+ let userActions: UserActions;
723
797
 
724
798
  beforeEach(() => {
725
799
  flux = createMockFlux();
@@ -732,7 +806,7 @@ describe('userActions', () => {
732
806
 
733
807
  it('should add user with validation', async () => {
734
808
  const userData = {username: 'test', email: 'test@example.com'};
735
- const result = await userActions.add(userData);
809
+ const result = await userActions.addUser(userData);
736
810
  expect(result).toBeDefined();
737
811
  });
738
812
  });
@@ -741,7 +815,7 @@ describe('userActions', () => {
741
815
  #### Testing with Custom Adapters
742
816
 
743
817
  ```typescript
744
- const mockAdapter = jest.fn((input) => ({
818
+ const mockAdapter = vi.fn((input) => ({
745
819
  ...input,
746
820
  validated: true
747
821
  }));
@@ -1217,8 +1291,8 @@ For more detailed examples, see [`examples/permission-system-usage.tsx`](./examp
1217
1291
  ### Prerequisites
1218
1292
 
1219
1293
  - Node.js 16+
1220
- - React 18+
1221
- - TypeScript 4.5+
1294
+ - React 19+
1295
+ - TypeScript 7+
1222
1296
 
1223
1297
  ### Full Installation
1224
1298
 
@@ -1242,7 +1316,18 @@ Configure your environment-specific settings in the `config` prop of the `Metrop
1242
1316
 
1243
1317
  ## Contributing
1244
1318
 
1245
- We love contributions! Here's how you can help:
1319
+ Before opening a pull request, run the same quality gates used for source, tests, examples, and the published declarations:
1320
+
1321
+ ```bash
1322
+ npm run lint
1323
+ npm run typecheck
1324
+ npm test
1325
+ npm run build
1326
+ ```
1327
+
1328
+ `npm run typecheck` checks the production source, unit and integration tests, lint inputs, and every file under `examples/`.
1329
+
1330
+ To contribute:
1246
1331
 
1247
1332
  1. **Fork** the repository
1248
1333
  2. **Create** a feature branch (`git checkout -b feature/amazing-feature`)
@@ -1256,6 +1341,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
1256
1341
 
1257
1342
  ## Additional Documentation
1258
1343
 
1344
+ - **[Actions Reference](./docs/ACTIONS.md)** - Action families, hooks, factories, and RUM beacon delivery
1259
1345
  - **[Factory Pattern Guide](./factoryPatternGuide.md)** - Detailed guide on using the factory pattern
1260
1346
  - **[Architecture Analysis](./ARCHITECTURE_ANALYSIS.md)** - Deep dive into the architecture
1261
1347
  - **[Changelog](./CHANGELOG.md)** - Complete list of changes and improvements
package/docs/ACTIONS.md CHANGED
@@ -7,8 +7,9 @@ This reference documents every action family exposed by MetropolisJS, how to acc
7
7
  Use specialized hooks in React components:
8
8
 
9
9
  ```tsx
10
- import {useUserActions, usePostActions, useMessageActions, useRestActions} from '@nlabs/metropolisjs';
10
+ import {useAwsRum, useUserActions, usePostActions, useMessageActions, useRestActions} from '@nlabs/metropolisjs';
11
11
 
12
+ const rum = useAwsRum();
12
13
  const userActions = useUserActions();
13
14
  const postActions = usePostActions();
14
15
  const messageActions = useMessageActions();
@@ -25,6 +26,26 @@ const postActions = createPostActions(flux);
25
26
  const restActions = createRestActions(flux);
26
27
  ```
27
28
 
29
+ ### Typed Consolidated Factories
30
+
31
+ The consolidated factories preserve the selected action types without casts:
32
+
33
+ ```ts
34
+ import {createAction, createActions, createAllActions} from '@nlabs/metropolisjs';
35
+
36
+ const userActions = createAction('user', flux);
37
+ await userActions.addUser({username: 'ada'});
38
+
39
+ const actions = createActions(['user', 'post', 'message'], flux);
40
+ await actions.post.add({content: 'Hello!'});
41
+ await actions.message.sendMessage({content: 'Welcome!'});
42
+
43
+ const allActions = createAllActions(flux);
44
+ await allActions.permission.list();
45
+ ```
46
+
47
+ `createAction()` maps its key to the corresponding action interface, `createActions()` returns exactly the requested keys, and `createAllActions()` returns the complete exported `ActionMap`.
48
+
28
49
  ## Action Families
29
50
 
30
51
  Each row links to:
@@ -37,7 +58,8 @@ Each row links to:
37
58
  These action families are available through specialized hooks when present, `useMetropolis([...])`, `createAction(...)`, `createActions(...)`, and direct creators.
38
59
 
39
60
  | Family | Hook | Factory Key | Creator | Typical Methods | Source |
40
- | --- | --- | --- | --- | --- |
61
+ | --- | --- | --- | --- | --- | --- |
62
+ | AWS RUM | `useAwsRum` | `awsRum` | `createAwsRumActions` | `track`, `flush`, `destroy` | [awsRumActions.ts](../src/actions/awsRumActions/awsRumActions.ts) |
41
63
  | Content | `useContentActions` | `content` | `createContentActions` | `add`, `itemById`, `itemByKey`, `listByCategory`, `list`, `update`, `delete` | [contentActions.ts](../src/actions/contentActions/contentActions.ts) |
42
64
  | CRM | `useCrmActions` | `crm` | `createCrmActions` | `mailingLists`, `createMailingList`, `createSupportTicket`, `supportTickets`, `customerOrdersByUser` | [crmActions.ts](../src/actions/crmActions/crmActions.ts) |
43
65
  | Event | `useEventActions` | `event` | `createEventActions` | `addEvent`, `getEvent`, `getEventsByTags`, `getEventsByReactions`, `updateEvent`, `deleteEvent` | [eventActions.ts](../src/actions/eventActions/eventActions.ts) |
@@ -54,7 +76,7 @@ These action families are available through specialized hooks when present, `use
54
76
  | Subscription | `useSubscriptionActions` | `subscription` | `createSubscriptionActions` | `addPlan`, `getPlanByItem`, `addSubscription`, `getSubscriptionByItem`, `getSubscriptionListByUser`, `deleteSubscription` | [subscriptionActions.ts](../src/actions/subscriptionActions/subscriptionActions.ts) |
55
77
  | Tag | `useTagActions` | `tag` | `createTagActions` | `addTag`, `addTagToItem`, `getTags`, `updateTag`, `deleteTag`, `deleteTagFromItem` | [tagActions.ts](../src/actions/tagActions/tagActions.ts) |
56
78
  | Translation | `useTranslationActions` | `translation` | `createTranslationActions` | `addTranslations`, `getTranslation`, `getTranslations`, `hasTranslation`, `queueTranslationKey`, `processPendingTranslations` | [translationActions.ts](../src/actions/translationActions/translationActions.ts) |
57
- | User | `useUserActions` | `user` | `createUserActions` | `signIn`, `signUp`, `session`, `refreshSession`, `itemById`, `listByLatest`, `updateUser` | [userActions.ts](../src/actions/userActions/userActions.ts) |
79
+ | User | `useUserActions` | `user` | `createUserActions` | `signIn`, `signUp`, `session`, `createBillingSetupSession`, `completeBillingSetupSession`, `deleteBillingCard`, `itemById`, `updateUser` | [userActions.ts](../src/actions/userActions/userActions.ts) |
58
80
  | Video | `useVideoActions` | `video` | `createVideoActions` | `add`, `itemById`, `list`, `update`, `delete` | [videoActions.ts](../src/actions/videoActions/videoActions.ts) |
59
81
  | Websocket | `useWebsocketActions` | `websocket` | `createWebsocketActions` | `wsInit`, `wsSend`, `onOpen`, `onReceive`, `onClose`, `onError` | [websocketActions.ts](../src/actions/websocketActions/websocketActions.ts) |
60
82
 
@@ -68,6 +90,85 @@ These creators are exported directly from MetropolisJS, but are not currently pa
68
90
  | Connection | `createConnectionActions` | `addConnection`, `getConnections`, `removeConnection` | [connectionActions.ts](../src/actions/connectionActions/connectionActions.ts) |
69
91
  | Conversation | `createConversationActions` | `add`, `itemById`, `list`, `update`, `delete` | [conversationActions.ts](../src/actions/conversationActions/conversationActions.ts) |
70
92
 
93
+ ## AWS RUM Actions
94
+
95
+ Use `useAwsRum()` to queue sanitized analytics events and flush them to the configured RUM endpoint:
96
+
97
+ ```tsx
98
+ import {useAwsRum} from '@nlabs/metropolisjs';
99
+
100
+ const AnalyticsExample = () => {
101
+ const rum = useAwsRum();
102
+
103
+ const trackCheckout = () => {
104
+ rum.track({
105
+ name: 'checkout_started',
106
+ path: '/checkout',
107
+ properties: {source: 'cart'},
108
+ type: 'click'
109
+ });
110
+ };
111
+
112
+ return <button onClick={trackCheckout}>Checkout</button>;
113
+ };
114
+ ```
115
+
116
+ Configure the action through the `Metropolis` provider:
117
+
118
+ ```tsx
119
+ <Metropolis
120
+ config={{
121
+ production: {
122
+ app: {
123
+ api: {
124
+ endpoints: {
125
+ rum: 'https://events.example.com/track'
126
+ }
127
+ },
128
+ rum: {
129
+ analyticsId: 'my-public-analytics-id',
130
+ enabled: true
131
+ }
132
+ }
133
+ }
134
+ }}
135
+ >
136
+ <App />
137
+ </Metropolis>
138
+ ```
139
+
140
+ `flush()` uses the normal asynchronous request path. `flush({useBeacon: true})` first asks the browser to queue the JSON batch with `navigator.sendBeacon()`, then falls back to the normal request if the Beacon API is unavailable or declines the payload.
141
+
142
+ The `Metropolis` provider requests beacon delivery automatically on `pagehide` and when `document.visibilityState` changes to `hidden`. A batch accepted by the Beacon API is not submitted a second time. All RUM delivery remains subject to `enabled`, `respectPrivacySignals`, batching, throttling, deduplication, and event sanitization.
143
+
144
+ ## User Billing Setup Sessions
145
+
146
+ Use the authenticated user actions to collect a billing method through the hosted setup flow:
147
+
148
+ ```ts
149
+ import {createUserActions} from '@nlabs/metropolisjs';
150
+
151
+ const userActions = createUserActions(flux);
152
+ const checkoutUrl = await userActions.createBillingSetupSession(
153
+ 'https://app.example.com/settings/billing/complete'
154
+ );
155
+
156
+ window.location.assign(checkoutUrl);
157
+ ```
158
+
159
+ After the billing provider redirects back, complete the session with its identifier:
160
+
161
+ ```ts
162
+ const user = await userActions.completeBillingSetupSession(
163
+ setupSessionId,
164
+ ['stripeCardBrand', 'stripeCardLast4']
165
+ );
166
+ ```
167
+
168
+ `createBillingSetupSession(returnUrl)` validates the return URL and resolves to the hosted checkout URL. `completeBillingSetupSession(sessionId, userProps?, requestOptions?)` validates the identifier, returns the updated user, synchronizes the active session when it belongs to that user, dispatches `USER_UPDATE_ITEM_SUCCESS`, and clears related request caches. `deleteBillingCard(userProps?, requestOptions?)` removes the stored method through the same authenticated action family.
169
+
170
+ These APIs exchange setup-session identifiers and sanitized billing metadata only. They do not accept raw card details.
171
+
71
172
  ## REST Actions
72
173
 
73
174
  Use REST actions for external APIs that are not represented in Reaktor. REST actions delegate to `@nlabs/rip-hunter`, share Metropolis network/session handling, and can target either a configured endpoint key or an absolute URL.
@@ -120,6 +221,18 @@ All action creators are re-exported from:
120
221
 
121
222
  - [src/actions/index.ts](../src/actions/index.ts)
122
223
 
224
+ The package root also exports every creator, action interface, `ActionMap`, and the consolidated factory functions.
225
+
123
226
  All specialized hooks are exposed from:
124
227
 
125
228
  - [src/utils/useMetropolis.ts](../src/utils/useMetropolis.ts)
229
+
230
+ ## Development Type Checks
231
+
232
+ Run the complete TypeScript gate with:
233
+
234
+ ```bash
235
+ npm run typecheck
236
+ ```
237
+
238
+ This validates the production source, tests, lint inputs, and examples. Run `npm run lint`, `npm test`, and `npm run build` before publishing.
Binary file
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 600" role="img" aria-labelledby="title desc">
2
+ <title id="title">MetropolisJS</title>
3
+ <desc id="desc">Purple winged V mark representing the connection between frontend and backend systems.</desc>
4
+ <path
5
+ d="M600 280C585 280 575 270 565 250C525 170 480 100 415 70C318 25 205 60 125 155C82 206 55 278 40 340C95 250 170 205 245 205C365 205 470 360 555 505C570 532 582 548 600 548C618 548 630 532 645 505C730 360 835 205 955 205C1030 205 1105 250 1160 340C1145 278 1118 206 1075 155C995 60 882 25 785 70C720 100 675 170 635 250C625 270 615 280 600 280Z"
6
+ fill="#712CF9"/>
7
+ </svg>
@@ -700,7 +700,7 @@ export const EventManagerExample = () => {
700
700
  <p>
701
701
  {new Date(event.startDate!).toLocaleString()} - {new Date(event.endDate!).toLocaleString()}
702
702
  </p>
703
- <p>Location: {event.location}</p>
703
+ <p>Location: {typeof event.location === 'string' ? event.location : event.location?.address}</p>
704
704
  <button onClick={() => handleDeleteEvent(event.eventId!)}>Delete</button>
705
705
  </div>
706
706
  ))}